@orkestrel/scaffold 0.0.1

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/dist/bin/scaffold.js +1539 -0
  4. package/dist/bin/scaffold.js.map +1 -0
  5. package/dist/host/AGENTS.md +939 -0
  6. package/dist/host/CLAUDE.md +495 -0
  7. package/dist/host/LICENSE +21 -0
  8. package/dist/host/claude/agents/builder.md +48 -0
  9. package/dist/host/claude/agents/checker.md +37 -0
  10. package/dist/host/claude/agents/composer.md +64 -0
  11. package/dist/host/claude/agents/grok.md +50 -0
  12. package/dist/host/claude/agents/orkestrel.md +236 -0
  13. package/dist/host/claude/agents/planner.md +44 -0
  14. package/dist/host/claude/agents/researcher.md +38 -0
  15. package/dist/host/claude/agents/reviewer.md +47 -0
  16. package/dist/host/claude/agents/scout.md +35 -0
  17. package/dist/host/claude/agents/verifier.md +34 -0
  18. package/dist/host/claude/settings.json +26 -0
  19. package/dist/host/dotfiles/editorconfig +17 -0
  20. package/dist/host/dotfiles/gitattributes +3 -0
  21. package/dist/host/dotfiles/gitignore +40 -0
  22. package/dist/host/dotfiles/oxfmtrc.json +18 -0
  23. package/dist/host/dotfiles/oxlintignore +20 -0
  24. package/dist/host/dotfiles/oxlintrc.json +58 -0
  25. package/dist/host/dotfiles/prettierignore +5 -0
  26. package/dist/host/github/workflows/ci.yml +64 -0
  27. package/dist/host/guides/src/guide.md +312 -0
  28. package/dist/host/guides/src/scaffold.md +2152 -0
  29. package/dist/host/manifest.json +137 -0
  30. package/dist/host/scripts/cursor.sh +74 -0
  31. package/dist/host/scripts/deps.sh +38 -0
  32. package/dist/host/scripts/ollama.sh +163 -0
  33. package/dist/src/core/index.cjs +3728 -0
  34. package/dist/src/core/index.cjs.map +1 -0
  35. package/dist/src/core/index.d.cts +1941 -0
  36. package/dist/src/core/index.d.ts +1941 -0
  37. package/dist/src/core/index.js +3636 -0
  38. package/dist/src/core/index.js.map +1 -0
  39. package/dist/src/server/index.cjs +1595 -0
  40. package/dist/src/server/index.cjs.map +1 -0
  41. package/dist/src/server/index.d.cts +779 -0
  42. package/dist/src/server/index.d.ts +779 -0
  43. package/dist/src/server/index.js +1572 -0
  44. package/dist/src/server/index.js.map +1 -0
  45. package/package.json +113 -0
@@ -0,0 +1,2152 @@
1
+ # Scaffold
2
+
3
+ > A synchronous, deterministic **package-blueprint compiler** for the `@orkestrel` line:
4
+ > a closed, JSON-serializable **`Blueprint`** (name, surfaces, dependencies, overrides…)
5
+ > is compiled into a **`Plan`** — an ordered list of **`Artifact`**s, each carrying an
6
+ > `origin` that says whether its content was host-copied, template-filled, or computed —
7
+ > and every downstream product (the files on disk, a review document, an audit of an
8
+ > existing package, a dry-run summary) is **projected** from that one `Plan`, never
9
+ > authored separately.
10
+ >
11
+ > FORWARD: a `Blueprint` is **drafted** into artifacts — the per-surface variant matrix as
12
+ > data (`SURFACE_MATRIX`) selects the `exports` map, the per-surface configs, and the test
13
+ > projects; caller **`overrides`** layer over the shipped defaults — the fail-closed
14
+ > **gate** validates the name, surfaces, and dependencies, and a passing plan is **pinned**
15
+ > (`trace` + `hash` derived from content, never authored).
16
+ >
17
+ > REVERSE: `planToReview` / `planToSummary` render the plan for humans; `diffPlan` audits
18
+ > it against a target's current content and returns **drift findings as data**; the server
19
+ > surface's `Materializer` is the impure WRITE step.
20
+ >
21
+ > LIVE: the server surface's `Sync` entity fetches each declared dependency's guide and
22
+ > registry version from upstream — reporting freshness, refreshing mirrors under an explicit
23
+ > apply — the ONLY part of the system that touches the network.
24
+ >
25
+ > This module runs no `git`, invokes no `npm`, and embeds no LLM; its only network access is
26
+ > the server `Sync` entity's read-only fetch of upstream guides and registry versions. The
27
+ > core is pure (no `node:*`, no clocks, no randomness — `trace` and `hash` derive from
28
+ > content alone), and writing lives behind an explicit apply on the server surface. A
29
+ > blueprint that fails the gate yields a visible INCOMPLETE `Scaffolding` carrying the
30
+ > questions — a half-formed package is worse than a question, so the gate fails closed rather
31
+ > than emitting.
32
+ > Every discriminant names its axis, never `kind` / `type` (AGENTS §4.4): `origin` splits
33
+ > how an artifact's content is produced, `group` splits the artifact groups, `surface`
34
+ > splits the environment faces, `category` splits declared members, `drift` splits audit
35
+ > verdicts, `freshness` splits sync currency, `stage` splits the pipeline phases, `code`
36
+ > splits coded errors. Source: [`src/core`](../../src/core) + [`src/server`](../../src/server)
37
+ >
38
+ > - the [`src/bin`](../../src/bin) CLI. The core surfaces through `@src/core`, the materializer
39
+ > and sync through `@src/server`; the bin is an executable, not a barrel.
40
+
41
+ The problem this module solves: standing up (or auditing) an `@orkestrel` package is a
42
+ mechanical projection of the line's conventions onto a name — the exports map for the
43
+ variant, the per-surface build configs, the barrels, the guide stubs, the parity harness —
44
+ and this package IS that projection: creation and repair through the bin's `new` / `pull` /
45
+ `audit` / `repair` verbs, and fleet-wide propagation through the bin's `fleet` verb.
46
+ Rendered defaults ship as **versioned package data** — frozen `TemplateDefinition`s filled
47
+ by `@orkestrel/template`'s pure engine — so a convention change is a version bump here, not
48
+ a hand-edit in every repo. The module is deliberately **mechanism, never policy** (AGENTS
49
+ §21): the judgment calls (the name, the description, the keywords, which surfaces, which
50
+ dependencies, any template override) belong to the caller — a human, or an agent following
51
+ a `/scaffold` command — while this module supplies the closed vocabularies, the variant
52
+ matrix as data, the exact-record validation, the fail-closed gate, the deterministic pin,
53
+ and the lossless projections. Separating the WHAT (the `Blueprint`) from the HOW (the
54
+ `Plan` and its writes) is the whole design: because the plan and the audit are pure data,
55
+ the same engine that _creates_ a package can **audit** an existing one (`diffPlan` against
56
+ its current content — the per-file conformance checklist, now returned as
57
+ findings) and **repair** only what drifted. Scaffold is the line's conformance engine, not
58
+ merely its generator. And because the vendored dependency mirrors and pinned ranges
59
+ themselves drift as upstream moves, the server `Sync` entity is the freshness arm — it
60
+ fetches each declared `@orkestrel` dependency's guide and registry version from upstream and
61
+ reports (or, under an explicit apply, refreshes) what has fallen behind.
62
+
63
+ The compiler's core stands on four runtime dependencies — `@orkestrel/contract` (the shape
64
+ DSL behind the `Blueprint` / `Plan` contracts), `@orkestrel/emitter` (the observation
65
+ side-channels), `@orkestrel/markdown` (the AST + `renderMarkdown` the guide-table emitter
66
+ rides), and `@orkestrel/template` (whose pure `fillTemplate` LEAF carries the rendered
67
+ defaults, with NO `TemplateManager` inside the compiler — the core stays pure and
68
+ stateless). The bin adds two more, consumed ONLY at the executable: `@orkestrel/terminal`
69
+ (interactive blueprint prompts) and `@orkestrel/console` (the reporter + spinner). Because
70
+ `@orkestrel/terminal` is an L3 package, `@orkestrel/scaffold` sits at **L4** in the line's
71
+ dependency layering.
72
+
73
+ ## Surface
74
+
75
+ Compile a `Blueprint` into a `Scaffolding`, then project the `Plan` it carries — the whole
76
+ core path is pure and synchronous; writing lives on the server surface:
77
+
78
+ ```ts
79
+ import { blueprint, createCompiler, dependency, planToReview } from '@orkestrel/scaffold'
80
+
81
+ const compiler = createCompiler()
82
+
83
+ const scaffolding = compiler.compile(
84
+ blueprint('router', {
85
+ description: 'A tiny hash-router. Part of the @orkestrel line.',
86
+ keywords: ['router', 'hash', 'spa'],
87
+ surfaces: ['core', 'browser', 'server'],
88
+ dependencies: [dependency('@orkestrel/contract', '^0.0.5')],
89
+ }),
90
+ )
91
+
92
+ scaffolding.complete // true — the gate passed
93
+ if (scaffolding.plan) {
94
+ scaffolding.plan.artifacts.length // every file the package needs, ordered
95
+ planToReview(scaffolding.plan) // the copy-ready dry-run review document
96
+ }
97
+
98
+ compiler.emitter.on('block', (questions) => questions.length)
99
+ compiler.destroy()
100
+ ```
101
+
102
+ `compile()` is genuinely SYNCHRONOUS and runs the fixed three-stage pipeline
103
+ `[draft, gate, pin]`; a failing gate yields a visible INCOMPLETE `Scaffolding` (`plan`
104
+ absent, `questions` populated) rather than throwing. Compilation, review, summary, and
105
+ audit are ALL pure — no clocks, no randomness, no `node:*`, no I/O. The package has THREE
106
+ faces: the pure **core** (`@orkestrel/scaffold`), the server face
107
+ (`@orkestrel/scaffold/server`) — the impure `Materializer` writes and the `Sync` fetches —
108
+ and the **bin** CLI (`src/bin/scaffold.ts`, the `scaffold` executable). The Surface below
109
+ documents the two LIBRARY faces, marked **(server)** where server-only; the bin is an
110
+ EXECUTABLE, not a barrel — it exports NO public members, so `SURFACES` stays closed at three
111
+ (`Surface` names the SCAFFOLDED package's environment faces, unrelated to scaffold's own
112
+ three code faces). The core and the `Materializer` are deterministic and synchronous; the
113
+ bin AND the server `Sync` entity are legitimately Promise-based — the bin's interactive
114
+ prompt flow (`@orkestrel/terminal`) and `Sync`'s upstream fetches are async orchestration
115
+ AROUND the synchronous `compile` / write, never inside them.
116
+
117
+ ### Types
118
+
119
+ | Type | Kind | Shape |
120
+ | ----------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
121
+ | `Surface` | type | `'core' \| 'browser' \| 'server'` — the environment surface an artifact or member belongs to (the SCAFFOLDED package's faces, not scaffold's own). |
122
+ | `Origin` | type | `'host' \| 'template' \| 'computed'` — how an `Artifact`'s content is produced: `host` byte-copied from the vendored data root, `template` filled from a frozen `TemplateDefinition` by `@orkestrel/template`'s pure fill engine, `computed` derived by the core's manifest/exports combination logic; the axis that decides whether it carries `source` (host) or `content` (template / computed). |
123
+ | `Group` | type | `'manifest' \| 'configs' \| 'source' \| 'tests' \| 'guides' \| 'docs' \| 'orchestration'` — the closed artifact-group vocabulary a plan selects over. |
124
+ | `Category` | type | `'type' \| 'constant' \| 'factory' \| 'entity'` — what a declared `Member` IS in the scaffolded surface. |
125
+ | `CatalogEntry` | interface | `{ name, version, description }` — one fleet package's catalog row; `description` is the flattened text of that package's guide's FIRST blockquote, `''` when the guide is missing, unreadable, or carries no blockquote. |
126
+ | `Drift` | type | `'aligned' \| 'stale' \| 'missing' \| 'foreign'` — one `Finding`'s verdict against the target's current content. |
127
+ | `Freshness` | type | `'current' \| 'behind' \| 'missing' \| 'failed'` — one `GuideSync` / `VersionSync`'s currency against upstream (`missing` = an upstream `404`, `failed` = a transport fault). |
128
+ | `CompileStage` | type | `'draft' \| 'gate' \| 'pin'` — the three fixed pipeline phases, in order. |
129
+ | `ScaffoldErrorCode` | type | `'INVALID' \| 'BLOCKED' \| 'DESTROYED' \| 'TARGET' \| 'WRITE' \| 'FETCH'` — coded `ScaffoldError` reasons. |
130
+ | `Dependency` | interface | `{ name, range, optional? }` — one runtime `@orkestrel/*` dependency; drives its `package.json` entry, the build externals, and its `guides/src/<dep>.md` mirror — byte-correct for a dep this package vendors (contract / emitter / markdown / template / terminal / console / guide), a `host`-origin POINTER the caller syncs otherwise. `optional` is meaningful only when the `Dependency` appears in a `Blueprint`'s `peers` — `true` emits a `peerDependenciesMeta` `{ optional: true }` entry alongside it. |
131
+ | `Override` | interface | `{ path, content }` — one caller template override; `content` REPLACES the rendered artifact at `path`, never partially merges. An override whose `path` matches no planned artifact, or targets a `host`-origin path, is a BLOCKING question — never a silent add. |
132
+ | `Blueprint` | interface | `{ name, description, keywords, surfaces, dependencies, peers, extras, version, engines, overrides }` — the closed, JSON-serializable package spec. `peers` are runtime `@orkestrel/*` peers emitted as `peerDependencies` (an `optional` peer also gets a `peerDependenciesMeta` entry); `extras` are package-specific `devDependencies` merged into the generated uniform baseline (extras win on a name collision) — the middleware pattern of shipping `@orkestrel/{database,router,server}` for its tests. |
133
+ | `Member` | interface | `{ name, category, summary, surface }` — one declared public export of the scaffolded package; derived by `blueprintToMembers`, never authored. |
134
+ | `Artifact` | interface | `{ path, group, origin, surface?, content?, source? }` — one file in a `Plan`; `content` present for `template` / `computed`, `source` (a host-relative path) for `host`. |
135
+ | `Plan` | interface | `{ blueprint, groups, artifacts, trace?, hash? }` — the compiled, ordered artifact list plus the selection it covers; `trace` / `hash` filled by the pin. |
136
+ | `Finding` | interface | `{ path, group, drift }` — one audit drift result. |
137
+ | `Audit` | interface | `{ findings, clean, complete, questions, drifted, missing, foreign }` — the whole diff of a plan against a target's content; a `Compiler.audit` over a gate-failing blueprint sets `complete: false` with the gate's `questions` and zero findings, while `diffPlan` over an existing plan is always `complete: true`. |
138
+ | `Question` | interface | `{ field, text, blocking, candidates? }` — one validation issue; `blocking: true` fails the gate closed, `false` is an advisory that rides a complete result. |
139
+ | `Validation` | interface | `{ valid, questions, warnings }` — the semantic pass over a blueprint; returns, never throws. |
140
+ | `GuideSync` | interface | `{ name, path, content, freshness, note? }` — one dependency guide fetched from upstream (`content`) at its `path`, plus its `freshness` verdict against the caller-supplied reference (see `guides`). `note` carries the failure/anomaly CAUSE (an `HTTP <status>`, a transport message, a redirect-blocked or oversized-body notice) on any non-`current` outcome that has one — absent on `current` and `behind`. |
141
+ | `VersionSync` | interface | `{ name, range, latest, freshness, note? }` — one dependency's declared `range` against the registry `latest`, plus its `freshness` verdict. `note` carries the failure/anomaly CAUSE — see `GuideSync.note`. |
142
+ | `SyncReport` | interface | `{ target, guides, versions, clean, failed }` — the whole outcome of a `Sync.pull`: the fetched `guides` + `versions`, `clean` (no drift AND no failures), and the `failed` count. |
143
+ | `PlanSummary` | interface | `{ name, surfaces, groups, artifacts, host, template, computed }` — the dry-run tally. |
144
+ | `CompileRecord` | interface | `{ stage, input, output, failed, error? }` — a structured input/output snapshot of one pipeline phase. |
145
+ | `CompileFailure` | interface | `{ stage, code, message }` — a visible marker for a stage that failed. |
146
+ | `Scaffolding` | interface | `{ blueprint, plan?, questions, stages, failures, complete, digest }` — the full, replayable outcome of one `compile()` call. |
147
+ | `PlanRecord` | interface | `{ id, plan, version, hash }` — a versioned, content-hashed `Plan` inside a `PlanManager`. |
148
+ | `MaterializeResult` | interface | `{ target, written, copied, skipped, removed }` — the outcome of one materialization **(server)**; `removed` lists paths a `prune` deleted. |
149
+ | `ManifestEntry` | interface | `{ storage, destination, executable }` — one entry of the vendored `host/manifest.json` **(server)**; `storage` is the un-dotted vendored path, `destination` the destination-relative path a caller-supplied artifact `source` resolves against, `executable` a post-copy `chmod` bit. |
150
+ | `CompilerEventMap` | type | `Compiler`'s push observation surface (AGENTS §13) — `compile(scaffolding)` · `audit(audit)` · `block(questions)` · `error(error)` · `destroy()`. |
151
+ | `CompilerOptions` | interface | `{ on?, error? }` — input to `createCompiler`. |
152
+ | `CompilerInterface` | interface | The compilation orchestrator contract — `emitter` + `compile` / `audit` / `destroy`. |
153
+ | `PlanManagerEventMap` | type | `PlanManager`'s push observation surface — `add(id)` · `remove(id)` · `destroy()`. |
154
+ | `PlanManagerOptions` | interface | `{ plans?, on?, error? }` — input to `createPlanManager`. |
155
+ | `PlanManagerInterface` | interface | The plan registry contract (AGENTS §9) — `emitter` / `size` + `has` / `plan` / `plans` / `add` / `remove` / `destroy`. |
156
+ | `MaterializerEventMap` | type | `Materializer`'s push observation surface **(server)** — `copy(path)` · `write(path)` · `remove(path)` · `done(result)` · `error(error)` · `destroy()`. |
157
+ | `MaterializerOptions` | interface | `{ host?, on?, error? }` — input to `createMaterializer` **(server)**; `host` is the vendored-data root host-origin artifacts are copied FROM, defaulting to the PACKAGE's own vendored `dist/host` — resolved from the installed module's own location via a file URL, never `process.cwd()` — so a caller-supplied `host` (a sibling repo, for `mirror`) maps 1:1 onto the same host-relative paths. |
158
+ | `MaterializerInterface` | interface | The materialization contract **(server)** — `emitter` + `materialize` / `repair` / `prune` / `destroy`. |
159
+ | `SyncEventMap` | type | `Sync`'s push observation surface **(server)** — `guide(name)` · `version(name)` · `package(name, note)` · `write(path)` · `done(report)` · `error(error)` · `destroy()`. |
160
+ | `SyncOptions` | interface | `{ on?, error?, guides?, registry?, concurrency?, retries?, strict?, limit? }` — input to `createSync` **(server)**; the endpoint bases + branch are INJECTABLE (`guides.base` default `raw.githubusercontent.com`, `guides.branch` default `main`, `registry.base` default `registry.npmjs.org`, `guides.timeout` / `registry.timeout` default 10s — `registry.base` also anchors `catalog()`'s org package-list + packument fetches), `concurrency` default 6, `retries` default 0, `strict` default false, `limit` (max response body bytes; declared `Content-Length` or streamed total, whichever trips first — an overflow is a transport fault) default 5,242,880 (5 MiB). Every fetch is UNAUTHENTICATED — no token option exists; every fleet repo is public. |
161
+ | `SyncInterface` | interface | The upstream-synchronization contract **(server)** — `emitter` + `guides` / `versions` / `catalog` / `pull` / `write` / `destroy`. |
162
+
163
+ The `Blueprint` and the `Plan` are the two closed contracts — every field is a `string`,
164
+ `readonly` array, or record, so both round-trip JSON and both cross a tool / RPC boundary
165
+ unchanged. `Artifact` is discriminated by `origin`: a `host` artifact names a `source` (the
166
+ host-relative path the server byte-copies from the vendored data root) and carries NO
167
+ `content` (the pure core never reads host bytes); a `template` artifact carries `content`
168
+ FILLED from a frozen `TemplateDefinition` by `@orkestrel/template`'s pure `fillTemplate`
169
+ engine (`missing: 'error'` — an unresolved token fails loud, never silently blanks); a
170
+ `computed` artifact carries `content` DERIVED by the core's own manifest/exports combination logic
171
+ (the `exports` map, the entry re-pointing). The token-collision boundary is a HARD rule:
172
+ only genuinely templated PROSE artifacts (the README, the guide stubs, file headers) pass
173
+ through the fill engine; a STRUCTURAL file (a JSON or TS config, anything that could
174
+ legitimately contain a literal `{{…}}`) is ALWAYS `computed`, never `template`, so a stray
175
+ `{{` in a tsconfig can never be mistaken for a placeholder. That single `origin` axis is
176
+ what keeps the core pure while still describing files it cannot itself read.
177
+
178
+ ### Constants
179
+
180
+ | API | Kind | Summary |
181
+ | ------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
182
+ | `SURFACES` | const | The three `Surface` values, frozen — compose with `literalOf(...)` / `parseEnum(...)`. |
183
+ | `ORIGINS` | const | The three `Origin` values, frozen. |
184
+ | `GROUPS` | const | The seven `Group` values, frozen — the artifact-group selection vocabulary. |
185
+ | `CATEGORIES` | const | The four `Category` values, frozen. |
186
+ | `FRESHNESS` | const | The four `Freshness` values, frozen — the currency axis `Sync` reports on. |
187
+ | `COMPILE_STAGES` | const | `['draft', 'gate', 'pin']`, frozen — the pipeline phases in order. |
188
+ | `SURFACE_MATRIX` | const | The per-surface variant matrix as data: per `Surface`, its `configs/src` files, Vitest project label, `exports` subpath, and build formats — the per-surface layer `blueprintToPlan` reads BENEATH the manifest and exports combination rules it applies on top. |
189
+ | `HOST_PATHS` | const | The byte-copied host artifact paths (`AGENTS.md`, `CLAUDE.md`, `LICENSE`, `.claude`, `scripts/deps.sh`, `scripts/cursor.sh`, `scripts/ollama.sh` — the SessionStart hooks, orchestration-grouped — `guides/src/guide.md` — the line-wide dev-tooling guide — and `guides/src/scaffold.md` — the scaffold engine's own self-guide, both guides-grouped — `.editorconfig`, `.gitattributes`, `.gitignore`, `.oxfmtrc.json`, `.oxlintrc.json`, `.oxlintignore`, `.prettierignore`, `.github/workflows/ci.yml`), frozen — the shared artifacts every `@orkestrel` repo's vendored host carries. |
190
+ | `SCAFFOLD_RANGE` | const | `'^0.0.1'` — the exact devDependency range a scaffolded package's `package.json` pins `@orkestrel/scaffold` at. |
191
+ | `NAME_PATTERN` | const | The `/^[a-z][a-z0-9-]*$/` package-name RegExp, closed vocabulary as data. |
192
+ | `DEPENDENCY_NAME_PATTERN` | const | The `/^@orkestrel\/[a-z][a-z0-9-]*$/` dependency-name RegExp — every `Dependency.name` must be `@orkestrel`-scoped and NAME_PATTERN-shaped after the scope, closing the traversal vector a hand-built `../`-laced name would open through the pointer-artifact and `Sync.write` path derivation. |
193
+ | `EXTRA_NAME_PATTERN` | const | The `extras` dependency-name RegExp — an optional single `@scope/` prefix, then lowercase letters/digits/hyphens/dots/underscores (never leading). Broader than `DEPENDENCY_NAME_PATTERN` on purpose: `extras` names are `devDependencies`-content only, never path-derived, so any valid npm package name (not just `@orkestrel/*`) is accepted while staying structurally traversal-closed. |
194
+ | `DEFAULT_VERSION` | const | `'0.0.1'` — the starting version the `blueprint` builder fills. |
195
+ | `DEFAULT_ENGINES` | const | `'>=22'` — the `engines.node` range the `blueprint` builder fills. |
196
+ | `COMPILER_ID` | const | `'compiler'` — the default id for a `Compiler` orchestrator. |
197
+ | `TEMPLATES` | const | The shipped, versioned `TemplateDefinition` data every `template`-origin artifact fills against (README, the own-guide stub, the guides index, the per-surface source stubs, the shared test recorder plus `parityTest` — the frozen `tests/guides/src/parity.test.ts` body — and `setupServer` / `setupBrowser`, the per-surface test-setup stubs) — placeholders documented per entry, frozen. |
198
+
199
+ ```ts
200
+ import {
201
+ CATEGORIES,
202
+ DEPENDENCY_NAME_PATTERN,
203
+ EXTRA_NAME_PATTERN,
204
+ FRESHNESS,
205
+ GROUPS,
206
+ HOST_PATHS,
207
+ NAME_PATTERN,
208
+ ORIGINS,
209
+ SCAFFOLD_RANGE,
210
+ SURFACES,
211
+ TEMPLATES,
212
+ } from '@orkestrel/scaffold'
213
+
214
+ SURFACES // ['core', 'browser', 'server']
215
+ ORIGINS // ['host', 'template', 'computed']
216
+ GROUPS // ['manifest', 'configs', 'source', 'tests', 'guides', 'docs', 'orchestration']
217
+ CATEGORIES // ['type', 'constant', 'factory', 'entity']
218
+ FRESHNESS // ['current', 'behind', 'missing', 'failed']
219
+ SCAFFOLD_RANGE // '^0.0.1' — the pinned devDependency range for @orkestrel/scaffold
220
+ NAME_PATTERN.test('router') // true
221
+ NAME_PATTERN.test('Router') // false — the package-name law rejects a leading capital
222
+ DEPENDENCY_NAME_PATTERN.test('@orkestrel/contract') // true
223
+ DEPENDENCY_NAME_PATTERN.test('@orkestrel/../etc') // false — closes the traversal vector
224
+ EXTRA_NAME_PATTERN.test('zod') // true — extras accept any valid npm package name
225
+ EXTRA_NAME_PATTERN.test('@types/node') // true
226
+ EXTRA_NAME_PATTERN.test('../etc') // false — still structurally traversal-closed
227
+ HOST_PATHS.includes('scripts/deps.sh') // true — orchestration-grouped host artifact
228
+ HOST_PATHS.includes('src/core/index.ts') // false — the host set covers shared artifacts, not source
229
+ TEMPLATES.entity.placeholders // [{ name: 'pascal', … }] — the entity stub's one token
230
+ ```
231
+
232
+ A closed-set field that does not fit a listed value is a signal the request is mis-scoped,
233
+ not licence to invent a value — the exact-record validators below reject an off-vocabulary
234
+ literal, and the shapers compile the same tuples into the JSON Schema `enum`s, so the
235
+ vocabulary cannot drift between the guard, the parser, and the schema.
236
+
237
+ ### Errors
238
+
239
+ | API | Kind | Summary |
240
+ | ----------------- | -------- | --------------------------------------------------- |
241
+ | `ScaffoldError` | class | Carries a `ScaffoldErrorCode` + optional `context`. |
242
+ | `isScaffoldError` | function | Narrow a caught value to a `ScaffoldError`. |
243
+
244
+ ```ts
245
+ import { isScaffoldError, ScaffoldError } from '@orkestrel/scaffold'
246
+
247
+ try {
248
+ throw new ScaffoldError('INVALID', 'Blueprint failed the exact-record contract')
249
+ } catch (error) {
250
+ if (isScaffoldError(error)) error.code // 'INVALID'
251
+ }
252
+ ```
253
+
254
+ Throws are reserved for caller misuse (AGENTS §12): `createBlueprint` on off-contract data
255
+ throws `INVALID`, any method after `destroy()` throws `DESTROYED`, and on the server surface
256
+ a non-vacant target throws `TARGET` while a failed write throws `WRITE`. A failing gate is
257
+ NOT an error — it fails closed into an incomplete `Scaffolding` whose `failures` carry a
258
+ `BLOCKED` marker, mirroring the brief compiler's visible-incomplete outcome. `FETCH` is
259
+ server/bin-only: the `Sync` entity throws it ONLY under `strict` mode when an upstream fetch
260
+ fails (or on wrap-level misuse), naming the failing URL in `context`; in the default COLLECT
261
+ mode a per-dependency `404` becomes `freshness: 'missing'` and any transport error / other
262
+ non-2xx becomes `freshness: 'failed'`, captured on the `SyncReport` rather than thrown.
263
+
264
+ ### Validators
265
+
266
+ Total guards (AGENTS §14) COMPILED from the shapers below via the contract package's
267
+ `createContract` — one shape declaration is the single source, so `isBlueprint`,
268
+ `parseBlueprint`, and the JSON Schema can never drift. Adversarial input (junk, cycles,
269
+ hostile prototypes) returns `false`, never throws. Every record guard is EXACT: an extra
270
+ key fails, which is why the builders below omit absent optional keys.
271
+
272
+ | API | Kind | Narrows to |
273
+ | -------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
274
+ | `isDependency` | const | `Dependency` — `name` a non-empty string, `range` a non-empty string, `optional` (when present) a boolean. |
275
+ | `isOverride` | const | `Override` — `path` / `content` non-empty strings. |
276
+ | `isBlueprint` | const | `Blueprint` — `surfaces` on-vocabulary and non-empty; `name` a non-empty string (the `NAME_PATTERN` law is the semantic pass's, not the guard's). |
277
+ | `isMember` | const | `Member` — `category` an on-vocabulary `Category`, `surface` an on-vocabulary `Surface`. |
278
+ | `isArtifact` | const | `Artifact` — `group` / `origin` on-vocabulary; `content` xor `source` per `origin`. |
279
+ | `isPlan` | const | `Plan` — the whole exact-record contract, section guards composed. |
280
+ | `isSyncReport` | const | `SyncReport` — the whole exact-record sync contract, `guide` / `version` sections composed. |
281
+
282
+ ```ts
283
+ import {
284
+ blueprint,
285
+ isBlueprint,
286
+ isDependency,
287
+ isPlan,
288
+ validateBlueprint,
289
+ } from '@orkestrel/scaffold'
290
+
291
+ isDependency({ name: '@orkestrel/contract', range: '^0.0.5' }) // true
292
+ isBlueprint({ name: 'router', surfaces: ['core'] }) // false — sections missing (exact record)
293
+
294
+ // NAME_PATTERN is the semantic pass's job, not the shape's — so the guard passes an
295
+ // off-pattern name and validateBlueprint is what rejects it:
296
+ const offPattern = blueprint('Router', { surfaces: ['core'] }) // a complete spec; name off NAME_PATTERN
297
+ isBlueprint(offPattern) // true — the shape polices STRUCTURE only
298
+ validateBlueprint(offPattern).valid // false — the semantic pass owns the NAME_PATTERN law
299
+
300
+ isPlan({ blueprint: {}, groups: [], artifacts: [] }) // false — blueprint off-contract
301
+ ```
302
+
303
+ ### Parsers
304
+
305
+ The coercing counterparts of the guards, COMPILED from the same shapes through the contract
306
+ package's `createContract` — a guard-valid value round-trips unchanged, an off-contract
307
+ value returns `undefined`, and neither ever throws (AGENTS §14). This is the parse-then-trust
308
+ boundary for a stored plan, a tool argument, or an agent's emission.
309
+
310
+ | API | Kind | Returns |
311
+ | ----------------- | ----- | ---------------------------------------------------------------- |
312
+ | `parseBlueprint` | const | a `Blueprint` from `unknown` / a JSON string, else `undefined`. |
313
+ | `parsePlan` | const | a `Plan` from `unknown` / a JSON string, else `undefined`. |
314
+ | `parseSyncReport` | const | a `SyncReport` from `unknown` / a JSON string, else `undefined`. |
315
+
316
+ ```ts
317
+ import { blueprint, isBlueprint, parseBlueprint } from '@orkestrel/scaffold'
318
+
319
+ const json = JSON.stringify(blueprint('router', { surfaces: ['core'] })) // a complete, on-contract spec
320
+ const parsed = parseBlueprint(json) // Blueprint | undefined
321
+ parsed && isBlueprint(parsed) // true — a non-undefined parse always satisfies the guard
322
+ parseBlueprint('{"name":"router"}') // undefined — sections missing (exact record), never throws
323
+ ```
324
+
325
+ ### Shapers
326
+
327
+ The `Blueprint` and `Plan` contracts declared ONCE as contract `ContractShape` values
328
+ (AGENTS §14 heavy machinery, earned here: validation, the JSON Schema a tool boundary needs,
329
+ and seeded test blueprints must stay in lockstep). Each shaper is a function returning a
330
+ fresh shape value; `blueprintShape()` / `planShape()` compose the section shapes, and the
331
+ module's own validators and parsers are compiled from them at the barrel.
332
+
333
+ | API | Kind | Builds… |
334
+ | ----------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
335
+ | `dependencyShape` | function | the `Dependency` object shape. |
336
+ | `overrideShape` | function | the `Override` object shape. |
337
+ | `blueprintShape` | function | the `Blueprint` object shape — `surfaces` a `literalShape(SURFACES)` array with `min: 1`; `peers` / `extras` are `dependencyShape()` arrays alongside `dependencies`; `name` a plain `min: 1` string, NOT pattern-constrained, so `generate` stays satisfiable (the `NAME_PATTERN` law, and the cross-array uniqueness/overlap rules, live in the semantic pass). |
338
+ | `memberShape` | function | the `Member` object shape — `category` / `surface` literal shapes. |
339
+ | `artifactShape` | function | the `Artifact` object shape — `origin` a `literalShape(ORIGINS)`; `content` / `source` optional. |
340
+ | `planShape` | function | the whole `Plan` object shape, section shapes composed; `trace` / `hash` optional. |
341
+ | `syncReportShape` | function | the `SyncReport` object shape — `guide` + `version` array sub-shapes (each with a `literalShape(FRESHNESS)`) composed; `isSyncReport` / `parseSyncReport` compile from it. |
342
+
343
+ ```ts
344
+ import {
345
+ artifactShape,
346
+ blueprintShape,
347
+ dependencyShape,
348
+ memberShape,
349
+ overrideShape,
350
+ planShape,
351
+ syncReportShape,
352
+ } from '@orkestrel/scaffold'
353
+ import { createContract, schemaToParameters, seededRandom } from '@orkestrel/contract'
354
+
355
+ const contract = createContract(blueprintShape())
356
+ contract.schema // the full JSON Schema — hand to a tool boundary via schemaToParameters
357
+ contract.generate(seededRandom(42)) // a reproducible, on-contract seed blueprint for tests
358
+ schemaToParameters(contract.schema) // the open tool-parameters record, no `as` anywhere
359
+
360
+ // The section shapes `blueprintShape` / `planShape` compose — each is a fresh, independent
361
+ // `ContractShape` value, usable on its own contract:
362
+ createContract(dependencyShape()).schema // the `Dependency` section schema alone
363
+ createContract(overrideShape()).schema // the `Override` section schema alone
364
+ createContract(memberShape()).schema // the `Member` section schema alone
365
+ createContract(artifactShape()).schema // the `Artifact` section schema alone
366
+ createContract(planShape()).schema // the whole `Plan` schema, section shapes composed
367
+ createContract(syncReportShape()).schema // the `SyncReport` schema — the compiled `isSyncReport` source
368
+ ```
369
+
370
+ ### Builders
371
+
372
+ Lowercase value builders — every builder returns a fresh object and OMITS absent optional
373
+ keys entirely, so its output round-trips the exact-record validators above.
374
+
375
+ | API | Kind | Builds… |
376
+ | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
377
+ | `dependency` | function | a `Dependency` from name / range / optional `optional` flag (omitted entirely when absent). |
378
+ | `override` | function | an `Override` from path / content. |
379
+ | `member` | function | a `Member` from name / category / summary / surface (`surface` defaults `'core'`). |
380
+ | `blueprint` | function | a `Blueprint` from a name + a partial of the rest — `version` / `engines` default (`DEFAULT_VERSION` / `DEFAULT_ENGINES`), `surfaces` defaults `['core']`, and `keywords` / `dependencies` / `peers` / `extras` / `overrides` default `[]`. |
381
+
382
+ ```ts
383
+ import { blueprint, dependency, override } from '@orkestrel/scaffold'
384
+
385
+ const spec = blueprint('router', {
386
+ description: 'A tiny hash-router. Part of the @orkestrel line.',
387
+ keywords: ['router', 'hash'],
388
+ surfaces: ['core', 'browser'],
389
+ dependencies: [dependency('@orkestrel/contract', '^0.0.5')],
390
+ overrides: [override('README.md', '# @orkestrel/router\n\nHand-written readme.\n')],
391
+ })
392
+ spec.version // '0.0.1' — the builder default
393
+ spec.engines // '>=22' — the builder default
394
+ ```
395
+
396
+ ### Compilers
397
+
398
+ Pure, exported leaves of `blueprintToPlan`'s pipeline (AGENTS §5 no-nested-functions law) —
399
+ one function per drafting concern, each independently unit-tested; `blueprintToPlan` (below,
400
+ Helpers) is the sole orchestrator that calls them in sequence, never the sole holder of the
401
+ logic itself.
402
+
403
+ | API | Kind | Contract |
404
+ | ------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
405
+ | `hostGroup` | function | Resolve the `Group` a byte-copied `HOST_PATHS` entry belongs to (docs / orchestration / guides / configs fallback). |
406
+ | `fillArtifact` | function | Fill one `TEMPLATES` entry into a `template`-origin `Artifact`, optionally tagged with the owning `Surface`. |
407
+ | `surfaceVariant` | function | Classify a blueprint's surfaces into the manifest/exports variant class — the sole declared `Surface`, or `'multi'` when two or more are declared. |
408
+ | `entryFields` | function | Build the `package.json` `main` / `module` / optional top-level `types` fields for a declared `Surface[]`. |
409
+ | `dualCondition` | function | Build one dual-format (`import` + `require`) `exports` condition block for an extensionless dist path. |
410
+ | `exportsMap` | function | Build the `package.json` `exports` map for a declared `Surface[]`. |
411
+ | `compareCodeUnit` | function | A code-unit (not locale-sensitive) string comparator — matches the `keywords` sort and keeps ordering stable across locales/environments. |
412
+ | `devDependenciesFor` | function | Merge a blueprint's `extras` (code-unit sorted) over the shared devDependency baseline, extras winning on a name collision. |
413
+ | `packageManifest` | function | Compute the `package.json` artifact's `content`, applying the manifest/exports combination rules over a blueprint's surfaces. |
414
+ | `rootTsconfig` | function | The root `tsconfig.json` — one `@src/<surface>` path alias per declared surface, in declared order. |
415
+ | `viteHeader` | function | The rendered import/`resolve` header block every `rootViteConfig` shape prefixes — the Playwright import lines + `createBrowserProvider` present only when `needsPlaywright`. |
416
+ | `singleSurfaceViteConfig` | function | The root `vite.config.ts` body for a single non-`core` surface — the surface's own `viteHeader` (Playwright only when the surface is `browser`) prefixes the factory that IS the base (Shape 3 of `rootViteConfig`). |
417
+ | `rootViteConfig` | function | The root `vite.config.ts` — three grounded shapes chosen by a blueprint's `surfaces` (core-only, multi-surface, single non-`core` surface). |
418
+ | `coreTsconfig` | function | `configs/src/tsconfig.core.json` — the unchanged core shape. |
419
+ | `coreViteConfig` | function | `configs/src/vite.core.config.ts` — inlines its own `build.lib` / `rollupOptions`. |
420
+ | `surfaceTsconfig` | function | `configs/src/tsconfig.<browser\|server>.json` — `rootDir`/`outDir` point at the whole `src`/`dist/src` tree. |
421
+ | `surfaceViteConfig` | function | `configs/src/vite.<browser\|server>.config.ts` — a thin `dts`-only wrapper anchored on the root `srcBrowser` / `srcServer` export. |
422
+ | `configArtifacts` | function | Draft the `configs` group's `computed` artifacts — the root `tsconfig.json` / `vite.config.ts` plus each declared surface's `configs/src/*` pair. |
423
+ | `sourceArtifacts` | function | Draft the `source` group's `template` artifacts — one full `{types, <Pascal>, factories, index}` stub set per declared surface. |
424
+ | `paritySpecifiers` | function | Build the computed `SELF_SPECIFIERS` / `SPECIFIER_MODULES` / `exportsFor` block the `parityTest` template's `{{specifiers}}` placeholder fills — one shape for every surface count. |
425
+ | `testArtifacts` | function | Draft the `tests` group's `template` artifacts — shared recorder setup, conditional per-surface environment setup, per-surface entity/factory test stubs, and the guides-parity drop-in. |
426
+ | `guideMemberTable` | function | Build an `alignTable` markdown table over a member category's rows, deduped by name — one row per declared member across all its surfaces. |
427
+ | `guideArtifacts` | function | Draft the `guides` group's artifacts — the package's own filled guide stub, the guides index, and any vendored dependency guide mirrors (the seven grounded `@orkestrel/*` names). |
428
+ | `applyOverrides` | function | Apply a blueprint's `overrides` over a drafted artifact list — an override REPLACES the matching artifact's `content` in place; a non-matching or `host`-origin-targeting override is left unapplied here (the gate stage surfaces it as a blocking question). |
429
+
430
+ ```ts
431
+ import { fillArtifact, hostGroup } from '@orkestrel/scaffold'
432
+
433
+ hostGroup('AGENTS.md') // 'docs'
434
+ hostGroup('.claude') // 'orchestration'
435
+ fillArtifact('README.md', 'docs', 'readme', { name: 'router', pascal: 'Router' })
436
+ // { path: 'README.md', group: 'docs', origin: 'template', content: '# router\n…' }
437
+ ```
438
+
439
+ ```ts
440
+ import {
441
+ compareCodeUnit,
442
+ devDependenciesFor,
443
+ dualCondition,
444
+ entryFields,
445
+ exportsMap,
446
+ surfaceVariant,
447
+ } from '@orkestrel/scaffold'
448
+
449
+ surfaceVariant(['core', 'server']) // 'multi'
450
+ entryFields(['browser']).main // './dist/src/browser/index.js'
451
+ dualCondition('./dist/src/core/index') // { import: {…}, require: {…} }
452
+ exportsMap(['core'])['.'] // dual import/require condition block
453
+ devDependenciesFor([])['typescript'] // '^6.0.3'
454
+ ;[...['b', 'a']].sort(compareCodeUnit) // ['a', 'b']
455
+ ```
456
+
457
+ ```ts
458
+ import { applyOverrides, override } from '@orkestrel/scaffold'
459
+
460
+ applyOverrides(
461
+ [{ path: 'README.md', group: 'docs', origin: 'template', content: '# old' }],
462
+ [override('README.md', '# custom')],
463
+ )[0].content // '# custom'
464
+ ```
465
+
466
+ ### Helpers
467
+
468
+ Pure, exported utility functions (AGENTS §4.3) — the referentially-transparent leaves
469
+ behind the `Compiler`, the `Sync` entity, and the projection surface. Projections use the
470
+ `{noun}To{Noun}` idiom (AGENTS §4.6.1): each consumes a WHOLE and returns a derived view of it.
471
+
472
+ | API | Kind | Summary |
473
+ | ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
474
+ | `blueprintToMembers` | function | Derive the declared public `Member[]` from a blueprint (name → Pascal → the canonical inventory per surface) — the SINGLE source both the source stubs and the guide Surface tables read. The skeleton vocabulary is deliberately the four `Category` buckets (`type` / `constant` / `factory` / `entity`); standalone helpers, validators, and shapers are hand-authored in implementation, not scaffolded. |
475
+ | `blueprintToPlan` | function | The full pure compilation, orchestrating the Compilers section's exported stages in sequence (never nesting them): draft the artifacts — `packageManifest` (the manifest and exports combination rules; multi-surface OMITS the top-level `package.json` `types`; a single-variant server-/browser-only retargets its lone surface to the `.` root, `main` / `module` re-pointed), `configArtifacts`, `sourceArtifacts`, `testArtifacts`, `guideArtifacts` OVER the per-surface `SURFACE_MATRIX` rows, plus `hostGroup`-classified `HOST_PATHS` and `applyOverrides` — then `pinPlan`; the computed `package.json` also emits `peerDependencies` / `peerDependenciesMeta` for `peers` (an `optional` peer gains a meta entry) and merges `extras` into the generated `devDependencies` baseline (an extra's range wins on a name collision), plus a `"scaffold": "scaffold"` script and a `'@orkestrel/scaffold': SCAFFOLD_RANGE` devDependency, so every scaffolded package ships already wired for its own future `repair` / `audit`; optionally scoped to a `Group[]` selection (default: all groups). |
476
+ | `pinPlan` | function | Return a fresh `Plan` with `trace` (the one-line derivation summary) and `hash` (a canonical structural digest, via `computeHash` over `stableStringify`) filled — deterministic, no timestamps, no run-specific data. |
477
+ | `computeHash` | function | Compute a canonical FNV-1a digest (32-bit offset basis/prime, `Math.imul` wraparound multiply) of a text string as an 8-hex-digit zero-padded string — deterministic, no clocks or randomness. |
478
+ | `stableStringify` | function | Serialize a value to a canonical, key-order-INDEPENDENT JSON-like string — object keys code-unit sorted, array order preserved — so two logically-equal blueprints built in a different field order still hash identically once fed through `computeHash`. |
479
+ | `validateBlueprint` | function | The semantic pass over a blueprint — name against `NAME_PATTERN` (and a 203-char bound, so the published `@orkestrel/<name>` fits npm's 214-character cap), non-empty on-vocabulary `surfaces` with no repeats (a single surface — `core`-only, `server`-only, `browser`-only — is fully first-class, no `core` required), the ONE exemplar-less combination — `browser`+`server` declared together with no `core` — is blocking ("The browser+server combination without core has no defined configuration class — declare core alongside them, or declare a single surface"; closes the silent surface-drop `rootViteConfig`'s dispatch would otherwise produce), well-formed `dependencies` / `peers` / `extras` via `validateDependencyArray` (non-empty name/range, no duplicate names within an array, and no name overlap ACROSS the three arrays — a name in both `dependencies` and `peers`, or an `extras` name repeating either, is blocking; `dependencies`/`peers` names are `DEPENDENCY_NAME_PATTERN`-shaped — closed to `@orkestrel/*`, since only those two arrays are path-derived — while `extras` names are `EXTRA_NAME_PATTERN`-shaped, broader, since `extras` is `devDependencies`-content only), `version` shaped `\d+.\d+.\d+`, `engines` shaped `>=\d+`, no duplicate override paths, and no empty override content. Returns a `Validation`, never throws. |
480
+ | `validateDependencyArray` | function | Validate one dependency-shaped array under the name/range/duplicate rules — pure, returns `{ questions, seen }` rather than mutating a closed-over array, so `validateBlueprint` can apply the cross-array (`dependencies` / `peers` / `extras`) overlap rules over the returned `seen` sets. |
481
+ | `manifestToDependencies` | function | Parse a `package.json` text into `readonly Dependency[]`, keeping the `DEPENDENCY_NAME_PATTERN` entries across `dependencies` / `devDependencies` / `peerDependencies` (all three, deduplicated) — pure, never throws. |
482
+ | `isRecord` | function | Narrow an unknown value to a plain (non-null, non-array) JSON object. |
483
+ | `rangeToFreshness` | function | Compare a declared `range` to the registry `latest`: `'current'` iff the range's `^0.0.N` exact pin equals `latest`, else `'behind'` (the `0.0.x` exact-pin law); the `missing` / `failed` verdicts come from the fetch layer, not this pure comparison. |
484
+ | `diffPlan` | function | The AUDIT projection: diff a plan's artifacts against a caller-supplied `Readonly<Record<string, string>>` of the target's current content, returning an `Audit` of drift findings — pure, no I/O. |
485
+ | `inferGroup` | function | Infer a foreign path's `Group` from its leading path segment — ordered prefix match (`src/`, `tests/`, `guides/`, `docs/`, `configs/`, `.github/` / `scripts/` as `orchestration`, then the two manifest file names), falling back to `configs`. |
486
+ | `planToReview` | function | Project a `Plan` into a copy-ready markdown review document — the artifact table by group, the members table, the summary; the diff-first dry run. |
487
+ | `auditToReview` | function | Project an `Audit` into a markdown drift report — findings grouped by `drift`, aligned entries elided; what `repair` will touch. |
488
+ | `syncToReview` | function | Project a `SyncReport` into a markdown freshness report via `alignTable` — the sibling of `auditToReview`, guides + versions grouped by `freshness`. |
489
+ | `isBehind` | function | Test whether a `Freshness` verdict counts toward "behind" — `true` iff `freshness` is `'behind'`. |
490
+ | `planToSummary` | function | Project a `Plan` into a `PlanSummary` — the artifact tally by `origin`, the surfaces, and the covered groups. |
491
+ | `pascalCase` | function | Derive the PascalCase entity name from a lowercase-hyphen package name (`'my-router'` → `'MyRouter'`) — hyphens are word breaks. |
492
+ | `alignTable` | function | Build a formatter-width-aligned GFM table string from header + row cell strings (+ optional `readonly TableAlign[]`) — the guide Surface-table emitter. |
493
+ | `catalogNames` | function | Extract the `@orkestrel/<name>` names from a catalog markdown block/table (the `orkestrel.md` embedded shape), in row order — pure, `[]` when none. The single source both `runCatalog`'s shrink-count and the interactive `new` deps prompt's catalog resolution read. |
494
+ | `splitTableRow` | function | Split one rendered GFM table row into its trimmed cell strings, splitting on an UNESCAPED `\|` (a `\\\|` is a literal pipe inside a cell, not a column boundary). |
495
+ | `padCell` | function | Right-pad a cell to a codepoint width, oxfmt-style — measured via `Array.from` so a surrogate pair or wide codepoint counts once. |
496
+ | `delimiterCell` | function | Build one delimiter-row cell for a GFM table column per its `TableAlign` (`left` / `right` / `center` / `none`). |
497
+ | `catalogToBlock` | function | Project a `CatalogEntry[]` into a markdown table via `alignTable` — deduplicated by `name` (a repeated name's LAST entry wins), code-unit sorted; an empty `description` renders `—` (an em dash). The block the `catalog` bin verb splices between `.claude/agents/orkestrel.md`'s markers. |
498
+
499
+ ```ts
500
+ import {
501
+ alignTable,
502
+ blueprintToMembers,
503
+ blueprintToPlan,
504
+ catalogToBlock,
505
+ diffPlan,
506
+ manifestToDependencies,
507
+ pascalCase,
508
+ planToReview,
509
+ planToSummary,
510
+ rangeToFreshness,
511
+ validateBlueprint,
512
+ } from '@orkestrel/scaffold'
513
+
514
+ const plan = blueprintToPlan(spec)
515
+ plan.hash // '7b1c9e04' — canonical FNV-1a digest of the plan's content, stable across runs
516
+ plan.trace // 'router · core+browser · groups:7 · artifacts:21' — derived, never authored
517
+
518
+ pascalCase('my-router') // 'MyRouter' — hyphens are word breaks
519
+ blueprintToMembers(spec) // [{ name: 'RouterOptions', category: 'type', surface: 'core' }, …]
520
+ planToSummary(plan) // { name: 'router', artifacts: 21, host: 12, template: 6, computed: 3, … }
521
+ planToReview(plan) // '# Scaffolding router\n## Artifacts\n| Path | Group | Origin |\n…'
522
+ validateBlueprint(spec) // { valid: true, questions: [], warnings: [] }
523
+
524
+ const current = { 'package.json': '{ "name": "@orkestrel/router" }' }
525
+ diffPlan(plan, current) // { findings: [...], clean: false, complete: true, drifted: 1, missing: 20, foreign: 0 }
526
+
527
+ manifestToDependencies('{"dependencies":{"@orkestrel/contract":"^0.0.5"}}') // [{ name: '@orkestrel/contract', range: '^0.0.5' }]
528
+ rangeToFreshness('^0.0.5', '0.0.5') // 'current' — pinned to latest
529
+ rangeToFreshness('^0.0.5', '0.0.7') // 'behind' — a newer patch is published
530
+
531
+ catalogToBlock([
532
+ { name: '@orkestrel/router', version: '0.0.5', description: 'A tiny hash-router.' },
533
+ { name: '@orkestrel/contract', version: '0.0.5', description: '' },
534
+ ]) // '| Package | Version | Description |\n| … |\n| @orkestrel/contract | 0.0.5 | — |\n…' — empty description renders as an em dash
535
+
536
+ alignTable(['API', 'Kind'], [['`createRouter`', 'function']]) // '| API | Kind |\n| … |'
537
+ ```
538
+
539
+ ```ts
540
+ import { computeHash, stableStringify, validateDependencyArray } from '@orkestrel/scaffold'
541
+
542
+ computeHash('hello-world') // '428d118e' — same input, same digest, every run
543
+ stableStringify({ b: 1, a: 2 }) // '{"a":2,"b":1}' — key order independent
544
+ computeHash(stableStringify({ b: 1, a: 2 })) === computeHash(stableStringify({ a: 2, b: 1 })) // true
545
+
546
+ validateDependencyArray('dependencies', [{ name: '', range: '^1' }])
547
+ // { questions: [{ field: 'dependencies', text: 'A dependency name must not be empty', … }], seen: Set(0) {} }
548
+ ```
549
+
550
+ `alignTable` builds a markdown `TableNode` (each cell's string parsed with `parseInline`)
551
+ and serializes it through `@orkestrel/markdown`'s `renderMarkdown`, which contributes the
552
+ STRUCTURE — `\|`-escaping any literal pipe inside a cell and emitting the alignment delimiter
553
+ row — at a flat 1-space cell padding. `alignTable` then re-pads BOTH the cells AND the
554
+ delimiter row to per-column codepoint width; that re-pad is the whole capability, matching
555
+ oxfmt's markdown re-padding so a generated guide passes `format:check` without a formatter
556
+ run — codepoint-aware re-padding so a CJK or other wide cell keeps every column aligned,
557
+ typed and tested. The `\|`-escape is load-bearing: an unescaped
558
+ pipe in a cell would
559
+ split it into two columns and silently corrupt the table. Its optional `readonly
560
+ TableAlign[]` is the `@orkestrel/markdown` alignment type, imported at the call site, never
561
+ re-exported here (AGENTS §6).
562
+
563
+ ### Factories
564
+
565
+ | API | Kind | Builds… |
566
+ | -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
567
+ | `createCompiler` | function | A `CompilerInterface` — the compilation orchestrator, seeded from `CompilerOptions`. |
568
+ | `createPlanManager` | function | A working `PlanManagerInterface`. |
569
+ | `createBlueprint` | function | Validate and return a `Blueprint` from plain data — throws `ScaffoldError('INVALID', …)` on failure (structure AND the semantic pass, so an off-`NAME_PATTERN` name throws too). |
570
+ | `createMaterializer` | function | A `MaterializerInterface` **(server)** — the materialization entity, seeded from `MaterializerOptions`. |
571
+ | `createSync` | function | A `SyncInterface` **(server)** — the upstream-synchronization entity, seeded from `SyncOptions`. |
572
+
573
+ ```ts
574
+ import { createBlueprint, createCompiler, createPlanManager } from '@orkestrel/scaffold'
575
+
576
+ const compiler = createCompiler() // owns a typed emitter, no sub-engines
577
+ compiler.destroy()
578
+
579
+ const plans = createPlanManager()
580
+ plans.size // 0
581
+ plans.destroy()
582
+
583
+ createBlueprint({ name: 'Router', surfaces: [] }) // throws ScaffoldError('INVALID', …)
584
+ ```
585
+
586
+ ### Entities
587
+
588
+ | API | Kind | Summary |
589
+ | -------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
590
+ | `Compiler` | class | The compilation orchestrator — runs the three-stage pipeline and the audit projection, owns a typed emitter. |
591
+ | `PlanManager` | class | The self-owning, versioned/hashed plan registry (AGENTS §9) — record ids default to each plan's own content hash. |
592
+ | `Materializer` | class | The materialization entity **(server)** — the impure WRITE surface; writes a plan (green-field) or repairs drift (into-existing). |
593
+ | `Sync` | class | The upstream-synchronization entity **(server)** — the impure FETCH sibling of `Materializer`; fetches dependency guides + registry versions, refreshes vendored mirrors under the containment law. Promise-based. |
594
+
595
+ The server surface also ships nineteen helpers and its factories:
596
+
597
+ | API | Kind | Summary |
598
+ | ------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
599
+ | `isVacant` | function | **(server)** Whether a target path is absent, empty, or contains nothing but a `.git` directory — the green-field target law. |
600
+ | `readTarget` | function | **(server)** Read a target's current content at a set of relative paths into a `Record<string, string>` — the I/O that feeds the pure `diffPlan`. |
601
+ | `readManifest` | function | **(server)** Read `target/package.json` text; an absent manifest throws `ScaffoldError('TARGET', …)` — the read that feeds `manifestToDependencies`. |
602
+ | `locateHostSource` | function | **(server)** Resolve the absolute host-storage path for a host-origin artifact's `source`, manifest-aware — `join(host, source)` when `manifest` is `undefined` (no vendored staging indirection), else the SINGLE matching entry's `storage` path, or `undefined` when zero or more than one entry matches `destination`. |
603
+ | `hydratePlan` | function | **(server)** Fill a `host`-origin artifact's `content` from the resolved `host` root (manifest-aware, via `locateHostSource`) so `diffPlan` can content-compare it — a `host` file's drift becomes `'stale'`-detectable rather than presence-only. |
604
+ | `discoverPackages` | function | **(server)** List a root's immediate child directories whose `package.json` name starts `@orkestrel/`, as absolute paths, code-unit sorted — the fleet-discovery primitive `fleet` walks. |
605
+ | `hostRoot` | function | **(server)** Resolve THIS module's own installed package root (walking up from `import.meta.url`, never `process.cwd()`) and return its vendored `dist/host` path — the single source of truth for the default `Materializer` / bin host; throws `ScaffoldError('TARGET', …)` when no ancestor holds a `package.json`. |
606
+ | `selectOrkestrelEntries` | function | **(server)** Filter a manifest record's entries down to `@orkestrel/`-prefixed keys with string values — the shared `dependencies` / `peerDependencies` reader `deriveBlueprint` uses for those two fields (and for computing the `peers ∩ dependencies` exclusion set); `[]` when `value` is not a plain object. |
607
+ | `deriveBlueprint` | function | **(server)** Reconstruct a `Blueprint` from an EXISTING repo at `target` — `name` strips the `@orkestrel/` prefix, `surfaces` is read off which `src/<surface>/` directories exist, `dependencies` / `peers` come from `manifest.dependencies` / `manifest.peerDependencies` via `selectOrkestrelEntries` (a `peerDependenciesMeta`-flagged peer carries `optional: true`), `extras` is EVERY entry of `manifest.devDependencies` — not only `@orkestrel/`-prefixed ones, so an external extra (e.g. `zod`) round-trips too — EXCLUDING the generated devDependency baseline (`devDependenciesFor([])`'s keys, read from that SAME source of truth rather than a duplicated literal, which already covers `@orkestrel/guide` / `@orkestrel/scaffold`) and any name also present in `dependencies` / `peerDependencies`, and `overrides` is always `[]`; throws a coded `TARGET` failure on an unreadable/non-`@orkestrel` manifest or no surface directory. |
608
+ | `isManifestEntry` | function | **(server)** Whether `value` is a well-formed `host/manifest.json` entry — a string `storage`, a string `destination`, and a boolean `executable`. |
609
+ | `readHostManifest` | function | **(server)** Read and validate a vendored host root's `manifest.json`, when present — returns its `readonly ManifestEntry[]`, or `undefined` when `host` has no `manifest.json` (the raw-root 1:1 fallback); throws `ScaffoldError('TARGET', …)` when the file exists but is unreadable, invalid JSON, or not an array of `ManifestEntry`. |
610
+ | `listFiles` | function | **(server)** Recursively list a directory's files as root-relative, posix-style paths — `[]` when the directory is absent; the walk `Materializer.prune` uses to enumerate a vendored/target directory's current files. |
611
+ | `PRUNE_DIRECTORIES` | const | **(server)** `['.claude/agents', 'scripts']`, frozen — the prune-owned directories: the ONLY directories `pruneTargets` scans and `Materializer.prune` deletes under; a file anywhere else is never prune's business. |
612
+ | `vendoredPruneSet` | function | **(server)** The vendored allowlist for ONE prune directory — the destination-relative paths under `directory` (a `PRUNE_DIRECTORIES` entry) that `host` declares and `pruneTargets` must NOT report: read from `manifest.json` `destination`s when the host carries one, else listed straight off `host/<directory>`. FAIL CLOSED: the vendored source must be POSITIVELY established before ANY allowlist (even an empty one) returns — a missing `host` root, or no `manifest.json` AND no `host/<directory>`, throws `ScaffoldError('TARGET', …)`, so an unresolved host never reads as "vendors nothing"; a host that EXISTS and vendors zero files in `directory` remains a valid empty allowlist. |
613
+ | `pruneTargets` | function | **(server)** List the repo-relative POSIX paths under `target`'s prune directories (`.claude/agents`, `scripts`) that the vendored `host` allowlist does NOT declare — THE single source of truth for prune drift, consumed by both `Materializer.prune` (which deletes exactly these paths) and the bin's audit/preview UX (which shows them honestly instead of a structurally-always-zero foreign count). Pure read — never deletes anything; `[]` when a prune directory is absent under `target`, or when none of its files are unexpected. Throws `ScaffoldError('TARGET', …)` when `host` cannot positively establish a vendored allowlist for a prune directory that DOES exist under `target` (fail-closed — an unresolved host never reads as "vendors nothing"). |
614
+ | `isRecord` | function | **(server)** Whether `value` is a plain object — not `null`, not an array — narrowing to `Record<string, unknown>`. |
615
+ | `catalogPackages` | function | **(server)** Build the fleet package catalog — one `CatalogEntry` per `@orkestrel/*` package discovered under each root (`discoverPackages`), its `description` the flattened text of the first paragraph of the guide's opening blockquote (parsed via `@orkestrel/markdown`'s `parseDocument` + `walkNodes`), `''` when the guide is missing/unreadable/blockquote-less/paragraph-less; merged across roots (a later root wins on a repeated name), code-unit sorted. An unreadable ROOT propagates whatever `discoverPackages` throws, unwrapped. |
616
+ | `storagePath` | function | **(server)** Map a repo-relative path to its vendored-host STAGING path — a leading-dot TOP-LEVEL FILE maps to `dotfiles/<name-without-dot>`, a leading-dot DIRECTORY segment loses its dot wherever it appears, an undotted path is unchanged. |
617
+ | `stageHost` | function | **(server)** The BUILD-time staging primitive the `build:host` npm script calls directly (replacing a standalone build script) — wipes `out`, byte-copies (`copyFileSync`) every file under each `paths` entry (default `HOST_PATHS`) into `<out>/<storagePath(path)>`, deriving each entry's `executable` flag from the `.sh` suffix on `destination` (deterministic on every build platform — Windows `stat` carries no execute bit; all vendored executables are shell scripts by construction), and writes `<out>/manifest.json` (entries code-unit sorted by `destination`, tab-indented, trailing newline); throws `ScaffoldError('TARGET', …)` naming a missing source path, or naming BOTH colliding destinations on a `storagePath` collision (checked BEFORE the manifest is written). Returns the written entries. |
618
+
619
+ ```ts
620
+ import { createMaterializer, isVacant } from '@orkestrel/scaffold/server'
621
+
622
+ const target = './packages/router'
623
+ isVacant(target) // true — absent, empty, or nothing but a .git dir
624
+
625
+ const materializer = createMaterializer()
626
+ const result = materializer.materialize(plan, target) // writes every artifact; throws TARGET if not vacant
627
+ result.written // ['package.json', 'tsconfig.json', 'src/core/index.ts', …] — rendered files
628
+ result.copied // ['AGENTS.md', 'LICENSE', '.claude/settings.json', …] — host-origin byte copies
629
+ materializer.destroy()
630
+ ```
631
+
632
+ ```ts
633
+ import { diffPlan } from '@orkestrel/scaffold'
634
+ import { discoverPackages, hydratePlan, readTarget } from '@orkestrel/scaffold/server'
635
+
636
+ // hydratePlan: content-compare a host-origin artifact instead of presence-only.
637
+ const hydrated = hydratePlan(plan, './packages/router') // fills each `host` artifact's `content`
638
+ const audit = diffPlan(
639
+ hydrated,
640
+ readTarget(
641
+ './packages/router',
642
+ hydrated.artifacts.map((artifact) => artifact.path),
643
+ ),
644
+ )
645
+ audit.findings.filter((finding) => finding.drift === 'stale') // now catches drifted host files too
646
+
647
+ // discoverPackages: the fleet-walk `fleet` runs per-repo.
648
+ discoverPackages('/repos') // ['/repos/contract', '/repos/scaffold', …] — @orkestrel/* children, sorted
649
+ ```
650
+
651
+ ```ts
652
+ import { hostRoot } from '@orkestrel/scaffold/server'
653
+
654
+ hostRoot() // '/…/node_modules/@orkestrel/scaffold/dist/host' — the package's own vendored data root
655
+ ```
656
+
657
+ ```ts
658
+ import { deriveBlueprint } from '@orkestrel/scaffold/server'
659
+
660
+ const spec = deriveBlueprint('./packages/router')
661
+ spec.surfaces // ['core', 'browser', 'server'] — read off the live src/<surface>/ directories
662
+ spec.peers // e.g. [{ name: '@orkestrel/database', range: '^0.0.5', optional: true }]
663
+ spec.overrides // [] — derivation cannot know a caller's template-override intent
664
+ ```
665
+
666
+ ```ts
667
+ import { hostRoot, isRecord, listFiles, readHostManifest } from '@orkestrel/scaffold/server'
668
+
669
+ const host = hostRoot()
670
+ const manifest = readHostManifest(host) // readonly ManifestEntry[] | undefined
671
+ listFiles(`${host}/.claude/agents`) // ['scout.md', 'builder.md', …] — root-relative, posix-style
672
+
673
+ isRecord({ a: 1 }) // true
674
+ isRecord(null) // false
675
+ ```
676
+
677
+ ```ts
678
+ import {
679
+ PRUNE_DIRECTORIES,
680
+ hostRoot,
681
+ pruneTargets,
682
+ vendoredPruneSet,
683
+ } from '@orkestrel/scaffold/server'
684
+
685
+ // pruneTargets: the single source of truth Materializer.prune consumes — a pure
686
+ // read, never a deletion; [] when the target carries no unexpected files.
687
+ pruneTargets('./packages/router', hostRoot()) // ['.claude/agents/rogue.md']
688
+
689
+ PRUNE_DIRECTORIES // ['.claude/agents', 'scripts'] — the only directories prune ever touches
690
+
691
+ // vendoredPruneSet: one prune directory's allowlist — fail-closed when the
692
+ // vendored source cannot be positively established.
693
+ vendoredPruneSet(hostRoot(), 'scripts') // Set { 'scripts/deps.sh', 'scripts/cursor.sh', 'scripts/ollama.sh' }
694
+ ```
695
+
696
+ ```ts
697
+ import { stageHost, storagePath } from '@orkestrel/scaffold/server'
698
+
699
+ storagePath('.gitignore') // 'dotfiles/gitignore'
700
+ storagePath('.claude/agents/scout.md') // 'claude/agents/scout.md'
701
+
702
+ // The `build:host` script line staging THIS package's own vendored set into dist/host:
703
+ const entries = stageHost(process.cwd(), 'dist/host')
704
+ entries.length // number of files staged
705
+ ```
706
+
707
+ ```ts
708
+ import { catalogPackages } from '@orkestrel/scaffold/server'
709
+ import { catalogToBlock } from '@orkestrel/scaffold'
710
+
711
+ const entries = catalogPackages(['/repos'])
712
+ entries[0] // { name: '@orkestrel/contract', version: '0.0.5', description: '…' }
713
+ catalogToBlock(entries) // the markdown table `scaffold catalog` splices into orkestrel.md
714
+ ```
715
+
716
+ ```ts
717
+ import {
718
+ isManifestEntry,
719
+ locateHostSource,
720
+ selectOrkestrelEntries,
721
+ } from '@orkestrel/scaffold/server'
722
+
723
+ selectOrkestrelEntries({ '@orkestrel/core': '^1.0.0', lodash: '^4.0.0' })
724
+ // [['@orkestrel/core', '^1.0.0']]
725
+
726
+ isManifestEntry({ storage: 'gitignore', destination: '.gitignore', executable: false }) // true
727
+ isManifestEntry({ storage: 'gitignore', destination: '.gitignore' }) // false — missing `executable`
728
+
729
+ locateHostSource(undefined, 'package.json', './dist/host') // './dist/host/package.json'
730
+ locateHostSource(
731
+ [{ storage: 'pkg.tmpl', destination: 'package.json', executable: false }],
732
+ 'package.json',
733
+ './dist/host',
734
+ ) // './dist/host/pkg.tmpl'
735
+ ```
736
+
737
+ ````
738
+
739
+ ## Methods
740
+
741
+ The public methods of each behavioral interface — one table per type, keyed by its
742
+ backticked name, every call-signature member listed (the `readonly` data members —
743
+ `emitter` on `Compiler`; `emitter` / `size` on `PlanManager`; `emitter` on `Materializer`
744
+ and `Sync` — stay in the Surface rows above). Each implementing class exposes exactly its
745
+ interface's methods, so this doubles as the per-instance method surface (AGENTS §22). The
746
+ bin (`src/bin/scaffold.ts`) is a thin procedural entrypoint — it implements NO behavioral
747
+ interface and carries no Methods table (it exports no public members, and §22 parity
748
+ excludes `src/bin`).
749
+
750
+ #### `CompilerInterface`
751
+
752
+ `compile` and `audit` are genuinely SYNCHRONOUS and pure — the compiler holds no I/O. After
753
+ `destroy()` every method except the getter and `destroy` itself throws
754
+ `ScaffoldError('DESTROYED', …)`; `destroy()` is idempotent and tears the emitter down LAST.
755
+
756
+ | Method | Returns | Behavior |
757
+ | --------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
758
+ | `compile` | `Scaffolding` | Run the three-stage pipeline over a `Blueprint` (optionally scoped to a `Group[]` selection), returning a complete or visible-incomplete result. |
759
+ | `audit` | `Audit` | Compile the blueprint (optionally group-scoped), then diff the plan against the caller-supplied current target content — drift findings as data, no I/O. A gate-failing blueprint returns an `Audit` with `complete: false`, the gate's blocking `questions`, and ZERO findings — no plan to diff. |
760
+ | `destroy` | `void` | Idempotent teardown — emits `destroy`, then destroys the emitter LAST. |
761
+
762
+ A groups-scoped `Scaffolding`'s `plan`, materialized into a VACANT target, writes only THOSE
763
+ groups' artifacts — a deliberate partial tree, not a complete package. Full package creation
764
+ uses the unscoped `compile` (no `groups` argument); `repair` — which reads an existing target's
765
+ `Audit` rather than assuming vacancy — is the primary scoped consumer.
766
+
767
+ ```ts
768
+ import { blueprint, createCompiler } from '@orkestrel/scaffold'
769
+
770
+ const compiler = createCompiler()
771
+ const spec = blueprint('timeout', {
772
+ description: 'A typed timeout. Part of the @orkestrel line.',
773
+ surfaces: ['core'],
774
+ })
775
+
776
+ const scaffolding = compiler.compile(spec)
777
+ scaffolding.stages.map((record) => record.stage) // ['draft', 'gate', 'pin']
778
+ scaffolding.complete // true
779
+ scaffolding.plan?.hash // pinned
780
+
781
+ const audit = compiler.audit(spec, { 'package.json': '{ "name": "@orkestrel/timeout" }' })
782
+ audit.clean // false — the current target is nearly empty
783
+ audit.missing // 19 — everything but package.json is absent
784
+ compiler.destroy()
785
+ ````
786
+
787
+ #### `PlanManagerInterface`
788
+
789
+ The self-owning, ordered registry over plans (AGENTS §9). `add` re-pins the plan and mints
790
+ each record's `id` FROM its content `hash` — the hash IS the identity, so distinct content
791
+ always mints a fresh record at `version: 1`; re-adding a plan whose content is unchanged
792
+ resolves to the SAME id and returns the existing record untouched, `version` never
793
+ incrementing. The array overload of `remove` is declared FIRST (AGENTS §9.2) so an id list
794
+ resolves to the batch form. A call after `destroy()` throws `ScaffoldError('DESTROYED', …)`.
795
+
796
+ | Method | Returns | Behavior |
797
+ | --------- | ------------------------- | ----------------------------------------------------------------------------------------------------- |
798
+ | `has` | `boolean` | Whether a plan with the given id is registered. |
799
+ | `plan` | `PlanRecord \| undefined` | Look up ONE registered plan record by id (AGENTS §9.1 singular accessor). |
800
+ | `plans` | `readonly PlanRecord[]` | List ALL registered plan records (AGENTS §9.1 plural accessor). |
801
+ | `add` | `PlanRecord` | Register (or re-register) one plan; emits `add`. |
802
+ | `remove` | `boolean` (or `void`) | Remove LISTED plans by id, ONE plan by id, or ALL plans (AGENTS §9.2); emits `remove` per removed id. |
803
+ | `destroy` | `void` | Idempotent teardown — clears the collection, emits `destroy`, then destroys the emitter LAST. |
804
+
805
+ ```ts
806
+ import { blueprint, blueprintToPlan, createPlanManager } from '@orkestrel/scaffold'
807
+
808
+ const plans = createPlanManager()
809
+ const record = plans.add(blueprintToPlan(blueprint('budget', { surfaces: ['core'] })))
810
+ record.id === record.hash // true — id minted from content, deterministic
811
+ record.version // 1
812
+ plans.has(record.id) // true
813
+ plans.plan(record.id) // the PlanRecord, or undefined
814
+ plans.plans() // every registered record
815
+ plans.remove(record.id) // true
816
+ plans.destroy()
817
+ ```
818
+
819
+ #### `MaterializerInterface`
820
+
821
+ **(server surface.)** The impure WRITE entity — `node:fs` writes behind an explicit call.
822
+ `materialize` is green-field: it refuses any target `isVacant` rejects
823
+ (throwing `ScaffoldError('TARGET', …)`), then byte-copies each `host` artifact from the
824
+ `host` root and writes each `template` / `computed` artifact's rendered `content`, failing
825
+ fast on any write error (`WRITE`). `repair` is into-existing: it skips the vacancy check and
826
+ writes ONLY the `missing` / `stale` artifacts an `Audit` names. `prune` is the DELETION
827
+ counterpart: it removes `foreign` (target-only) files an `Audit` names, but ONLY under the
828
+ two directories the fleet trues wholesale (`.claude/agents/` and `scripts/`) — a `foreign`
829
+ file elsewhere is left untouched, never guessed at. After `destroy()` every method throws
830
+ `DESTROYED`; teardown is idempotent, emitter last.
831
+
832
+ A dependency's `guides/src/<dep>.md` is the ONE write-side degrade: it materializes as a
833
+ short one-line stub at `new`-time (the vendored host never carries other packages' guides),
834
+ and `scaffold pull` later replaces that stub with the real fetched guide. Any OTHER host
835
+ artifact missing from the vendored manifest is a hard `TARGET` failure, never a stub — a
836
+ non-guide zero-match manifest lookup means a corrupted or truncated `manifest.json`, not a
837
+ legitimate degrade.
838
+
839
+ | Method | Returns | Behavior |
840
+ | ------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
841
+ | `materialize` | `MaterializeResult` | Write a whole plan into a VACANT target — host copies + rendered writes; throws `TARGET` if the target is non-empty beyond `.git`. |
842
+ | `repair` | `MaterializeResult` | Write ONLY the artifacts an `Audit` marks `missing` / `stale`, into an EXISTING target — the drift-repair path, no vacancy check. |
843
+ | `prune` | `MaterializeResult` | Delete the target-only files under `.claude/agents/` or `scripts/` that the vendored host set does not carry — the bounded-deletion counterpart to `repair`; takes only the target path. |
844
+ | `destroy` | `void` | Idempotent teardown — emits `destroy`, then destroys the emitter LAST. |
845
+
846
+ ```ts
847
+ import { blueprint, blueprintToPlan, diffPlan } from '@orkestrel/scaffold'
848
+ import { createMaterializer, readTarget } from '@orkestrel/scaffold/server'
849
+
850
+ const plan = blueprintToPlan(blueprint('budget', { surfaces: ['core'] }))
851
+ const materializer = createMaterializer()
852
+
853
+ // Green-field: write everything into a fresh, vacant directory.
854
+ materializer.materialize(plan, './packages/budget-new')
855
+
856
+ // Repair: audit an existing package, then write back only what drifted.
857
+ const audit = diffPlan(
858
+ plan,
859
+ readTarget(
860
+ './packages/budget',
861
+ plan.artifacts.map((a) => a.path),
862
+ ),
863
+ )
864
+ materializer.repair(plan, audit, './packages/budget')
865
+
866
+ // prune: delete only foreign files under the two bounded directories.
867
+ const pruned = materializer.prune('./packages/budget') // deletes .claude/agents/ + scripts/ foreigns only
868
+ pruned.removed // ['scripts/legacy-hook.sh'] — a foreign file elsewhere is left untouched
869
+ materializer.destroy()
870
+ ```
871
+
872
+ #### `SyncInterface`
873
+
874
+ **(server surface.)** The impure FETCH sibling of `Materializer` — Promise-based,
875
+ network-only. Every method reads upstream over HTTPS with a 10-second per-request timeout
876
+ (`AbortSignal.timeout`) and bounded `concurrency` (default 6, never an unbounded
877
+ `Promise.all`); the default COLLECT posture captures each dependency's `freshness` (`404` →
878
+ `missing`, transport / non-2xx → `failed`) into the report, while `strict` mode instead
879
+ throws `ScaffoldError('FETCH', …)` naming the failing URL. `pull` and `write` are the two
880
+ halves of a sync — `pull` reads and reports (NO writes), `write` commits the fetched guides
881
+ under the containment law. Every non-`current` `GuideSync` / `VersionSync` that has a
882
+ discoverable cause carries it in `note` — the last attempt's transport error (with an
883
+ `ECONNREFUSED`-style code appended when the runtime attaches one), an `HTTP <status>`, the
884
+ fixed redirect-blocked string, or the oversized-body message — so a `failed` entry is never a
885
+ bare unexplained verdict (an `ETIMEDOUT` behind a corporate proxy reads identically to any
886
+ other transport fault). Guide URLs are built in the CANONICAL
887
+ `<base>/orkestrel/<short>/refs/heads/<branch>/guides/src/<short>.md` form directly (never the
888
+ legacy `/orkestrel/<short>/<branch>/…` shorthand raw.githubusercontent.com now 301-redirects
889
+ from) — `redirect: 'manual'` (A1) stays a deliberate security posture, so the fix is building
890
+ the redirect-free URL, never relaxing that policy. Every fetch is UNAUTHENTICATED — no token,
891
+ no `Authorization` header, anywhere; every fleet repo is public, so reachability alone is the
892
+ signal. After `destroy()` every method throws `DESTROYED`; teardown is idempotent, emitter last.
893
+
894
+ | Method | Returns | Behavior |
895
+ | ---------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
896
+ | `guides` | `Promise<readonly GuideSync[]>` | Fetch each named dependency's guide from the branch HEAD and verdict its `freshness` against an OPTIONAL `current` reference — a caller-supplied `Readonly<Record<string, string>>` of local-mirror content keyed by dependency name (the caller-supplied-reference pattern `diffPlan` uses for target content). WITH the map: a fetched guide byte-equal to its entry is `current`, differing or absent-from-map is `behind`. WITHOUT the map: a successful fetch is ALWAYS `behind` (no reference means it needs syncing). An HTTP `404` is `missing`, a transport fault `failed`, either way. Emits `guide` per resolution. |
897
+ | `versions` | `Promise<readonly VersionSync[]>` | Fetch each named dependency's registry `latest` and compare it to the declared `range` via `rangeToFreshness`; emits `version` per resolution. |
898
+ | `catalog` | `Promise<readonly CatalogEntry[]>` | The registry-AUTHORITATIVE fleet catalog: enumerates every `@orkestrel/*` name from the org's exact package list (`-/org/orkestrel/package` — never a fuzzy search), then per name fetches its packument (`version` + a registry-path `description` fallback) and its guide (the PREFERRED `description`, its first blockquote's first paragraph). A packument failure keeps the entry degraded (`version: ''`) rather than dropping it; a guide `404` likewise STAYS LISTED (public repos make unreachability a readiness signal, not an absence) with a note like `guide unreachable (HTTP 404 — repo private or guide missing?)`. Sorted code-unit by `name`; emits `package(name, note)` per entry (`note` empty when both fetches succeeded). |
899
+ | `pull` | `Promise<SyncReport>` | Read `target/package.json` (`readManifest`), resolve its declared `@orkestrel` deps (`manifestToDependencies`), READ the target's existing `guides/src/<short>.md` mirrors into the `current` reference map (absent files simply omitted), then fetch guides (WITH that map) + versions and return a `SyncReport` — so `pull`'s `GuideSync` freshness is genuinely target-relative. NO writes; emits `done`. |
900
+ | `write` | `Promise<readonly string[]>` | Write a report's fetched guides into `target/guides/src` under the containment law (filenames derived from `DEPENDENCY_NAME_PATTERN`-validated names, never a traversal); returns the written paths and emits `write` per file. |
901
+ | `destroy` | `void` | Idempotent teardown — emits `destroy`, then destroys the emitter LAST. |
902
+
903
+ ```ts
904
+ import { createSync } from '@orkestrel/scaffold/server'
905
+
906
+ const sync = createSync() // defaults: raw.githubusercontent.com, branch main, registry.npmjs.org
907
+ const report = await sync.pull('.') // reads ./package.json, fetches guides + versions, NO writes
908
+ report.clean // false — a mirror or a range fell behind
909
+ report.guides.filter((guide) => guide.freshness === 'behind') // stale vendored mirrors
910
+ report.versions.filter((version) => version.freshness === 'behind') // out-of-date ranges
911
+
912
+ const written = await sync.write(report, '.') // commit the refreshed guides under guides/src
913
+
914
+ const catalog = await sync.catalog() // the registry-authoritative fleet catalog
915
+ catalog.length // every published @orkestrel/* package
916
+ sync.destroy()
917
+ ```
918
+
919
+ ## Contract
920
+
921
+ These invariants hold across `src/core` + `src/server` ↔ `scaffold.md`:
922
+
923
+ 1. **DOC ↔ SOURCE bijection.** Every `function` / `class` / `const` / `interface` / `type`
924
+ row in the `## Surface` tables is a real export of the scaffold library source (core or
925
+ server), and every such export appears as a Surface row — exhaustive, both directions
926
+ (AGENTS §22). The scan covers `src/core` + `src/server` ONLY; `src/bin` is EXCLUDED — the
927
+ bin is an executable with no public exports. Adding, renaming, or removing a library
928
+ export breaks the parity gate until the doc is reconciled.
929
+ 2. **Deterministic, synchronous, immutable — in the core and the `Materializer` (§11).** Same
930
+ `Blueprint` + same `Group` selection → the same `Scaffolding`, every time — no clocks, no
931
+ randomness, no I/O in the core, nothing async. `pinPlan`'s `trace` and `hash` derive from
932
+ the plan's CONTENT alone (paths, origins, sources, and rendered content — everything the
933
+ blueprint fully determines), and the `PlanManager` mints record ids from that hash, so
934
+ re-adding an unchanged plan is a version no-op. The **bin** AND the server `Sync` entity
935
+ are legitimately Promise-based — the bin's prompt flow and `Sync`'s upstream fetches
936
+ orchestrate AROUND the synchronous `compile` / write, never inside them; the core and the
937
+ `Materializer` stay synchronous. No input is ever mutated; every builder, projection, and
938
+ pipeline stage returns a fresh value.
939
+ 3. **Three origins, one token boundary.** An `Artifact`'s `origin` is exhaustive and
940
+ load-bearing: `host` artifacts byte-copy from the vendored data root (server-only I/O, no
941
+ inline `content`); `template` artifacts fill a frozen `TemplateDefinition` through
942
+ `@orkestrel/template`'s pure `fillTemplate` with `missing: 'error'` (an unresolved token
943
+ fails LOUD, never silently blanks); `computed` artifacts derive from the core's own
944
+ manifest/exports combination logic. Only genuinely templated PROSE artifacts fill; every
945
+ STRUCTURAL (JSON / TS) file is `computed`, so a literal `{{…}}` in a config is never
946
+ mistaken for a placeholder. There is NO `TemplateManager` inside the compiler — the core
947
+ uses only the template package's fill LEAF and stays pure and stateless.
948
+ 4. **Fail closed at the gate.** A non-empty set of BLOCKING questions (a bad name, empty or
949
+ off-vocabulary `surfaces`, a malformed dependency, an override that matches no planned
950
+ artifact or targets a `host`-origin path) yields `complete: false`, an ABSENT `plan`, the
951
+ `questions` on `Scaffolding.questions`, and a `CompileFailure` coded `BLOCKED` — never a
952
+ throw, never a half-formed plan. A NON-blocking question (e.g. a non-vendored dependency's
953
+ mirror pointer) rides a COMPLETE result as an advisory. Emitting a partly-valid package
954
+ skeleton is worse than returning the question that blocks it.
955
+ 5. **One plan, many projections; projections never add.** `planToReview`, `planToSummary`,
956
+ `diffPlan`, `auditToReview`, and (on the server) `materialize` are pure views over the
957
+ pinned plan — the review renders exactly the plan's artifacts, the summary counts exactly
958
+ them, the audit compares exactly them, and materialization writes exactly them. Nothing
959
+ downstream is authored separately, so the files on disk, the review, the audit, and the
960
+ summary cannot disagree with the plan or one another.
961
+ 6. **The variant matrix is data (§21).** A blueprint's `surfaces` mints EVERY live variant
962
+ class the line carries: core-only, core+server, core+browser+server, server-only,
963
+ browser-only, core+browser — driving the `package.json` `exports` shape, the per-surface
964
+ `configs/src` files, the Vitest projects, the per-surface `src/<surface>/*` and
965
+ `tests/src/<surface>/*` source/test artifacts, and the conditional test-setup consequences
966
+ (`tests/setupServer.ts` IFF a `server` surface, `tests/setupBrowser.ts` IFF a `browser`
967
+ surface — `@vitest/browser-playwright` itself ships in the generated `devDependencies`
968
+ baseline UNCONDITIONALLY, grounded against the live exemplars rather than gated on a
969
+ browser surface). `SURFACE_MATRIX` is the per-surface layer; ABOVE it `blueprintToPlan`
970
+ applies the manifest and exports COMBINATION rules — a multi-surface package OMITS the
971
+ top-level `types` field, a single-variant (server-only / browser-only) retargets its lone
972
+ surface to the `.` root with `main` / `module` re-pointed (browser-only using flat ESM
973
+ conditions). Adding a surface changes the PLAN, not the compiler; every variant, including
974
+ the core-only single path, is one row of a table.
975
+ 7. **Mechanism, never policy + scoped mirrors (§21).** The module decides NOTHING about a
976
+ package's identity: the caller owns `name` / `description` / `keywords` / `dependencies`
977
+ and any template `overrides`; the compiler owns the rendering, the closed vocabularies,
978
+ the gate, the pin, and the projections. An absent override means the canonical shipped
979
+ default; a present override REPLACES the rendered artifact at its `path`, never partially
980
+ merges. Dependency guide mirrors scope by the vendored-guides law (Law #2 — one vendored
981
+ copy per runtime dependency): THIS repo vendors all six runtime deps' guides (contract /
982
+ emitter / markdown / template / terminal / console) plus `guide.md` alongside its own —
983
+ seven mirror files. A scaffolded package's `Dependency` therefore gets a BYTE-CORRECT
984
+ OFFLINE mirror only when scaffold vendors that dep's guide (the seven above); any OTHER
985
+ `@orkestrel` dependency yields NO fabricated mirror — the plan emits a `host`-origin
986
+ POINTER artifact, surfaced as a NON-blocking Question. Those shipped mirrors are the
987
+ OFFLINE BASELINE — correct for offline creation; the server `Sync` entity is the FRESHNESS
988
+ path for ANY declared `@orkestrel` dependency (vendored or not), fetching the current guide
989
+ and range from upstream and superseding the baseline when the network is available.
990
+ 8. **Diff-first, write-last.** `compile`, `audit`, `blueprintToPlan`, `diffPlan`,
991
+ `planToReview`, `planToSummary`, and `Sync.pull` are report-only; the ONLY writing acts in
992
+ the package are the server surface's `materialize` / `repair` / `prune` / `Sync.write`,
993
+ each gated behind an explicit call (and the bin's `--apply` / `--prune`, or an accepted
994
+ terminal confirm). The fleet flow (`scaffold fleet`) is the same shape one level up:
995
+ `discoverPackages` finds the repos, `hydratePlan` + `diffPlan` reports drift per repo, and
996
+ only a confirmed write (or `--apply`) writes. The dry-run review is the default posture
997
+ everywhere — you always see the plan (or the drift, or the freshness) before a byte is
998
+ written.
999
+ 9. **Guard totality and single-source parity (§14).** Every validator is a total `Guard` —
1000
+ adversarial input returns `false`, never throws. `isBlueprint` / `isPlan` / `isSyncReport`
1001
+ / the section guards are COMPILED from `blueprintShape()` / `planShape()` /
1002
+ `syncReportShape()` through the contract package's `createContract`, so the guard, the
1003
+ parser, the JSON Schema, and the seeded generator are lockstep by construction — an
1004
+ off-vocabulary literal, a missing section, or an extra key fails all four identically.
1005
+ `NAME_PATTERN` is deliberately NOT a shape refinement (contract's `compileGenerator` throws
1006
+ on a pattern-constrained string it cannot sample), so `generate` stays satisfiable; the
1007
+ name law lives in the SEMANTIC pass (`validateBlueprint`, the gate, and `createBlueprint`),
1008
+ not the compiled contract.
1009
+ 10. **Coded errors (§12).** Every throw out of this module is a `ScaffoldError` with a
1010
+ machine-readable code (`INVALID` / `DESTROYED` from the core, `TARGET` / `WRITE` / `FETCH`
1011
+ from the server) and a `context` carrying the offending path, field, or URL; `BLOCKED` is
1012
+ a contained failure marker on a `Scaffolding`, never thrown, and in `Sync`'s default
1013
+ collect mode a fetch fault is a captured `freshness`, not a throw. `catch` blocks narrow
1014
+ with `isScaffoldError`, never `as`.
1015
+ 11. **Observation is a pure side-channel (§13).** The `Compiler` owns a typed emitter
1016
+ (`CompilerEventMap` — `compile` / `audit` / `block` / `error` / `destroy`); the
1017
+ `PlanManager`, the server `Materializer`, and the server `Sync` own their own. Every event
1018
+ is emitted directly and synchronously, AFTER the outcome it reports; only complete
1019
+ `compile()` calls emit `compile`, and a gated one emits `block` instead. `audit()` emits
1020
+ `audit` after its outcome and NEVER `compile`; a gated `audit()` emits `block` then
1021
+ `audit`. A stage throw inside `compile` / `audit` is CONTAINED as a `CompileFailure` on the
1022
+ result AND emitted on the domain `error` event for observability. Listener isolation is the
1023
+ emitter's own — a throwing listener routes to the `error` OPTION handler, never onto the
1024
+ domain `error` event. `destroy()` is idempotent and tears the emitter down LAST.
1025
+ 12. **Network is server/bin-only.** ONLY the server `Sync` entity touches the network — the
1026
+ core, the `Compiler`, the `Materializer`, every projection, and every guard are
1027
+ network-free. `Sync` fetches over HTTPS with a per-request 10-second timeout
1028
+ (`AbortSignal.timeout`), no retries by default (opt in via `retries`), bounded concurrency
1029
+ (default 6, never an unbounded `Promise.all`), and TLS / proxy configured through the
1030
+ ENVIRONMENT (never a verification bypass); it reads guides from `raw.githubusercontent.com`
1031
+ and versions from `registry.npmjs.org` (both `base`s injectable). A failed fetch is EITHER
1032
+ a thrown `ScaffoldError('FETCH', …)` naming the URL (under `strict`) OR a captured
1033
+ `freshness: 'failed'` (the default collect mode) — never an unhandled rejection.
1034
+ 13. **DOC ↔ SOURCE method bijection.** Every behavioral interface's `## Methods` table lists
1035
+ exactly its public methods (call-signature members) — exhaustive, both directions — and
1036
+ each implementing class exposes the same public methods, no more (AGENTS §22). The bin
1037
+ implements no interface and is excluded, as in invariant 1.
1038
+
1039
+ This package is the line's sole scaffolding and fleet-conformance tool: it renders the
1040
+ whole per-surface variant matrix from versioned `TemplateDefinition` data and trues the
1041
+ fleet through `scaffold fleet`, so a convention change is a version bump here rather than
1042
+ a hand-edit in every repo's copy. Every repo's shared artifacts flow from its vendored host.
1043
+
1044
+ Deliberately absent: any **git** operation (no `git init` / `git clone` — the caller prepares
1045
+ the vacant target, and the package stops at the file boundary), any **npm** INVOCATION (no
1046
+ `npm install`, no lockfile generation — the caller runs the gates; the `Sync` entity's read
1047
+ of registry version METADATA is an HTTPS GET, not an npm invocation), any **LLM** (the
1048
+ authoring judgment is the caller's, per invariant 7), a foreign template ecosystem (the
1049
+ module renders only the `@orkestrel` line's own conventions, versioned in this package),
1050
+ asynchronous compilation, and plan persistence (`JSON.stringify(plan)` out, `parsePlan` back
1051
+ in). Three sibling engines were considered and REJECTED, each for a concrete reason:
1052
+ **`@orkestrel/reason`** — the gate is regex / set-membership / path-matching checks a reason
1053
+ `Check`'s comparisons cannot express, and facet deduction already IS `SURFACE_MATRIX` plus the
1054
+ manifest/exports combination rules, so there is no inference gap for a reasoner to fill;
1055
+ **`@orkestrel/interpret`** — there is no natural-language input to interpret and no
1056
+ `ReasonResult` to render; and **`@orkestrel/relation`** — a plan's artifacts are one ORDERED
1057
+ list, fully served by the guards, `diffPlan`, and the summary, so no graph layer is needed
1058
+ (revisit only if cross-artifact dependency edges ever earn their keep).
1059
+
1060
+ ## Patterns
1061
+
1062
+ ### Compiling a package with full variant control
1063
+
1064
+ The forward path end to end: blueprint → draft → gate → pin → materialize. The
1065
+ `SURFACE_MATRIX` selects the `exports` shape, the per-surface configs, and the test projects
1066
+ from the declared `surfaces`, so one call scaffolds any variant.
1067
+
1068
+ ```ts
1069
+ import { blueprint, createCompiler, dependency } from '@orkestrel/scaffold'
1070
+ import { createMaterializer } from '@orkestrel/scaffold/server'
1071
+
1072
+ const compiler = createCompiler()
1073
+ const scaffolding = compiler.compile(
1074
+ blueprint('database', {
1075
+ description: 'A minimal-interface data layer. Part of the @orkestrel line.',
1076
+ keywords: ['database', 'storage', 'query'],
1077
+ surfaces: ['core', 'browser', 'server'], // the full three-surface variant
1078
+ dependencies: [dependency('@orkestrel/contract', '^0.0.5')],
1079
+ }),
1080
+ )
1081
+
1082
+ scaffolding.complete // true
1083
+ scaffolding.plan?.groups // ['manifest', 'configs', 'source', 'tests', 'guides', 'docs', 'orchestration']
1084
+
1085
+ if (scaffolding.plan) {
1086
+ const materializer = createMaterializer()
1087
+ materializer.materialize(scaffolding.plan, './packages/database') // green-field, vacant target
1088
+ materializer.destroy()
1089
+ }
1090
+ compiler.destroy()
1091
+ ```
1092
+
1093
+ ### Selecting artifact groups — partial generation
1094
+
1095
+ `compile`'s optional `Group[]` scopes the plan to a subset — regenerate just the configs and
1096
+ guides after a convention bump, leaving hand-written source untouched.
1097
+
1098
+ ```ts
1099
+ import { blueprint, createCompiler } from '@orkestrel/scaffold'
1100
+
1101
+ const compiler = createCompiler()
1102
+ const spec = blueprint('sqlite', { surfaces: ['server'] })
1103
+
1104
+ const scaffolding = compiler.compile(spec, ['configs', 'guides']) // only these two groups
1105
+ scaffolding.plan?.artifacts.every(
1106
+ (artifact) => artifact.group === 'configs' || artifact.group === 'guides',
1107
+ ) // true
1108
+ compiler.destroy()
1109
+ ```
1110
+
1111
+ ### Failing closed — the blocking path
1112
+
1113
+ An off-`NAME_PATTERN` name (or an override that matches nothing, or one targeting a host path)
1114
+ is a BLOCKING question: the gate stops, no plan is pinned, and the `Scaffolding` carries the
1115
+ question — the caller fixes it and re-compiles. No half-formed package ever leaves the
1116
+ pipeline.
1117
+
1118
+ ```ts
1119
+ import { blueprint, createCompiler } from '@orkestrel/scaffold'
1120
+
1121
+ const compiler = createCompiler()
1122
+ const scaffolding = compiler.compile(blueprint('My-Router', { surfaces: ['core'] }))
1123
+
1124
+ scaffolding.complete // false — the gate failed closed
1125
+ scaffolding.plan // undefined — nothing to project, deliberately
1126
+ scaffolding.questions // [{ field: 'name', text: 'Name must match ^[a-z][a-z0-9-]*$', blocking: true }]
1127
+ scaffolding.failures // [{ stage: 'gate', code: 'BLOCKED', message: '1 blocking question' }]
1128
+ compiler.emitter.on('block', (questions) => questions.length) // fires instead of `compile`
1129
+ compiler.destroy()
1130
+ ```
1131
+
1132
+ ### Auditing an existing package — the conformance engine
1133
+
1134
+ The audit is pure core: the server reads the target's current content (`readTarget`), the
1135
+ core diffs it against the plan (`diffPlan`), and the drift comes back as data — the
1136
+ per-file conformance checklist, mechanized. No byte is written.
1137
+
1138
+ ```ts
1139
+ import { auditToReview, blueprint, blueprintToPlan, diffPlan } from '@orkestrel/scaffold'
1140
+ import { readTarget } from '@orkestrel/scaffold/server'
1141
+
1142
+ const plan = blueprintToPlan(blueprint('abort', { surfaces: ['core'] }))
1143
+ const current = readTarget(
1144
+ './packages/abort',
1145
+ plan.artifacts.map((artifact) => artifact.path),
1146
+ )
1147
+
1148
+ const audit = diffPlan(plan, current)
1149
+ audit.clean // false — the repo drifted from the line's conventions
1150
+ audit.findings.filter((finding) => finding.drift === 'stale') // e.g. [{ path: '.oxfmtrc.json', … }]
1151
+ auditToReview(audit) // '# Drift — abort\n## Stale\n| Path | Group |\n…'
1152
+ ```
1153
+
1154
+ `diffPlan` compares by content equality: a `template` / `computed` artifact whose rendered
1155
+ content the target does not match is `stale`; one the target lacks is `missing`; a target
1156
+ file the plan does not own is `foreign`. A `host`-origin artifact carries no `content` on a
1157
+ RAW plan (the pure core never read the canonical host bytes), so an un-hydrated audit sees it
1158
+ by PRESENCE only — `missing` or `aligned`, never `stale`; running the server's `hydratePlan`
1159
+ first fills each `host` artifact's `content` from the resolved host root, so the SAME
1160
+ `diffPlan` becomes content-aware and a drifted host file surfaces as `stale` too. A
1161
+ directory-shaped host artifact (`.claude`) has no single storage file for `hydratePlan` to
1162
+ read, so it stays presence-only regardless — a KNOWN, documented boundary, not a promise:
1163
+ `pruneTargets` separately covers UNEXPECTED files under it (the prune allowlist scan), but a
1164
+ byte-modified file that IS on the allowlist inside `.claude` is not detected by either path.
1165
+
1166
+ ### Repairing drift — write only what changed
1167
+
1168
+ Repair chains the audit into the server surface: `materialize` refuses a non-vacant target,
1169
+ so repairing an EXISTING package goes through `repair`, which writes only the `missing` /
1170
+ `stale` artifacts the audit named.
1171
+
1172
+ ```ts
1173
+ import { blueprint, blueprintToPlan, diffPlan } from '@orkestrel/scaffold'
1174
+ import { createMaterializer, readTarget } from '@orkestrel/scaffold/server'
1175
+
1176
+ const plan = blueprintToPlan(blueprint('abort', { surfaces: ['core'] }))
1177
+ const audit = diffPlan(
1178
+ plan,
1179
+ readTarget(
1180
+ './packages/abort',
1181
+ plan.artifacts.map((a) => a.path),
1182
+ ),
1183
+ )
1184
+
1185
+ const materializer = createMaterializer()
1186
+ const result = materializer.repair(plan, audit, './packages/abort') // only the drifted files
1187
+ result.written // ['.oxfmtrc.json', 'configs/src/tsconfig.core.json'] — nothing aligned is touched
1188
+ materializer.destroy()
1189
+ ```
1190
+
1191
+ ### Layering template overrides
1192
+
1193
+ Mechanism-never-policy in practice: the package renders the canonical defaults; a caller who
1194
+ needs a bespoke file supplies an `override` whose `content` replaces the rendered artifact at
1195
+ that path. An absent override means the default — the caller opts into exactly the files they
1196
+ want to own. An override that matches NO planned artifact, or that targets a `host`-origin
1197
+ path (host bytes are governed by the mirror, not per-package overrides), is a BLOCKING
1198
+ question — a typo'd path fails the gate closed rather than silently adding a stray file.
1199
+
1200
+ ```ts
1201
+ import { blueprint, blueprintToPlan, override } from '@orkestrel/scaffold'
1202
+
1203
+ const readme = '# @orkestrel/router\n\nA hash-router with a hand-written readme.\n'
1204
+ const plan = blueprintToPlan(
1205
+ blueprint('router', { surfaces: ['core'], overrides: [override('README.md', readme)] }),
1206
+ )
1207
+ plan.artifacts.find((artifact) => artifact.path === 'README.md')?.content === readme // true
1208
+ ```
1209
+
1210
+ ### Serving blueprints at a tool boundary
1211
+
1212
+ The shape DSL payoff: the SAME declaration that compiled the guard serves the tool schema and
1213
+ the test data — an MCP tool that accepts blueprints cannot drift from the validator that
1214
+ checks them, and the plan it returns is JSON all the way down.
1215
+
1216
+ ```ts
1217
+ import { blueprintShape, blueprintToPlan, parseBlueprint } from '@orkestrel/scaffold'
1218
+ import { createContract, schemaToParameters, seededRandom } from '@orkestrel/contract'
1219
+
1220
+ const contract = createContract(blueprintShape())
1221
+
1222
+ const tool = {
1223
+ name: 'scaffold_package',
1224
+ description: 'Compile an @orkestrel package blueprint into a plan.',
1225
+ parameters: schemaToParameters(contract.schema), // the JSON Schema, no `as` anywhere
1226
+ }
1227
+
1228
+ // In the handler: the string boundary is parseBlueprint; the payload is then trusted typed data.
1229
+ function handle(argument: string): string {
1230
+ const incoming = parseBlueprint(argument)
1231
+ return incoming ? blueprintToPlan(incoming).hash : 'Rejected: not a valid blueprint.'
1232
+ }
1233
+
1234
+ contract.generate(seededRandom(7)) // a reproducible on-contract blueprint — the test fixture, for free
1235
+ ```
1236
+
1237
+ ### Targeted sync in an existing repo
1238
+
1239
+ `Sync.pull` reads the target's `package.json`, resolves its declared `@orkestrel`
1240
+ dependencies, and fetches each one's upstream guide and registry version — reporting freshness
1241
+ as data, writing nothing. `syncToReview` renders it; only an explicit `write` (the bin's
1242
+ `--apply`) commits the refreshed mirrors. To scope to a dependency SUBSET, resolve with
1243
+ `manifestToDependencies` and call `guides(deps)` / `versions(deps)` directly.
1244
+
1245
+ ```ts
1246
+ import { syncToReview } from '@orkestrel/scaffold'
1247
+ import { createSync } from '@orkestrel/scaffold/server'
1248
+
1249
+ const sync = createSync({ concurrency: 6 })
1250
+ const report = await sync.pull('.') // all declared @orkestrel deps
1251
+ syncToReview(report) // '# Sync — 2 behind\n## Guides\n| Name | Freshness |\n…'
1252
+ report.guides.filter((guide) => guide.freshness !== 'current') // the stale / missing mirrors
1253
+
1254
+ if (report.failed === 0) await sync.write(report, '.') // refresh the vendored mirrors under guides/src
1255
+ sync.destroy()
1256
+ ```
1257
+
1258
+ ### Auditing with live drift
1259
+
1260
+ `scaffold audit` is a WHOLE-PLAN conformance report — host AND generated artifacts alike, unlike
1261
+ `repair`'s host-only scope — layering TWO drift sources: the structural `diffPlan` (the plan vs
1262
+ the target on disk) and — under `--live` — the `Sync` freshness pass (each dependency's guide vs
1263
+ upstream HEAD, each range vs the registry latest). Any drift is a nonzero exit, so it doubles as
1264
+ a CI conformance gate. `audit` NEVER writes.
1265
+
1266
+ ```ts
1267
+ import { blueprintToPlan, diffPlan } from '@orkestrel/scaffold'
1268
+ import { createSync, readTarget } from '@orkestrel/scaffold/server'
1269
+
1270
+ const plan = blueprintToPlan(spec) // `spec` — the blueprint reconstructed for this repo
1271
+ const structural = diffPlan(
1272
+ plan,
1273
+ readTarget(
1274
+ '.',
1275
+ plan.artifacts.map((artifact) => artifact.path),
1276
+ ),
1277
+ )
1278
+
1279
+ // --live: `pull` reads the target's own guides/src mirrors into the reference map ITSELF, so
1280
+ // its GuideSync freshness is genuinely target-relative (a target-free `guides(deps)` with no
1281
+ // reference would instead read every fetched guide as 'behind').
1282
+ const sync = createSync()
1283
+ const report = await sync.pull('.')
1284
+ sync.destroy()
1285
+
1286
+ const drifted =
1287
+ !structural.clean ||
1288
+ report.guides.some((guide) => guide.freshness !== 'current') ||
1289
+ report.versions.some((version) => version.freshness !== 'current')
1290
+ process.exitCode = drifted ? 1 : 0 // ANY drift fails the CI gate
1291
+ ```
1292
+
1293
+ ### Offline and failure posture
1294
+
1295
+ `Sync` is built for an enterprise network. Each request carries a 10-second
1296
+ `AbortSignal.timeout`, there are no retries by default (opt in with `retries`), concurrency is
1297
+ bounded (default 6, never an unbounded `Promise.all`), and TLS / proxy come from the
1298
+ environment (never a verification bypass). The DEFAULT posture is COLLECT-and-report: a
1299
+ per-dependency failure becomes a captured `freshness` (`404` → `missing`, transport →
1300
+ `failed`) on the `SyncReport`, so one unreachable dep never sinks the whole run. `strict` flips
1301
+ a failure into a thrown `ScaffoldError('FETCH', …)` that names the URL — for a CI gate that
1302
+ must go red on any network fault.
1303
+
1304
+ ```ts
1305
+ import { isScaffoldError } from '@orkestrel/scaffold'
1306
+ import { createSync } from '@orkestrel/scaffold/server'
1307
+
1308
+ // Collect mode (default): partial failure is DATA, not a throw.
1309
+ const collect = createSync({ registry: { base: 'https://registry.example.internal' } })
1310
+ const report = await collect.pull('.')
1311
+ report.failed // 1 — one dep's registry was unreachable; the rest resolved
1312
+ report.versions.find((version) => version.freshness === 'failed') // the captured failure
1313
+ collect.destroy()
1314
+
1315
+ // Strict mode: any fetch fault throws, naming the URL — the CI-gate posture.
1316
+ const strict = createSync({ strict: true, retries: 2 })
1317
+ try {
1318
+ await strict.pull('.')
1319
+ } catch (error) {
1320
+ if (isScaffoldError(error)) error.code // 'FETCH'
1321
+ }
1322
+ strict.destroy()
1323
+ ```
1324
+
1325
+ ### The `scaffold` bin — six subcommands, one build target
1326
+
1327
+ The CLI is its OWN build target — `src/bin/scaffold.ts`, an executable, not a barrel. It
1328
+ opens with a `#!/usr/bin/env node` shebang, strips a single leading literal `--` off `argv`
1329
+ (npm's passthrough residue, mangled by PowerShell on Windows — `npm run scaffold -- new x`
1330
+ still parses as `new x`), parses the remainder with `node:util`'s `parseArgs` (no foreign arg
1331
+ parser), widens Node's trusted-issuer set to the OS certificate store via
1332
+ `trustSystemCertificates` (feature-detected, try/catch no-op, never touching
1333
+ `rejectUnauthorized` — so `fetch` survives a corporate TLS-inspecting proxy the way npm
1334
+ and browsers already do), and dispatches on SIX subcommands: **`new`** creates a package
1335
+ (resolving any `--deps` — `@orkestrel/*` runtime deps, landing in `Blueprint.dependencies`
1336
+ — to the registry `latest` → `^latest` ranges, fetching their guides into the plan; a
1337
+ range-less `--deps` name the registry could not resolve (`freshness` `'missing'`/`'failed'`,
1338
+ or an empty `latest` — `createSync()` is non-strict here, it never throws on its own) is a
1339
+ hard failure BEFORE any write, rather than silently landing an unwritable `"^"` range in
1340
+ `package.json` with exit `0` (U12c FIX 1); on a real terminal, `--deps`'s free-text prompt
1341
+ is an interactive question (Q1) taking `@orkestrel` SHORT names (`contract, emitter`; a bare
1342
+ token normalizes to `@orkestrel/<token>`, an already-prefixed one passes through unchanged)
1343
+ and validates each against the vendored `@orkestrel` catalog embedded in
1344
+ `.claude/agents/orkestrel.md` (re-asking, with a nearest-match suggestion, on an unresolved
1345
+ token — degrading to shape-only `DEPENDENCY_NAME_PATTERN` validation with a one-line note
1346
+ when the vendored catalog itself cannot be resolved) — TTY-only, `--deps` works identically
1347
+ off a terminal or under `--json`. Other npm packages are NOT a `new`-time concept — hand-add
1348
+ them to the generated `package.json`'s `devDependencies` after scaffolding;
1349
+ `deriveBlueprint`'s `extras` round-trip (below) recompiles them back into the plan on the
1350
+ next `audit`/`repair`/`pull`, so a hand-added `devDependencies` entry stays audit-clean
1351
+ without the CLI ever collecting it), **`pull`**
1352
+ refreshes an existing repo's vendored dependency mirrors and
1353
+ reports range drift, **`audit`** / **`repair`** / **`fleet`** all reconstruct the target's
1354
+ `Blueprint` with `deriveBlueprint` (never a hand-built stand-in) before compiling — **`audit`**
1355
+ runs the structural conformance check — hydration-aware, so host drift is content-detectable,
1356
+ not presence-only — (plus, under `--live`, guide-vs-HEAD and range-vs-latest freshness), and
1357
+ NOW MERGES the prune scan (`pruneTargets`) into its report: an unexpected file under
1358
+ `.claude/agents/` or `scripts/` is a real `foreign` row, its count included under `--json`,
1359
+ and it counts as drift like any other finding — `audit` exits `1` on a foreign file exactly as
1360
+ it does on a `missing` / `stale` one, so an unaudited stray file in a fleet repo is never
1361
+ invisible to the CI gate. `audit` accepts an optional `--groups a,b` to restrict the compiled plan to the listed `Group`s
1362
+ (validated against `GROUPS`; an unrecognized name is a USAGE error — exit `2`, not a coded
1363
+ failure) — default absent compiles the FULL plan, unchanged; **`repair`** is the HOST-RESTORATION tool, full
1364
+ stop — after compiling, it filters the plan to `origin === 'host'` artifacts ONLY (including
1365
+ `.github/workflows/ci.yml` — single-target explicit intent keeps full HOST scope, unlike
1366
+ `fleet`) BEFORE hydrate/diff/apply, so a mature repo's hand-written `src` / `tests` / `guides`
1367
+ / `package.json` is NEVER overwritten with a generated stub — writes a SINGLE target's
1368
+ missing + stale HOST artifacts and, behind a SECOND confirm (or `--prune`), deletes
1369
+ target-only files under `.claude/agents/` and `scripts/` ONLY — the preview (and `fleet`'s
1370
+ equivalent preview) LISTS the exact paths `pruneTargets` found rather than a bare count, the
1371
+ confirm states the TRUE count derived from that same list, and a count of `0` prints
1372
+ "no unexpected files to delete" and skips the prune question entirely (there is nothing to
1373
+ ask about) — `--prune` reaches this scan/preview/confirm/deletion flow whenever there is prune
1374
+ work to do, REGARDLESS of whether the host itself audits clean: a clean-host repo with a
1375
+ planted foreign file still gets pruned; only a clean host WITH nothing to prune skips straight
1376
+ to the "nothing to write" verdict. **`repair` WITHOUT `--prune` reports and restores template-owned (host-origin)
1377
+ files only** — its own audit is the raw `diffPlan` over the host-scoped plan, so it never
1378
+ reports (or acts on) unexpected files at all, an asymmetry with `audit`'s merged, honest
1379
+ foreign-aware report that is defensible (repair's scope is host-restoration, not deletion) but
1380
+ worth naming explicitly: run `audit` for the honest whole-picture report including unexpected
1381
+ files, and `repair --prune` to actually delete them. And **`fleet`** is the fleet
1382
+ verb: it walks the CURRENT WORKING DIRECTORY's IMMEDIATE CHILDREN via `discoverPackages`
1383
+ (never the cwd itself, and there is NO `--root` flag at all — the cd-model IS the interface:
1384
+ the caller `cd`s into the folder that CONTAINS the checkouts first, exactly as `repair` is the
1385
+ one-repo counterpart) → per-repo host-origin audit/repair, printing a per-repo AND total drift
1386
+ table and exiting nonzero on residual drift — fleet-wide writes EXCLUDE
1387
+ `.github/workflows/ci.yml` from their scope (repo-flavored CI genuinely diverges across two
1388
+ live repos in the fleet; a fleet write must never clobber that divergence), while
1389
+ single-target `repair` keeps FULL host scope, including `ci.yml`, since it operates on one
1390
+ repo the caller is intentionally editing. **`new`** / **`audit`** / **`repair`** / **`fleet`**
1391
+ default their read-only source to the resolved `hostRoot()` (the package's own vendored
1392
+ `dist/host`) and accept a repeatable `--from` to override it (a sibling repo's checkout, for
1393
+ fleet-wide mirroring; `--from` is UNCONFINED — a read-only source sits outside the
1394
+ write-destination containment law below) — the DEFAULT host degrades silently to
1395
+ presence-only auditing when it cannot resolve (dev ergonomics), but an EXPLICITLY-passed
1396
+ `--from` that fails to resolve to usable data is a coded `TARGET` failure on `audit` /
1397
+ `repair` / `fleet`, never a silent downgrade, since the caller named that source on purpose.
1398
+ **`catalog`** is the sixth: its default source is the npm REGISTRY (`Sync.catalog()`), the
1399
+ AUTHORITATIVE package list, sourced UNAUTHENTICATED (every fleet repo is public); the SAME
1400
+ repeatable `--from` (unified — the prior separate `--host` name and catalog-only `--root` name
1401
+ are BOTH gone) ADDS local-only discoveries the registry doesn't know about yet (registry wins
1402
+ overlapping `version`, the local guide's description wins overlapping `description` when a
1403
+ `--from` root carries one — it may be ahead of GitHub); `--offline` skips the registry/GitHub
1404
+ entirely and sources `--from`(s) only (default `[process.cwd()]`) — the prior, fully-local
1405
+ behavior, now opt-in rather than the silent default (a single stale `--from` can no longer
1406
+ quietly shrink the catalog to whatever that one root sees). The merged/local entries are
1407
+ spliced (via the pure core's `catalogToBlock`) into the fleet package catalog table embedded
1408
+ in `<target>/.claude/agents/orkestrel.md` between its `<!-- catalog:start -->` /
1409
+ `<!-- catalog:end -->` markers — a `target` missing those markers is a coded `TARGET` failure,
1410
+ never a silent skip — and a SHRINK WARNING prints (dry-run and write alike) whenever the new
1411
+ table has fewer rows than the one currently embedded, so an accidental catalog shrink is
1412
+ never silent either. The TERMINAL preview (`catalogTable`, printed before the apply prompt)
1413
+ lists Package + Version only, one line per package — descriptions can run long and would wrap
1414
+ each preview row across the terminal, so they stay out of the preview and appear only in the
1415
+ written `orkestrel.md` table and in `--json`'s entries. It narrates through `@orkestrel/console` and
1416
+ prompts interactively through `@orkestrel/terminal`'s `createTerminal` when a required argument
1417
+ is absent, but ONLY on a real TTY (§ non-TTY ceiling below); a piped run instead falls back to
1418
+ its flags, or fails a coded USAGE error naming which flag to pass.
1419
+
1420
+ An unknown verb (`scaffold sync`, `scaffold mirror`, or any typo) resolves through the
1421
+ did-you-mean helper — `sync` and `mirror` are the two RENAMED former verbs and print an
1422
+ EXPLICIT redirect (`'sync' has been renamed — use 'scaffold pull'`,
1423
+ `'mirror' has been renamed — use 'scaffold fleet'`) rather than a fuzzy guess; every other
1424
+ unrecognized verb still gets the nearest-match suggestion. Either way it is a USAGE error —
1425
+ exit `2`.
1426
+
1427
+ Dry-run is the default posture everywhere. On a real terminal, every WRITE verb PREVIEWS its
1428
+ plan (or drift, or freshness) and then ASKS — one confirm question, default **No**; `repair`'s
1429
+ (and `fleet`'s) prune deletion sits behind a SECOND default-**No** question, asked only once
1430
+ the first write is accepted and ONLY when `pruneTargets` found at least one unexpected file
1431
+ (a `0` count skips the question — nothing to ask about, see the prune-UX paragraph above).
1432
+
1433
+ `audit` NEVER writes — not even under `--apply` or `--yes` — full stop. Those two flags gate
1434
+ ONLY the plain-old `resolveApply` write-confirm every OTHER verb reads; `audit` never calls it
1435
+ for itself, so passing them to `audit` changes nothing about `audit`'s own read-only pass.
1436
+ What `audit` DOES offer, purely as an INTERACTIVE convenience, is a repair HANDOFF — a confirm
1437
+ question asking whether to launch a real `repair` run on the audited target — and that handoff
1438
+ is gated STRICTLY on the session being a real TTY. `--apply` / `--yes` are NOT handoff consent:
1439
+ on a non-TTY session (any piped/CI run, `--json` or not) the handoff is never even offered,
1440
+ regardless of which flags were passed — the flags that DO reach the handoff are the ones
1441
+ `audit` forwards to the `repair` run it launches AFTER the human accepts the question, exactly
1442
+ as if `repair` had been invoked directly. The handoff is offered when there is host/template-
1443
+ origin (shared-file) drift, OR when there are unexpected (foreign) files AND `--prune` was
1444
+ passed (a repair without `--prune` cannot delete a foreign file, so offering the handoff for
1445
+ foreign-only drift without `--prune` would be a dead end that still exits `1`) — its question
1446
+ names exactly what will happen (`N template-owned files have drift`, plus `and M unexpected
1447
+ files will be deleted` only when `--prune` is active and there are foreign files to delete),
1448
+ never promising a deletion it will not perform. When foreign files exist but the handoff can't
1449
+ help them (no `--prune`, or no handoff offered at all — e.g. non-TTY, or no owned drift and no
1450
+ `--prune`), `audit` instead prints a plain hint pointing at `scaffold repair --prune`.
1451
+ Generated-file-only drift never offers a handoff either way — `repair` cannot fix it, so
1452
+ `audit` prints a plain note that those files are generated instead. After an ACCEPTED handoff,
1453
+ `audit` re-diffs the FULL plan (not just the host-scoped slice `repair` wrote) and exits `1` if
1454
+ ANY drift remains — e.g. the generated-file drift `repair` structurally cannot touch — so a
1455
+ green handoff always means the WHOLE target is clean, never just the host-owned slice. A
1456
+ `ctrl-c` at any prompt prints `cancelled — nothing written` and exits `1` — an interrupted run
1457
+ is never mistaken for a clean one. `--yes` pre-answers every OTHER verb's write/prune prompts
1458
+ affirmatively, so a scripted run never blocks on a TTY that will never come; `--apply` goes
1459
+ further for those verbs, skipping the ask ENTIRELY and writing unasked, on a terminal or off
1460
+ one alike.
1461
+
1462
+ **The non-TTY prompt ceiling.** A non-interactive session (piped stdin, CI, any non-TTY) issues
1463
+ AT MOST ONE prompt EVER — the single write confirm above. Any SECOND question a verb would
1464
+ otherwise ask on a terminal (the prune confirm) instead resolves straight to its safe default
1465
+ (never prune) and prints a one-line explanation of why it was skipped, rather than blocking on
1466
+ input that will never arrive; `audit`'s repair-handoff question is not a "second prompt" that
1467
+ degrades this way — it is TTY-ONLY from the start (see above), so a non-TTY `audit` never asks
1468
+ it at all, degrading straight to the plain hint/note instead. A REQUIRED input missing on a
1469
+ non-TTY session (e.g. `new` with no name and no `--surfaces`) is a USAGE error — exit `2` —
1470
+ naming the flag to pass, directing the human to either run the verb bare on a real terminal or
1471
+ supply the flag; it is never silently defaulted. Full multi-prompt guidance (the name prompt,
1472
+ the surfaces checkbox, the write confirm, the prune confirm, the handoff offer) is TTY-ONLY — a
1473
+ non-TTY session never sees more than the one write confirm, and `--json` (which never prompts
1474
+ at all, TTY or not) is the strictest case of this ceiling.
1475
+
1476
+ `--json` emits EXACTLY ONE JSON value per verb on stdout — the same serializable contract each
1477
+ verb already returns (`SyncReport` for `pull`, `Audit` for `audit` / `repair` — now including
1478
+ the merged foreign-file data, plus a `live` field mirroring the freshness verdict when `--live`
1479
+ was passed to `audit` — `{ entries, drift, shrink? }` for `catalog`, a fleet drift array for
1480
+ `fleet`) — with NO prose alongside it, and implies non-interactive (no prompt, ever, under
1481
+ `--json`; combine with `--apply` to also write). EVERY path emits exactly one JSON value under
1482
+ `--json` — including an unknown-verb usage error and any otherwise-unexpected failure — as a
1483
+ single error envelope (`{ error: { code, message } }`) whose `code` carries the real
1484
+ `ScaffoldError` code (or `USAGE` for a bad flag / unknown verb), never a bare stderr line that
1485
+ breaks the "exactly one JSON value" contract. Every
1486
+ write destination (`new`'s resolved target, `--target` on `pull` / `audit` / `repair` /
1487
+ `catalog`, and `fleet`'s cwd-relative per-repo targets) is confined to the current working
1488
+ directory — equal to it or nested beneath — so the CLI is safe to run as a global command
1489
+ anywhere; a read-only source (`--from`) is EXEMPT from that containment law, and an escaping
1490
+ destination is a coded `INVALID` failure, never a silent clamp. Exit codes are UNIFORM across
1491
+ every verb — `0` clean, `1` drift or failure (including a cancelled prompt), `2` usage — and
1492
+ `pull`'s prior posture of exiting `0` on an unresolved failure outside `--strict` is GONE: any
1493
+ drift or failure now exits `1` regardless of `--strict` (`--strict` still additionally THROWS
1494
+ `ScaffoldError('FETCH', …)` on a network fault, rather than merely exiting nonzero).
1495
+
1496
+ ```ts
1497
+ // The `#!/usr/bin/env node` shebang is re-emitted by the build's `output.banner`, not source.
1498
+ import { parseArgs } from 'node:util'
1499
+ import {
1500
+ blueprint,
1501
+ blueprintToPlan,
1502
+ createCompiler,
1503
+ dependency,
1504
+ diffPlan,
1505
+ planToReview,
1506
+ planToSummary,
1507
+ SURFACES,
1508
+ syncToReview,
1509
+ } from '@src/core'
1510
+ import {
1511
+ createMaterializer,
1512
+ createSync,
1513
+ deriveBlueprint,
1514
+ discoverPackages,
1515
+ hostRoot,
1516
+ hydratePlan,
1517
+ pruneTargets,
1518
+ readTarget,
1519
+ } from '@src/server'
1520
+ import { createReporter, createSpinner } from '@orkestrel/console'
1521
+ import { createServerSink } from '@orkestrel/console/server'
1522
+ import { createTerminal } from '@orkestrel/terminal/server'
1523
+
1524
+ const { values, positionals } = parseArgs({
1525
+ allowPositionals: true,
1526
+ options: {
1527
+ surfaces: { type: 'string' },
1528
+ deps: { type: 'string' },
1529
+ target: { type: 'string' },
1530
+ from: { type: 'string', multiple: true },
1531
+ apply: { type: 'boolean', default: false },
1532
+ yes: { type: 'boolean', default: false },
1533
+ json: { type: 'boolean', default: false },
1534
+ strict: { type: 'boolean', default: false },
1535
+ live: { type: 'boolean', default: false },
1536
+ prune: { type: 'boolean', default: false },
1537
+ offline: { type: 'boolean', default: false },
1538
+ groups: { type: 'string' },
1539
+ },
1540
+ })
1541
+
1542
+ const sink = createServerSink()
1543
+ const reporter = createReporter({ sink, width: sink.columns })
1544
+ const [command] = positionals // 'new' | 'pull' | 'audit' | 'repair' | 'fleet' | 'catalog'
1545
+ const target = values.target ?? '.'
1546
+ const from = values.from ?? []
1547
+ const host = from[0] ?? hostRoot() // default: the package's own vendored dist/host
1548
+ const materializerOptions = from[0] ? { host: from[0] } : {}
1549
+ const terminal = createTerminal()
1550
+
1551
+ // Every write verb shares this: --apply skips the ask entirely; --json NEVER prompts and
1552
+ // NEVER writes on its own (json without --apply is always a pure dry-run, regardless of
1553
+ // --yes — json + apply is the only way to write under --json); --yes auto-answers yes on
1554
+ // a non-json run; otherwise only a real terminal ever asks (a non-TTY session here is the
1555
+ // ONE prompt the non-TTY ceiling allows).
1556
+ async function confirmWrite(message: string): Promise<boolean> {
1557
+ if (values.apply) return true
1558
+ if (values.json) return false
1559
+ if (values.yes) return true
1560
+ try {
1561
+ return await terminal.confirm({ message, default: false })
1562
+ } catch {
1563
+ reporter.status('error', 'cancelled — nothing written')
1564
+ process.exit(1)
1565
+ }
1566
+ }
1567
+
1568
+ // The SECOND, prune-only confirm — never bundled into confirmWrite's question, and only
1569
+ // ever asked once (the non-TTY ceiling: a second question off a real terminal instead
1570
+ // resolves to its safe default with a printed explanation, never a second prompt).
1571
+ async function confirmPrune(target: string, message: string): Promise<readonly string[]> {
1572
+ const found = pruneTargets(target, host) // the exact paths the preview already listed
1573
+ if (found.length === 0) return [] // "no unexpected files to delete" — no question asked
1574
+ if (values.apply || values.prune) return found
1575
+ if (values.json || values.yes) return values.yes ? found : []
1576
+ if (!terminal.isTTY) {
1577
+ reporter.status('info', 'non-TTY: skipping the second prune question — defaulting to No')
1578
+ return []
1579
+ }
1580
+ return (await confirmWrite(message)) ? found : []
1581
+ }
1582
+
1583
+ // --json emits exactly one JSON value (the verb's own serializable contract), no prose.
1584
+ function emit(value: unknown, prose: () => void): void {
1585
+ if (values.json) reporter.line(JSON.stringify(value))
1586
+ else prose()
1587
+ }
1588
+
1589
+ if (command === 'pull') {
1590
+ const sync = createSync({ strict: values.strict })
1591
+ const report = await sync.pull(target)
1592
+ emit(report, () => reporter.line(syncToReview(report)))
1593
+ if (!report.clean && (await confirmWrite('Write refreshed guide mirrors?'))) {
1594
+ await sync.write(report, target)
1595
+ }
1596
+ sync.destroy()
1597
+ process.exit(report.clean ? 0 : 1) // uniform: any drift/failure is nonzero, --strict or not
1598
+ } else if (command === 'audit') {
1599
+ // deriveBlueprint reconstructs the target's spec from its own package.json + src/<surface>/ dirs.
1600
+ const plan = hydratePlan(blueprintToPlan(deriveBlueprint(target)), host)
1601
+ const audit = diffPlan(
1602
+ plan,
1603
+ readTarget(
1604
+ target,
1605
+ plan.artifacts.map((a) => a.path),
1606
+ ),
1607
+ )
1608
+ // The prune scan MERGES into the audit report: `pruneTargets`'s count becomes `audit.foreign`
1609
+ // (a real finding, not a structurally-always-zero placeholder), counted toward drift — audit
1610
+ // exits 1 on a foreign file exactly like a missing/stale one — and carried under --json.
1611
+ const foreignPaths = pruneTargets(target, host)
1612
+ const merged = {
1613
+ ...audit,
1614
+ foreign: foreignPaths.length,
1615
+ clean: audit.clean && foreignPaths.length === 0,
1616
+ }
1617
+ emit(merged, () => reporter.line(planToReview(plan)))
1618
+ // The handoff is a TTY-ONLY interactive convenience — `values.apply` / `values.yes` are
1619
+ // NEVER handoff consent (audit itself never writes on their account; they only gate the
1620
+ // SEPARATE `repair` run launched below). It is offered when there is host/template-origin
1621
+ // drift, OR foreign files AND `--prune` was passed (a repair without `--prune` cannot
1622
+ // delete a foreign file, so offering the handoff for foreign-only drift without `--prune`
1623
+ // would be a dead end). Generated-only drift never offers a handoff either way.
1624
+ const originOf = new Map(plan.artifacts.map((a) => [a.path, a.origin]))
1625
+ const drifted = audit.findings.filter((f) => f.drift !== 'aligned')
1626
+ const ownedDrift = drifted.some((f) => originOf.get(f.path) === 'host')
1627
+ const generatedDrift = drifted.some((f) => originOf.get(f.path) !== 'host')
1628
+ const offerHandoff = terminal.isTTY && (ownedDrift || (foreignPaths.length > 0 && values.prune))
1629
+ let handoffAccepted = false
1630
+ if (offerHandoff) {
1631
+ handoffAccepted = await terminal.confirm({ message: 'Hand off to repair?', default: false })
1632
+ if (handoffAccepted) {
1633
+ reporter.status('info', `run: scaffold repair --target ${target}`)
1634
+ // After an ACCEPTED handoff, re-diff the FULL plan (not just repair's host-scoped
1635
+ // slice) so a "clean" handoff always means the whole target, not just the host set.
1636
+ }
1637
+ }
1638
+ if (!handoffAccepted && !values.json) {
1639
+ if (foreignPaths.length > 0 && !values.prune) {
1640
+ reporter.status(
1641
+ 'info',
1642
+ "unexpected files found — run 'scaffold repair --prune' to delete them",
1643
+ )
1644
+ }
1645
+ if (generatedDrift) {
1646
+ reporter.status(
1647
+ 'info',
1648
+ 'generated-file drift found — these files are generated, not hand-repaired',
1649
+ )
1650
+ }
1651
+ }
1652
+ process.exit(merged.clean ? 0 : 1) // ANY drift — including a foreign file — fails the CI gate
1653
+ } else if (command === 'repair') {
1654
+ // `scaffold repair` — single target, dry-run default, HOST-ORIGIN scope ONLY
1655
+ // (ci.yml included — full HOST scope, unlike fleet's ci.yml exclusion), so
1656
+ // hand-written src/tests/guides/package.json are never overwritten.
1657
+ const compiled = blueprintToPlan(deriveBlueprint(target))
1658
+ const scopedToHost = {
1659
+ ...compiled,
1660
+ artifacts: compiled.artifacts.filter((a) => a.origin === 'host'),
1661
+ }
1662
+ const plan = hydratePlan(scopedToHost, host)
1663
+ const audit = diffPlan(
1664
+ plan,
1665
+ readTarget(
1666
+ target,
1667
+ plan.artifacts.map((a) => a.path),
1668
+ ),
1669
+ )
1670
+ emit(audit, () => reporter.line(planToReview(plan)))
1671
+ const materializer = createMaterializer(materializerOptions)
1672
+ const wrote = !audit.clean && (await confirmWrite('Write missing/stale host files?'))
1673
+ if (wrote) materializer.repair(plan, audit, target)
1674
+ // pruning sits behind its OWN second confirm, only once the first write is accepted — and
1675
+ // the preview above already LISTED the exact paths `pruneTargets` found (never a bare
1676
+ // count); a count of 0 skips this question entirely (nothing to ask about).
1677
+ if (wrote) {
1678
+ const foreignPaths = pruneTargets(target, host)
1679
+ const pruneNow = await confirmPrune(
1680
+ target,
1681
+ `Also prune ${foreignPaths.length} foreign file(s)?`,
1682
+ )
1683
+ if (pruneNow.length > 0) materializer.prune(target) // .claude/agents/ + scripts/ foreigns ONLY
1684
+ }
1685
+ materializer.destroy()
1686
+ process.exit(!audit.clean && !wrote ? 1 : 0)
1687
+ } else if (command === 'fleet') {
1688
+ // `scaffold fleet` — the CURRENT WORKING DIRECTORY's IMMEDIATE CHILDREN only, never the
1689
+ // cwd itself: the cd-model IS the interface, no --root flag exists — the caller cd's into
1690
+ // the folder that CONTAINS the checkouts first; `repair` is the one-repo counterpart.
1691
+ const materializer = createMaterializer(materializerOptions)
1692
+ let totalDrift = 0
1693
+ const rows: Array<readonly [string, number]> = []
1694
+ for (const repoTarget of discoverPackages('.')) {
1695
+ const repoPlan = blueprintToPlan(deriveBlueprint(repoTarget))
1696
+ const scoped = {
1697
+ ...repoPlan,
1698
+ artifacts: repoPlan.artifacts.filter((a) => a.path !== '.github/workflows/ci.yml'),
1699
+ }
1700
+ const hydrated = hydratePlan(scoped, host)
1701
+ const audit = diffPlan(
1702
+ hydrated,
1703
+ readTarget(
1704
+ repoTarget,
1705
+ hydrated.artifacts.map((a) => a.path),
1706
+ ),
1707
+ )
1708
+ if (!audit.clean) {
1709
+ totalDrift += audit.drifted + audit.missing
1710
+ if (await confirmWrite(`Write drift for ${repoTarget}?`)) {
1711
+ materializer.repair(hydrated, audit, repoTarget)
1712
+ }
1713
+ }
1714
+ rows.push([repoTarget, audit.drifted + audit.missing])
1715
+ }
1716
+ materializer.destroy()
1717
+ emit(
1718
+ rows.map(([repo, drift]) => ({ repo, drift })),
1719
+ () =>
1720
+ reporter.table({
1721
+ columns: [{ label: 'Repo' }, { label: 'Drift', align: 'right' }],
1722
+ rows: rows.map(([repo, drift]) => [repo, String(drift)]),
1723
+ }),
1724
+ )
1725
+ process.exit(totalDrift > 0 ? 1 : 0)
1726
+ } else if (command === 'catalog') {
1727
+ // --from ADDS local-only discoveries to the registry-authoritative default; --offline
1728
+ // sources --from(s) only (default [cwd]); merged/local entries splice via catalogToBlock
1729
+ // into <target>/.claude/agents/orkestrel.md between its markers, shrink-warning either way.
1730
+ // --json's value is `{ entries, drift, shrink? }` — NEVER a bare `CatalogEntry[]`, so a
1731
+ // consumer can read the drift verdict and any shrink warning without re-deriving them.
1732
+ // (Illustrative — see catalogToBlock / Sync.catalog in the Surface above.)
1733
+ process.exit(0)
1734
+ } else {
1735
+ // `scaffold new <name>` — creation.
1736
+ const name =
1737
+ positionals[1] ??
1738
+ (await terminal.input({ message: 'Package name', validate: { pattern: '^[a-z][a-z0-9-]*$' } }))
1739
+ const picked =
1740
+ values.surfaces?.split(',') ??
1741
+ (await terminal.checkbox({ message: 'Surfaces', choices: [...SURFACES], min: 1 }))
1742
+ const surfaces = SURFACES.filter((surface) => picked.includes(surface)) // narrow to Surface[], no `as`
1743
+
1744
+ // --deps (@orkestrel/* runtime deps) resolves an absent range through the registry —
1745
+ // ranges pin ^latest; its guides additionally fetch into the plan. On a real terminal,
1746
+ // this prompt becomes an interactive question instead — @orkestrel short names,
1747
+ // catalog-validated — illustrated in prose above, omitted here for brevity. Other npm
1748
+ // packages are hand-added to `package.json`'s `devDependencies` AFTER scaffolding —
1749
+ // `deriveBlueprint`'s `extras` round-trip (below) picks them back up on the next
1750
+ // `audit`/`repair`/`pull`, so `new` never collects them itself.
1751
+ const sync = createSync()
1752
+ const versions = await sync.versions(
1753
+ (values.deps?.split(',') ?? []).map((depName) => dependency(depName, '*')),
1754
+ )
1755
+ sync.destroy()
1756
+ const deps = versions.map((version) => dependency(version.name, `^${version.latest}`))
1757
+
1758
+ const compiler = createCompiler()
1759
+ const scaffolding = compiler.compile(blueprint(name, { surfaces, dependencies: deps }))
1760
+ if (!scaffolding.plan) {
1761
+ reporter.status('error', scaffolding.questions.map((question) => question.text).join('; '))
1762
+ compiler.destroy()
1763
+ process.exit(2) // usage: an off-contract name/surfaces is caller error, not drift
1764
+ }
1765
+ emit(scaffolding.plan, () => {
1766
+ reporter.section('Plan')
1767
+ reporter.line(planToReview(scaffolding.plan)) // dry-run default: show the review
1768
+ const summary = planToSummary(scaffolding.plan)
1769
+ reporter.table({
1770
+ columns: [{ label: 'Origin' }, { label: 'Count', align: 'right' }],
1771
+ rows: [
1772
+ ['host', String(summary.host)],
1773
+ ['template', String(summary.template)],
1774
+ ['computed', String(summary.computed)],
1775
+ ],
1776
+ })
1777
+ })
1778
+ if (await confirmWrite('Write the package to disk?')) {
1779
+ const spinner = createSpinner({ message: 'materializing', sink })
1780
+ spinner.start()
1781
+ const materializer = createMaterializer()
1782
+ const result = materializer.materialize(scaffolding.plan, values.target ?? `./${name}`)
1783
+ materializer.destroy()
1784
+ spinner.success(`wrote ${result.written.length + result.copied.length} files`)
1785
+ }
1786
+ compiler.destroy()
1787
+ process.exit(0)
1788
+ }
1789
+ ```
1790
+
1791
+ The build wiring follows the §7 two-file wrapper pattern: `configs/src/tsconfig.bin.json`
1792
+ sets `types: ["node"]` and uses the `rootDir` trick (the broad `../../src` root with a scoped
1793
+ `include: ["../../src/bin/**/*.ts"]`) so the bin can type-check against `@src/core` source;
1794
+ `configs/src/vite.bin.config.ts` is a lib build with `entry` → `dist/bin/scaffold.js`,
1795
+ `formats: ['es']`, externals `node:*` / `@orkestrel/*` / `@src/*`, an `output.banner`
1796
+ re-emitting the `#!/usr/bin/env node` shebang, and NO dts plugin (an executable ships no
1797
+ declarations). `package.json` declares `"bin": { "scaffold": "./dist/bin/scaffold.js" }`, and
1798
+ `build:src` chains the bin build LAST (after core and server) so the executable links against
1799
+ fresh sibling builds. Invocation follows the tool's life: `npm run scaffold` pre-publish (the
1800
+ repo's own script), `npx @orkestrel/scaffold` post-publish, and `node_modules/.bin/scaffold`
1801
+ once it is a devDependency of a consumer.
1802
+
1803
+ ```sh
1804
+ # new — create a package (dry-run previews; on a terminal it then asks, default No; --apply
1805
+ # writes unasked); --deps is @orkestrel/* runtime deps (dependencies). Other npm packages are
1806
+ # NOT a new-time flag — hand-add them to the generated package.json's devDependencies after
1807
+ # scaffolding; audit/repair/pull recompile them back into the plan (deriveBlueprint's extras
1808
+ # round-trip), so a hand-added devDependency stays audit-clean:
1809
+ npx @orkestrel/scaffold new router --surfaces core,browser,server
1810
+ npx @orkestrel/scaffold new router --deps @orkestrel/contract --apply --target ./packages/router
1811
+
1812
+ # pull — refresh vendored dep mirrors + report range drift (any drift/failure exits 1,
1813
+ # --strict or not; --strict additionally THROWS on a network fault):
1814
+ npx @orkestrel/scaffold pull --target . --apply
1815
+ npx @orkestrel/scaffold pull --deps @orkestrel/contract,@orkestrel/emitter --strict --json
1816
+
1817
+ # audit — structural conformance (now merging the prune scan: a stray file under
1818
+ # .claude/agents/ or scripts/ is a real foreign finding, counted as drift), +live freshness;
1819
+ # nonzero on ANY drift, foreign files included (the CI gate); offers a repair handoff on a
1820
+ # terminal whenever host-origin drift OR a foreign file is found (never for generated-only
1821
+ # drift, which gets a plain note instead); --json emits exactly one Audit value, its `foreign`
1822
+ # count now real and a `live` field present when --live ran:
1823
+ npx @orkestrel/scaffold audit --live
1824
+ npx @orkestrel/scaffold audit --json
1825
+
1826
+ # repair — single target, dry-run default; on a terminal it asks before writing (--apply
1827
+ # skips the ask), then a SECOND default-No question (or --prune) before deleting
1828
+ # target-only files under .claude/agents/ and scripts/ ONLY:
1829
+ npx @orkestrel/scaffold repair --target . --apply --prune
1830
+ npx @orkestrel/scaffold repair --target . --from ../contract # audit against a sibling's host
1831
+
1832
+ # fleet — the CURRENT WORKING DIRECTORY's IMMEDIATE CHILDREN (never the cwd itself) then
1833
+ # per-repo host-origin audit/repair; dry-run default, confirms per repo on a terminal
1834
+ # (--apply writes unasked), nonzero on residual drift; excludes
1835
+ # .github/workflows/ci.yml (repo-flavored — use `repair --apply` per repo for that one
1836
+ # file); NO --root flag at all — cd into the folder that CONTAINS your checkouts first
1837
+ # (repair is the single-repo tool: run it from inside one repo instead):
1838
+ cd ~/repos && npx @orkestrel/scaffold fleet
1839
+ cd ~/repos && npx @orkestrel/scaffold fleet --apply --json
1840
+
1841
+ # catalog — regenerate the fleet package catalog embedded in orkestrel.md between its
1842
+ # markers; the npm registry is authoritative by default (unauthenticated), --from ADDS
1843
+ # local-only discoveries (repeatable — the SAME flag the read-only-source verbs use,
1844
+ # replacing the old --host and catalog's old --root), --offline sources --from(s) only,
1845
+ # dry-run reports drift (nonzero) plus any shrink warning, --apply writes:
1846
+ npx @orkestrel/scaffold catalog --target . --apply
1847
+ npx @orkestrel/scaffold catalog --from ~/repos --from ~/other-repos --target . --apply
1848
+ npx @orkestrel/scaffold catalog --offline --from ~/repos --target . --apply
1849
+
1850
+ # unknown verb — the two RENAMED former names redirect explicitly rather than a fuzzy guess;
1851
+ # any other typo still gets the nearest-match suggestion. Either way: usage error, exit 2.
1852
+ npx @orkestrel/scaffold sync # 'sync' has been renamed — use 'scaffold pull'
1853
+ npx @orkestrel/scaffold mirror # 'mirror' has been renamed — use 'scaffold fleet'
1854
+ ```
1855
+
1856
+ ### Fleet wiring
1857
+
1858
+ The rendered defaults ship as **versioned package data**: each is a frozen
1859
+ `TemplateDefinition` (a `name`, a `content` string with `{{token}}` placeholders, and its
1860
+ `placeholders`) filled by `@orkestrel/template`'s pure `fillTemplate` with `missing: 'error'`
1861
+ — NOT bespoke string interpolation, and NOT a `TemplateManager` (the compiler carries no
1862
+ sub-engine). The byte-copied governance files (`HOST_PATHS`) ship VENDORED inside the
1863
+ published tarball at `dist/host/` (staged there by the build's `build:host` step, alongside a
1864
+ `dist/host/manifest.json` recording each entry's storage name, destination, and executable
1865
+ bit), which the server's `Materializer` copies from its `host` root — the package's OWN
1866
+ vendored copy by default, or an explicit `--from` sibling for fleet-wide mirroring. There is
1867
+ ONE versioned source of truth, and `npm update @orkestrel/scaffold` propagates a convention
1868
+ change to every consumer.
1869
+
1870
+ This package is the line's sole scaffolding and fleet-conformance spec, and this guide is its
1871
+ sole living document — the variant matrix, the per-file inventory, the exports shapes, the
1872
+ config wrappers, and the audit checklist all live here, projected from the same `Plan` the
1873
+ compiler emits. Every repo in the fleet carries `@orkestrel/scaffold` as a devDependency
1874
+ (pinned at `SCAFFOLD_RANGE`, joining `@orkestrel/guide` as line-wide dev tooling) and a
1875
+ `"scaffold": "scaffold"` script against the installed bin. Fleet-truing runs as
1876
+ `scaffold fleet` — either from the package's own vendored `dist/host` (the common case) or
1877
+ from an explicit `--from` sibling repo — trueing every repo's shared artifacts across the
1878
+ workspace; `repair` and `audit` operate per repo. `.github/workflows/ci.yml` is the one
1879
+ fleet exception: two repos carry repo-flavored CI, so `fleet` never writes it — a
1880
+ single-target `repair --apply` does, per repo, for that one file.
1881
+
1882
+ ### Practices
1883
+
1884
+ - **Dry-run first, always** — `compile` / `blueprintToPlan` / `planToReview` and `Sync.pull` /
1885
+ `syncToReview` are report-only; read the plan (or the audit, or the freshness) before ever
1886
+ writing. Writing is opt-in (the server's `materialize` / `repair` / `Sync.write`, the bin's
1887
+ `--apply`).
1888
+ - **One blueprint, one package** — a compound request (two packages) is two `compile` calls,
1889
+ not one blueprint with a wider `surfaces` list; `surfaces` selects the variant of ONE
1890
+ package, never bundles several.
1891
+ - **Audit before you edit a fleet repo** — `diffPlan` turns the per-file conformance checklist
1892
+ into findings; add `--live` for guide + range freshness, or `--groups a,b` to gate CI on a
1893
+ subset (e.g. `--groups configs,docs,orchestration`); repair the `missing` / `stale` HOST-ORIGIN
1894
+ set with `repair` (never a hand-written `src` / `tests` / `package.json` file — `repair` is
1895
+ the host-restoration tool ONLY), refresh mirrors with `Sync.write`, and leave `aligned` /
1896
+ `current` untouched.
1897
+ - **A named `--from` must resolve** — the default host degrades to presence-only silently when
1898
+ absent (dev ergonomics), but an explicitly-passed `--from` that fails to resolve is a coded
1899
+ `TARGET` failure on `audit` / `repair` / `fleet` — never a silent downgrade for a source the
1900
+ caller named on purpose.
1901
+ - **True the fleet with `fleet`, not by hand** — run `scaffold fleet` from the folder that
1902
+ CONTAINS your checkouts (no `--root` flag — the cd-model is the interface); it runs
1903
+ `discoverPackages` → per-repo hydrated audit → repair on an accepted confirm (or `--apply`),
1904
+ EXCLUDING `.github/workflows/ci.yml` (repo-flavored, never fleet-clobbered — use
1905
+ single-target `repair --apply` for that one file); reach for `repair --prune` only for the
1906
+ bounded `.claude/agents/` / `scripts/` cleanup a single target needs, never a wider deletion.
1907
+ - **Override, don't fork** — need a bespoke file? Add one `override` for that path; the rest
1908
+ stay canonical and keep tracking the shipped templates. Never copy the whole plan to change
1909
+ one file.
1910
+ - **Reference deps by their real range** — a `dependency('@orkestrel/contract', '^0.0.5')`
1911
+ drives the `package.json` entry, the vendored guide mirror (when scaffold ships it), and the
1912
+ build externals from one declaration; declare exactly what `src/` imports (the exports combination rules).
1913
+ - **Collect by default, `strict` for CI** — leave `Sync` in collect mode for an interactive
1914
+ freshness report; flip `strict: true` only where a network fault MUST fail the run (a CI
1915
+ gate), and inject `guides.base` / `registry.base` at a local fixture for hermetic tests.
1916
+ - **Gate untrusted blueprints twice** — `parseBlueprint` for shape at the boundary,
1917
+ `validateBlueprint` for semantics; reserve `createBlueprint`'s throw for programmer-error
1918
+ contexts where invalidity is a bug (§12).
1919
+ - **Store `pinPlan` output, not drafts** — the `hash` is the identity;
1920
+ `JSON.stringify(plan)` out, `parsePlan` back in, and the `PlanManager` recognizes the
1921
+ unchanged content as the same version.
1922
+ - **Keep the target vacant for creation** — `materialize` refuses a non-empty target
1923
+ (throwing `TARGET`); repair into an existing package with `repair`, never by clearing it
1924
+ first.
1925
+ - **Destroy when done** — `destroy()` releases the emitter; a destroyed `Compiler` /
1926
+ `PlanManager` / `Materializer` / `Sync` throws `DESTROYED` on use (narrow with
1927
+ `isScaffoldError`).
1928
+
1929
+ ## Tests
1930
+
1931
+ Environment-dependent cases degrade gracefully rather than false-redding: `tests/setupServer.ts`
1932
+ probes the running host's actual capability once at load (`canSymlink`, `canSocket`, `hasModes` —
1933
+ the last is platform-as-semantics, since POSIX mode bits have no Windows equivalent to probe for),
1934
+ and the handful of tests that need a real symlink, a real Unix domain socket, or a real exec bit
1935
+ guard themselves with `it.skipIf` naming exactly what goes unverified on a host lacking the
1936
+ capability. Every one of those cases runs — and must pass — unconditionally on a capable POSIX
1937
+ host; the skip is the environment's ceiling, never a hidden failure.
1938
+
1939
+ - [`tests/guides/src/parity.test.ts`](../../tests/guides/src/parity.test.ts) — the
1940
+ `## Surface` ↔ `src/core` + `src/server` bijection (value + type exports; `src/bin` is
1941
+ EXCLUDED — the executable has no public exports) and the `## Methods` ↔ interface-method
1942
+ bijection, across both library surfaces.
1943
+ - [`tests/src/core/Compiler.test.ts`](../../tests/src/core/Compiler.test.ts) — the three-stage
1944
+ pipeline, stage order and records, group-scoped compilation, the `audit` projection,
1945
+ override layering, fail-closed blocking (questions + `BLOCKED` failure + absent plan), event
1946
+ sequences (`compile` vs `block`, `audit`), idempotent `destroy`, `DESTROYED` throws.
1947
+ - [`tests/src/core/PlanManager.test.ts`](../../tests/src/core/PlanManager.test.ts) —
1948
+ content-hash IS the id, distinct content mints a fresh record at `version: 1`, an
1949
+ unchanged re-add returns the existing record with `version` never incrementing, batch
1950
+ `remove` all-or-nothing, per-event emissions, destroy semantics.
1951
+ - [`tests/src/core/helpers.test.ts`](../../tests/src/core/helpers.test.ts) — every projection
1952
+ (`blueprintToMembers` inventory, `blueprintToPlan` PER-VARIANT generation conformance across
1953
+ ALL SIX live classes — core-only, core+server, core+browser+server, server-only,
1954
+ browser-only, core+browser — asserting the right `src/<surface>/*` + `tests/src/<surface>/*`
1955
+ artifact set, the conditional `setupServer.ts` / `setupBrowser.ts`, the computed
1956
+ `{{specifiers}}` parity fill, and the three `rootViteConfig` shapes per surface count, plus
1957
+ `SURFACE_MATRIX` wiring and the manifest/exports combination rules — `peerDependencies` /
1958
+ `peerDependenciesMeta` emission for `peers`, `extras` merging into `devDependencies` with
1959
+ extras winning a collision — template-fill vs computed origins + the token-collision
1960
+ boundary, `planToReview` / `auditToReview` / `syncToReview` table emission, `planToSummary`
1961
+ counts, `diffPlan` drift verdicts incl. host presence-only on a RAW (unhydrated) plan,
1962
+ `manifestToDependencies` across all three sections deduplicated, `rangeToFreshness` exact-pin
1963
+ law, `pinPlan` determinism), `validateBlueprint` errors + warnings (incl. the per-array
1964
+ `peers` / `extras` name/range/duplicate rules and the three cross-array overlap blocks),
1965
+ `validateDependencyArray`'s pure `{ questions, seen }` return, `pascalCase`, `alignTable`
1966
+ (oxfmt-width padding, `\|` escaping, alignment delimiter row) and its `splitTableRow` /
1967
+ `padCell` / `delimiterCell` leaves, `isBehind`'s `'behind'`-only verdict, `inferGroup`'s
1968
+ ordered prefix classification, `isRecord`'s plain-object narrowing, and `computeHash` /
1969
+ `stableStringify` determinism (key-order-independent hashing).
1970
+ - [`tests/src/core/compilers.test.ts`](../../tests/src/core/compilers.test.ts) — every drafting
1971
+ leaf `blueprintToPlan` orchestrates: `hostGroup` classification, `fillArtifact` template
1972
+ filling (with/without a `surface` tag, throwing on an unknown template id), `surfaceVariant` /
1973
+ `entryFields` / `dualCondition` / `exportsMap` across every variant, `compareCodeUnit` /
1974
+ `devDependenciesFor` merging, `packageManifest` shape, `rootTsconfig`, the three
1975
+ `rootViteConfig` / `singleSurfaceViteConfig` shapes, `coreTsconfig` / `coreViteConfig`,
1976
+ `surfaceTsconfig` / `surfaceViteConfig`, `configArtifacts` / `sourceArtifacts` drafting,
1977
+ `paritySpecifiers` primary-surface resolution, `testArtifacts` drafting, `guideArtifacts` /
1978
+ `guideMemberTable` dedup-across-surfaces, `applyOverrides` replace/no-op/host-skip/no-match
1979
+ semantics, plus byte-for-byte cross-consistency between each direct leaf's output and the
1980
+ matching artifact `blueprintToPlan` emits, across every surface variant.
1981
+ - [`tests/src/core/builders.test.ts`](../../tests/src/core/builders.test.ts) — every builder's
1982
+ output shape (defaults filled, absent optional keys omitted, exact-guard round-trips).
1983
+ - [`tests/src/core/validators.test.ts`](../../tests/src/core/validators.test.ts) — each guard
1984
+ accepts valid / rejects invalid + adversarial junk, exact-record semantics, off-vocabulary
1985
+ literal rejection, `parseBlueprint` / `parsePlan` / `parseSyncReport` ↔ guard soundness.
1986
+ - [`tests/src/core/shapers.test.ts`](../../tests/src/core/shapers.test.ts) — `blueprintShape` /
1987
+ `planShape` / `syncReportShape` compilation through `createContract`: guard/parser/schema/generator
1988
+ lockstep, generated values satisfy their guards.
1989
+ - [`tests/src/server/Materializer.test.ts`](../../tests/src/server/Materializer.test.ts) —
1990
+ green-field `materialize` into a vacant temp dir (manifest-aware host copies incl. the
1991
+ executable bit off `manifest.json`, plus rendered writes), `TARGET` refusal on a non-vacant
1992
+ target, `repair` writing only drifted artifacts, `prune` deleting ONLY `foreign` artifacts
1993
+ under `.claude/agents/` / `scripts/` (the bounded containment law) and leaving a foreign file
1994
+ elsewhere untouched, PLUS `prune`'s FAIL-CLOSED law (H1): a `--host`-style unresolvable host
1995
+ root throws `TARGET` BEFORE any deletion (target files untouched), while a host that GENUINELY
1996
+ EXISTS and vendors zero files in a `directory` (an existing empty dir, or a manifest with zero
1997
+ entries there) still prunes every foreign file under it — the distinction is missing-host vs
1998
+ empty-vendor, never conflated; `isVacant` / `readTarget` / `hydratePlan` / `discoverPackages` /
1999
+ `hostRoot` / `deriveBlueprint` against a real `node:fs` fixture — `deriveBlueprint` fixtures
2000
+ covering surface detection off `src/<surface>/` directories, `peers` with
2001
+ `peerDependenciesMeta`-sourced `optional`, `extras` excluding the generated devDependency
2002
+ baseline (`devDependenciesFor([])`'s keys, covering `@orkestrel/guide` / `@orkestrel/scaffold`)
2003
+ AND any devDependency ALSO present in `peerDependencies` / `dependencies` (H3: the middleware
2004
+ pattern of dev-installing a peer for its own tests never double-lands in `extras`), an EXTERNAL
2005
+ (non-`@orkestrel`) devDependency (e.g. `zod`) surviving that exclusion as a genuine `extras`
2006
+ round-trip (U12c FIX 3: closes the hand-added-devDependency → immediate self-audit-DRIFTED
2007
+ regression — the CLI never collects `extras` itself; a reader hand-adds the entry to
2008
+ `package.json` and `deriveBlueprint` picks it back up), and the coded `TARGET` failures
2009
+ (unreadable/non-JSON/
2010
+ non-`@orkestrel` manifest, no surface directory); `hostRoot` resolving to the package's own
2011
+ BUILT `dist/host` bundle (never `process.cwd()`); `WRITE` fail-fast, `remove` event emission,
2012
+ destroy semantics.
2013
+ - [`tests/src/server/helpers.test.ts`](../../tests/src/server/helpers.test.ts) — `hostRoot`,
2014
+ `deriveBlueprint`, `discoverPackages`, `hydratePlan`, and `catalogPackages` against a real
2015
+ `node:fs` fixture; `diffPlan` content-comparing a HYDRATED host-origin artifact (a
2016
+ byte-mutated target is `stale`, counted in `drifted`) against the SAME target read by the
2017
+ UNHYDRATED plan (still `aligned` — presence-only preserved when there is no `content` to
2018
+ compare), plus a `Materializer.repair` round-trip proving a hydrated `'stale'` finding
2019
+ re-copies the artifact byte-equal from `host`; `pruneTargets` (a real fixture: an unexpected file under
2020
+ `.claude/agents/` / `scripts/` is reported, a vendored one is not, an absent prune directory
2021
+ under `target` yields `[]`, and the fail-closed `TARGET` throw when `host` cannot positively
2022
+ establish an allowlist for a prune directory that DOES exist under `target` — the same law
2023
+ `Materializer.prune` and the bin's audit/repair UX both consume), plus the three
2024
+ no-nested-functions leaves standalone:
2025
+ `selectOrkestrelEntries` (`@orkestrel/`-prefixed string-valued filtering, `[]` on a
2026
+ non-object), `isManifestEntry` (valid entry accepted; missing/mistyped `executable` and a
2027
+ non-object rejected), and `locateHostSource` (the manifest-`undefined` raw-join fallback, the
2028
+ single-match resolution, and `undefined` on zero or duplicate `destination` matches);
2029
+ `storagePath` (dotfile-top-level-file, `.claude`/`.github` directory-segment un-dotting,
2030
+ nested `.github/workflows/ci.yml`, and a plain undotted name) and `stageHost` against a real
2031
+ temp-directory fixture with a hand-built `paths` list — byte-preserving copies, the
2032
+ owner-execute bit captured AND propagated onto the staged copy, a wipe-first `out` (a stale
2033
+ file left over from a prior run disappears), the written `manifest.json`'s
2034
+ destination-sorted/tab-indented/trailing-newline shape, a missing-source `TARGET` naming the
2035
+ path, and a `storagePath` collision's `TARGET` naming both destinations.
2036
+ - [`tests/src/server/Sync.test.ts`](../../tests/src/server/Sync.test.ts) — a real `node:http`
2037
+ fixture serving guide bytes at `/<name>/<branch>/guides/src/<name>.md` and registry JSON
2038
+ `{"dist-tags":{"latest":"0.0.N"}}` at the URL-encoded scoped path, with `guides.base` /
2039
+ `registry.base` injected (§16 no-mocks): asserts fetching + writing under the containment
2040
+ law, the `freshness` verdicts (incl. `404` → `missing` and timeout → `failed`), the `strict`
2041
+ `FETCH` throw naming the URL, the bounded `concurrency`, the `guide` / `version` / `write` /
2042
+ `done` event order, and `DESTROYED`. `Sync.catalog` adds an org-list fixture
2043
+ (`/-/org/orkestrel/package`) plus per-package packument + guide routes: registry entries
2044
+ prefer the guide blockquote description over the packument's, a guide `404` STAYS LISTED
2045
+ with the packument-description fallback and the exact `guide unreachable (HTTP 404 — repo
2046
+ private or guide missing?)` note, a failed packument keeps the entry degraded (`version: ''`)
2047
+ rather than dropping it, an unreachable/malformed org-list response throws a coded `FETCH`,
2048
+ entries sort code-unit by `name` regardless of org-list key order, and no request anywhere
2049
+ (org list, packument, guide) ever carries an `Authorization` header.
2050
+ - [`tests/src/server/integration.test.ts`](../../tests/src/server/integration.test.ts) —
2051
+ the full flow against the fixture: `new` → `pull` → `audit --live` (compile → materialize →
2052
+ audit clean → mutate a file → audit drift → repair clean; then a stale mirror synced current);
2053
+ a scaffolded package whose deps are all vendored (contract / emitter / markdown / template /
2054
+ terminal / console) runs its own gates green by construction, while a dep outside that set
2055
+ leaves its mirror a pointer plus a non-blocking Question.
2056
+ - [`tests/src/bin/scaffold.test.ts`](../../tests/src/bin/scaffold.test.ts) — the bin's six
2057
+ subcommands: `new` (`parseArgs` flag decoding, a non-interactive `--json` compile emitting
2058
+ exactly one JSON value, dry-run review + summary table, the interactive preview-then-confirm
2059
+ flow driven by a scripted fake terminal — accept AND default-No decline — `--yes`
2060
+ pre-answering that confirm, `--apply` writing into a temp directory unasked, and the
2061
+ positional package name validated against the SAME shape the interactive prompt enforces
2062
+ (`^[a-z][a-z0-9-]*$`) — an invalid positional name exits `2` naming the expected shape, under
2063
+ `--json` and without alike (F4); the extras UX was removed from `new` entirely (`--extras` is
2064
+ now an unrecognized flag — `parseArgs`'s strict mode rejects it, exit `2`, nothing written);
2065
+ U12c FIX 3 (THE VERIFIER SMOKE, the closure regression): `new --apply` followed by a
2066
+ hand-added `package.json` `devDependencies` entry, then `audit --target <name>` exits `0`
2067
+ CLEAN — an external extra round-trips through `deriveBlueprint` instead of drifting on its
2068
+ own generated `package.json`; a dedicated offline case also resolves the package's own
2069
+ BUILT `dist/host` vendored `.claude/agents/orkestrel.md` through `hostRoot()` +
2070
+ `readHostManifest` + `locateHostSource`, proving `catalogNames` parses real `@orkestrel/*`
2071
+ rows off it — the exact primitive chain Q1's interactive catalog validation reads), `pull`
2072
+ (report + confirm-then-write, `--apply` writing unasked, `--json` emitting exactly one
2073
+ `SyncReport`, `--strict` still THROWING `FETCH` on a network fault, and any drift/failure
2074
+ exiting `1` regardless of `--strict` — `pull`'s prior only-nonzero-under-`--strict` posture
2075
+ is gone), `audit` (`deriveBlueprint` reconstruction, `--live` drift → exit `1`,
2076
+ hydration-aware host drift, the MERGED prune scan — a `pruneTargets`-found foreign file is a
2077
+ real finding that counts toward drift and exits `1`, its count carried under `--json`
2078
+ (`Audit.foreign`) alongside a `live` field when `--live` ran, `--groups a,b` scoping the
2079
+ compiled plan validated against `GROUPS` with exit `2` (a plain USAGE error, not a coded
2080
+ failure) on an unrecognized name, an EXPLICIT `--from` that fails to resolve exiting `TARGET`
2081
+ rather than silently downgrading — M1, an unscannable `--from` host that DOES resolve but
2082
+ cannot establish a vendored allowlist for a prune directory `target` actually has degrading
2083
+ the audit to its un-scanned findings with a printed `scanSkipped` note instead of crashing
2084
+ (F3), the repair-handoff confirm offered ONLY on a real TTY — `--apply` / `--yes` are NEVER
2085
+ handoff consent, so `audit --apply` (with or without `--prune`) on a drifted/foreign target
2086
+ is asserted to leave every file exactly as found and exit `1`, never auto-repairing or
2087
+ auto-pruning (F1) — and, when offered, ONLY when there is host/template-origin drift OR a
2088
+ foreign file present AND `--prune` was passed (a foreign-only handoff without `--prune` would
2089
+ be a dead end — F2); a foreign file with no `--prune` instead prints the `foreignHint` pointing
2090
+ at `scaffold repair --prune` (never a generated-file note, which stays reserved for
2091
+ computed-only drift), and — on an ACCEPTED handoff — a FULL-PLAN re-diff afterward that still
2092
+ exits `1` if any drift (e.g. generated-file drift `repair` cannot touch) remains), `repair`
2093
+ (`deriveBlueprint` reconstruction,
2094
+ dry-run exit code, confirm-then-write (or `--apply`) scoped to HOST-ORIGIN artifacts ONLY
2095
+ (H2) — INCLUDING `.github/workflows/ci.yml` (full HOST scope, no exclusion, unlike `fleet`)
2096
+ but a hand-modified `src` file is NEVER touched even when the target also carries drifted
2097
+ host files — a SECOND default-No confirm (or `--prune`) that LISTS the exact foreign paths
2098
+ before deleting `.claude/agents/` / `scripts/` foreigns ONLY, a `0`-count skipping the
2099
+ question entirely with a "no unexpected files to delete" note, `--prune` reaching the
2100
+ preview/confirm/deletion flow even on a CLEAN host audit — a clean audit alone no longer
2101
+ bypasses pruning, only a clean audit WITH nothing to prune does (U11 F2) — `--from` override defaulting
2102
+ to `hostRoot()`), a shared non-TTY CASE across every write verb asserting the ONE-PROMPT
2103
+ ceiling — a piped/non-TTY run never sees a second question (the prune confirm resolves to its
2104
+ safe default with a printed `pruneSkipped` explanation instead of blocking; `audit`'s
2105
+ repair-handoff is TTY-only from the start, so a non-TTY `audit` never asks it at all —
2106
+ degrading straight to the `foreignHint` / `generatedNote` prose instead), and a missing
2107
+ REQUIRED input off a TTY (e.g. `new` with no name/`--surfaces`) exits
2108
+ `2` naming the flag rather than hanging, and `fleet` (a FLEET
2109
+ fixture: multiple `discoverPackages`-discovered repos under the CURRENT WORKING DIRECTORY's
2110
+ immediate children — no `--root` flag anywhere, the cd-model IS the interface — `.github/
2111
+ workflows/ci.yml` EXCLUDED from the scoped plan regardless of confirm/`--apply`, per-repo +
2112
+ total drift table, `--json` emitting exactly one fleet-drift array, `--apply` writing
2113
+ unasked, exit `1` on residual drift) — all against the `node:http` fixture, PLUS a shared
2114
+ `ctrl-c`-at-prompt case asserting `cancelled — nothing written` and exit `1` — and `catalog`
2115
+ (`--offline` exercises the fully-local path against a fixture fleet under two `--from` roots,
2116
+ one guide carrying a blockquote and one missing its guide entirely: dry-run reports the
2117
+ no-description list and exits `1` on marker drift, `--apply` writes the spliced table and a
2118
+ re-run exits `0`, a target `.claude/agents/orkestrel.md` missing either marker exits coded
2119
+ `TARGET`, multiple `--from` values merge into one sorted, deduplicated table, `--json` emitting
2120
+ exactly `{ entries, drift, shrink? }` — NEVER a bare `CatalogEntry[]` — and a shrink
2121
+ warning prints on both dry-run and `--apply` when the new table has fewer rows than the
2122
+ currently-embedded one — the registry-authoritative default path's fetch/merge logic is
2123
+ covered at the `Sync.catalog()` unit level, `tests/src/server/Sync.test.ts`, against a
2124
+ fixture org-list + packument + guide host). A shared unknown-command case asserts `scaffold
2125
+ sync` / `scaffold mirror` print the explicit "renamed" redirect (`pull` / `fleet`
2126
+ respectively) rather than a fuzzy guess, any other typo gets the nearest-`KNOWN_VERBS`
2127
+ suggestion, and either way exits `2` with a single JSON error envelope (`{ error: { code,
2128
+ message } }`, `code: 'USAGE'`) under `--json` — the same envelope shape asserted for every
2129
+ other usage/unexpected failure across every verb, so `--json` NEVER emits a bare stderr line.
2130
+ - a BUILT-BIN end-to-end pack→install→scaffold-new proof — `npm pack` the built tarball,
2131
+ install it into a scratch project as a devDependency, then invoke the installed `scaffold
2132
+ new` bin with NO `--from` flag and assert the DEFAULT host resolution (`hostRoot()`) lands on
2133
+ the vendored `dist/host` bundle inside the installed package (not the source tree, not
2134
+ `process.cwd()`) — proving the published artifact is self-sufficient.
2135
+
2136
+ ## See also
2137
+
2138
+ - [`contract.md`](contract.md) — the guards, shapers, and `createContract` machinery the
2139
+ validators compile from, and `schemaToParameters` / `seededRandom` for the tool boundary.
2140
+ - [`emitter.md`](emitter.md) — the typed emitter behind the compiler's, manager's,
2141
+ materializer's, and sync's observation surfaces.
2142
+ - [`markdown.md`](markdown.md) — the AST + `renderMarkdown` writer `alignTable` builds the
2143
+ guide Surface tables on (`parseInline`, `TableNode`, `TableAlign`).
2144
+ - [`template.md`](template.md) — the `TemplateDefinition` + pure `fillTemplate` engine (`missing:
2145
+ 'error'`) that carries the rendered defaults.
2146
+ - [`terminal.md`](terminal.md) — the `createTerminal` `PromptFormInterface` the bin drives for
2147
+ interactive blueprint building (with a non-TTY readline fallback).
2148
+ - [`console.md`](console.md) — the `createReporter` / `createSpinner` + server `createServerSink`
2149
+ the bin narrates the plan, sync, and materialization through.
2150
+ - [`AGENTS.md`](../../AGENTS.md) — the rules; §4 naming, §9 managers, §11 determinism, §12
2151
+ errors, §13 emitters, §14 totality, §21 mechanism-never-policy, §22 documentation-as-contracts.
2152
+ - [`README.md`](../README.md) — the package index.