@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.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/dist/bin/scaffold.js +1539 -0
- package/dist/bin/scaffold.js.map +1 -0
- package/dist/host/AGENTS.md +939 -0
- package/dist/host/CLAUDE.md +495 -0
- package/dist/host/LICENSE +21 -0
- package/dist/host/claude/agents/builder.md +48 -0
- package/dist/host/claude/agents/checker.md +37 -0
- package/dist/host/claude/agents/composer.md +64 -0
- package/dist/host/claude/agents/grok.md +50 -0
- package/dist/host/claude/agents/orkestrel.md +236 -0
- package/dist/host/claude/agents/planner.md +44 -0
- package/dist/host/claude/agents/researcher.md +38 -0
- package/dist/host/claude/agents/reviewer.md +47 -0
- package/dist/host/claude/agents/scout.md +35 -0
- package/dist/host/claude/agents/verifier.md +34 -0
- package/dist/host/claude/settings.json +26 -0
- package/dist/host/dotfiles/editorconfig +17 -0
- package/dist/host/dotfiles/gitattributes +3 -0
- package/dist/host/dotfiles/gitignore +40 -0
- package/dist/host/dotfiles/oxfmtrc.json +18 -0
- package/dist/host/dotfiles/oxlintignore +20 -0
- package/dist/host/dotfiles/oxlintrc.json +58 -0
- package/dist/host/dotfiles/prettierignore +5 -0
- package/dist/host/github/workflows/ci.yml +64 -0
- package/dist/host/guides/src/guide.md +312 -0
- package/dist/host/guides/src/scaffold.md +2152 -0
- package/dist/host/manifest.json +137 -0
- package/dist/host/scripts/cursor.sh +74 -0
- package/dist/host/scripts/deps.sh +38 -0
- package/dist/host/scripts/ollama.sh +163 -0
- package/dist/src/core/index.cjs +3728 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +1941 -0
- package/dist/src/core/index.d.ts +1941 -0
- package/dist/src/core/index.js +3636 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/server/index.cjs +1595 -0
- package/dist/src/server/index.cjs.map +1 -0
- package/dist/src/server/index.d.cts +779 -0
- package/dist/src/server/index.d.ts +779 -0
- package/dist/src/server/index.js +1572 -0
- package/dist/src/server/index.js.map +1 -0
- 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.
|