@orkestrel/scaffold 0.0.67 → 0.0.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/bin/main.js +67 -44
  2. package/dist/bin/main.js.map +1 -1
  3. package/dist/host/agents/templates/brief.md +9 -0
  4. package/dist/host/claude/agents/orkestrel.md +4 -4
  5. package/dist/host/claude/rules/names.md +15 -0
  6. package/dist/host/claude/rules/tests.md +33 -4
  7. package/dist/host/claude/rules/workspace.md +14 -2
  8. package/dist/host/dotfiles/prettierignore +3 -0
  9. package/dist/host/guides/README.md +65 -0
  10. package/dist/host/guides/abort.md +169 -0
  11. package/dist/host/guides/agent.md +1509 -0
  12. package/dist/host/guides/brief.md +1266 -0
  13. package/dist/host/guides/browser.md +2200 -0
  14. package/dist/host/guides/budget.md +196 -0
  15. package/dist/host/guides/codec.md +519 -0
  16. package/dist/host/guides/console.md +785 -0
  17. package/dist/host/guides/contract.md +1193 -0
  18. package/dist/host/guides/csv.md +541 -0
  19. package/dist/host/guides/database.md +2518 -0
  20. package/dist/host/guides/emitter.md +233 -0
  21. package/dist/host/guides/form.md +1791 -0
  22. package/dist/host/guides/html.md +717 -0
  23. package/dist/host/guides/indexeddb.md +505 -0
  24. package/dist/host/guides/interpret.md +1029 -0
  25. package/dist/host/guides/lsp.md +515 -0
  26. package/dist/host/guides/markdown.md +964 -0
  27. package/dist/host/guides/mcp.md +5554 -0
  28. package/dist/host/guides/middleware.md +927 -0
  29. package/dist/host/guides/msg.md +440 -0
  30. package/dist/host/guides/ndjson.md +120 -0
  31. package/dist/host/guides/ollama.md +380 -0
  32. package/dist/host/guides/pool.md +280 -0
  33. package/dist/host/guides/probe.md +1210 -0
  34. package/dist/host/guides/process.md +1620 -0
  35. package/dist/host/guides/program.md +1110 -0
  36. package/dist/host/guides/qualifier.md +854 -0
  37. package/dist/host/guides/queue.md +370 -0
  38. package/dist/host/guides/rater.md +330 -0
  39. package/dist/host/guides/reason.md +1122 -0
  40. package/dist/host/guides/relation.md +373 -0
  41. package/dist/host/guides/router.md +753 -0
  42. package/dist/host/guides/scaffold.md +192 -31
  43. package/dist/host/guides/sea.md +383 -0
  44. package/dist/host/guides/server.md +752 -0
  45. package/dist/host/guides/sqlite.md +330 -0
  46. package/dist/host/guides/sse.md +187 -0
  47. package/dist/host/guides/supervisor.md +4890 -0
  48. package/dist/host/guides/table.md +1556 -0
  49. package/dist/host/guides/template.md +280 -0
  50. package/dist/host/guides/terminal.md +1145 -0
  51. package/dist/host/guides/test.md +2969 -0
  52. package/dist/host/guides/timeout.md +252 -0
  53. package/dist/host/guides/tool.md +311 -0
  54. package/dist/host/guides/toolbox.md +1038 -0
  55. package/dist/host/guides/websocket.md +282 -0
  56. package/dist/host/guides/worker.md +615 -0
  57. package/dist/host/guides/workflow.md +1507 -0
  58. package/dist/host/guides/workspace.md +595 -0
  59. package/dist/host/manifest.json +1218 -10
  60. package/dist/host/tests/policy.test.ts +279 -2
  61. package/dist/host/tests/setupPolicy.ts +437 -6
  62. package/dist/src/core/index.cjs +38 -16
  63. package/dist/src/core/index.cjs.map +1 -1
  64. package/dist/src/core/index.d.cts +33 -9
  65. package/dist/src/core/index.d.ts +33 -9
  66. package/dist/src/core/index.js +37 -17
  67. package/dist/src/core/index.js.map +1 -1
  68. package/dist/src/server/index.cjs +1750 -1567
  69. package/dist/src/server/index.cjs.map +1 -1
  70. package/dist/src/server/index.d.cts +106 -24
  71. package/dist/src/server/index.d.ts +106 -24
  72. package/dist/src/server/index.js +1751 -1570
  73. package/dist/src/server/index.js.map +1 -1
  74. package/package.json +3 -3
@@ -4,8 +4,10 @@ let _src_core = require("../core/index.cjs");
4
4
  let node_crypto = require("node:crypto");
5
5
  let node_fs = require("node:fs");
6
6
  let node_os = require("node:os");
7
+ let node_module = require("node:module");
7
8
  let node_path = require("node:path");
8
9
  let node_url = require("node:url");
10
+ let _orkestrel_markdown = require("@orkestrel/markdown");
9
11
  let _orkestrel_emitter = require("@orkestrel/emitter");
10
12
  //#region src/server/constants.ts
11
13
  /**
@@ -161,2022 +163,2162 @@ var UNREADABLE_VERSION_NOTE = "the answer carries no readable latest version";
161
163
  */
162
164
  var PACKUMENT_MEDIA_TYPE = "application/vnd.npm.install-v1+json";
163
165
  //#endregion
164
- //#region src/server/validators.ts
166
+ //#region src/server/helpers.ts
165
167
  /**
166
- * Narrows a value to a path naming a location on this host.
168
+ * Tests whether a caught filesystem error reports an absent path.
167
169
  *
168
- * @param value - The candidate host path.
169
- * @returns True if the value is a bounded absolute or relative path whose every segment
170
- * is portable across the supported filesystems; false otherwise.
170
+ * @param error - The caught value.
171
+ * @returns True if `error` is an `Error` whose `code` is exactly `ENOENT`; false otherwise.
171
172
  *
172
173
  * @remarks
173
- * The counterpart to the core path law, not a copy of it. A target directory and
174
- * the vendored host root are locations on the machine rather than paths inside a
175
- * workspace, so a drive prefix, a UNC share, and a backslash separator are all
176
- * admitted here and `..` is a legitimate way to name a sibling directory.
177
- * Containment is still enforced, but by the core law over the artifact paths
178
- * written beneath the target, not by this one.
179
- *
180
- * What it does refuse is a segment no supported filesystem can hold: an empty
181
- * one, a reserved Windows device name, a trailing dot or space, a wildcard or
182
- * redirection character, a colon anywhere but the drive prefix, and a name past
183
- * the byte ceiling. The character ceiling is read first so an oversized string is
184
- * refused before it is split.
185
- *
186
- * The spellings of an empty segment are answered differently. A trailing
187
- * separator terminates a directory rather than opening a segment, and every
188
- * supported filesystem and every Node path API reads `project/` and `project` as
189
- * one location, so it is admitted. A doubled separator is a genuine empty
190
- * segment, so `project//src` is refused. Nothing normalizes the argument first —
191
- * every server entry point guards the caller's text and resolves it afterwards —
192
- * so a directory taken from a shell completion arrives carrying the separator the
193
- * shell appended and names the directory it appears to name.
174
+ * The one place absence is told apart from failure. Every read here answers
175
+ * `undefined` or an empty result for a path that is not there and reports a path
176
+ * that is there but unreadable, so they must never be read from the same
177
+ * caught value by eye. Total for any caught value, including a hostile one.
194
178
  *
195
179
  * @example
196
180
  * ```ts
197
- * import { isFilesystemPath } from '@orkestrel/scaffold/server'
181
+ * import { matchesMissingPath } from '@orkestrel/scaffold/server'
198
182
  *
199
- * isFilesystemPath('C:/Users/sample/project') // true
200
- * isFilesystemPath('../sibling') // true
201
- * isFilesystemPath('project/') // true
202
- * isFilesystemPath('project//src') // false
203
- * isFilesystemPath('project/nul') // false
183
+ * matchesMissingPath(Object.assign(new Error('gone'), { code: 'ENOENT' })) // true
184
+ * matchesMissingPath(new Error('gone')) // false
204
185
  * ```
205
186
  */
206
- function isFilesystemPath(value) {
207
- return (0, _orkestrel_contract.holds)(() => {
208
- if (!(0, _orkestrel_contract.isString)(value) || value.length === 0 || value.length > _src_core.MAX_PATH_LENGTH) return false;
209
- if (_src_core.CONTROL_CHARACTER_PATTERN.test(value)) return false;
210
- const normalized = value.replaceAll("\\", "/");
211
- const rooted = normalized.startsWith("//") ? normalized.slice(2) : normalized.startsWith("/") ? normalized.slice(1) : normalized;
212
- const segments = (rooted.endsWith("/") ? rooted.slice(0, -1) : rooted).split("/");
213
- if (segments.length > 64) return false;
214
- for (const [index, segment] of segments.entries()) {
215
- if (segment === "." || segment === "..") continue;
216
- if (index === 0 && DRIVE_PATTERN.test(segment)) continue;
217
- if (segment.length === 0) return false;
218
- if (INVALID_SEGMENT_CHARACTER_PATTERN.test(segment)) return false;
219
- if (segment.endsWith(".") || segment.endsWith(" ")) return false;
220
- if ((0, _src_core.computeBytes)(segment) > 255) return false;
221
- if (RESERVED_SEGMENT_PATTERN.test(segment)) return false;
222
- }
223
- return true;
224
- });
187
+ function matchesMissingPath(error) {
188
+ return (0, _orkestrel_contract.holds)(() => (0, _orkestrel_contract.isError)(error) && Reflect.get(error, "code") === "ENOENT");
225
189
  }
226
190
  /**
227
- * Narrows a value to one exact SHA-256 digest.
191
+ * Tests whether a path addresses a target's own repository metadata.
192
+ *
193
+ * @param path - The path to classify; either separator is read.
194
+ * @returns True if the path is `.git` or sits beneath it; false otherwise.
228
195
  *
229
196
  * @remarks
230
- * The identity a vendored host manifest and a write precondition are both stated
231
- * in. Fixed at sixty-four lowercase digits, so the value either is a digest of
232
- * that algorithm or is refused; there is no shorter or longer accepted form.
197
+ * The one home of the `.git` membership rule, read in either direction. A target
198
+ * holding nothing but this directory is still vacant, because a checkout of an
199
+ * empty repository is where a fresh workspace legitimately starts. A path
200
+ * beneath it is never removed and never vendored, because deleting a target's
201
+ * history is not a repair.
233
202
  *
234
203
  * @example
235
204
  * ```ts
236
- * import { isDigest } from '@orkestrel/scaffold/server'
205
+ * import { matchesGitPath } from '@orkestrel/scaffold/server'
237
206
  *
238
- * isDigest('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') // true
239
- * isDigest('E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855') // false
207
+ * matchesGitPath('.git') // true
208
+ * matchesGitPath('.git/config') // true
209
+ * matchesGitPath('.gitignore') // false
240
210
  * ```
241
211
  */
242
- var isDigest = (0, _orkestrel_contract.stringOf)({ pattern: DIGEST_PATTERN });
212
+ function matchesGitPath(path) {
213
+ return /(?:^|\/)\.git(?:\/|$)/i.test(path.replaceAll("\\", "/"));
214
+ }
243
215
  /**
244
- * Narrows a value to a working-tree inventory within the limit one target may report.
216
+ * Tests whether a target-relative path is one no verb may delete.
245
217
  *
246
- * @param value - The candidate inventory.
247
- * @returns True if the value is an array of no more than
248
- * `MAX_INVENTORY_PATHS` items; false otherwise.
218
+ * @param path - The target-relative path to classify.
219
+ * @returns True if the path must survive every verb this package runs; false otherwise.
249
220
  *
250
221
  * @remarks
251
- * Compose this ahead of an element guard exactly as the core collection guard is
252
- * composed, and for the same reason: the item count is settled before anything
253
- * walks the items, and a hostile `length` accessor answers `false` rather than
254
- * escaping as a throw. It exists beside that guard rather than reusing it
255
- * because they bound different things — one bounds what a caller may hand a
256
- * public method, this one bounds what a checkout may contain.
222
+ * The deletion deny-list, stated as a rule over paths rather than as a list of
223
+ * directories. It is the inversion the contract asks for: the candidate set
224
+ * is re-derived from the plan and narrowed by what git tracks, and the audit
225
+ * must agree with that derivation rather than supply the set itself.
226
+ * Git metadata is protected because losing history is not a repair,
227
+ * and a target's own `src` and `app` trees are protected because a
228
+ * workspace's source is the one thing scaffold never plans and never owns. A
229
+ * plan the compiler emits never maps a protected root, so this guard exists
230
+ * for the caller-authored plan a consumer can still supply.
257
231
  *
258
232
  * @example
259
233
  * ```ts
260
- * import { isInventory } from '@orkestrel/scaffold/server'
234
+ * import { matchesProtectedPath } from '@orkestrel/scaffold/server'
261
235
  *
262
- * isInventory(['AGENTS.md']) // true
263
- * isInventory('AGENTS.md') // false
236
+ * matchesProtectedPath('src/core/index.ts') // true
237
+ * matchesProtectedPath('.git/config') // true
238
+ * matchesProtectedPath('.claude/agents/rogue.md') // false
264
239
  * ```
265
240
  */
266
- function isInventory(value) {
267
- return (0, _orkestrel_contract.holds)(() => (0, _orkestrel_contract.isArray)(value) && value.length <= 1e5);
241
+ function matchesProtectedPath(path) {
242
+ const normalized = path.replaceAll("\\", "/");
243
+ if (matchesGitPath(normalized)) return true;
244
+ if (normalized === "src" || normalized === "app") return true;
245
+ return normalized.startsWith("src/") || normalized.startsWith("app/");
268
246
  }
269
247
  /**
270
- * Narrows a value to a bounded upstream endpoint.
248
+ * Tests whether a path names local configuration or a credential.
271
249
  *
272
- * @remarks
273
- * Length only. Which schemes and hosts an endpoint may name is the reader's law,
274
- * because it builds the request and can report why one was refused, where a
275
- * guard has only `false` to say.
276
- */
277
- var isEndpoint = (0, _orkestrel_contract.stringOf)({
278
- min: 1,
279
- max: MAX_ENDPOINT_LENGTH
280
- });
281
- /**
282
- * Narrows a value to a Git branch the repository endpoint accepts.
250
+ * @param path - The path to classify; either separator is read.
251
+ * @returns True if the path must never be copied into a vendored host; false otherwise.
283
252
  *
284
253
  * @remarks
285
- * A branch reaches the repository URL's path, so the syntax is closed rather than
286
- * merely bounded and no `..` is admitted anywhere in it.
254
+ * The vendoring deny-list. A host root is staged from a real checkout, so the
255
+ * refusal is stated over the path rather than over the file's content: a
256
+ * credential is recognizable by where it sits and what it is called long before
257
+ * anything reads it. Git metadata is included through
258
+ * {@link matchesGitPath}, so one call answers the whole question and no caller
259
+ * has to remember to ask twice.
287
260
  *
288
261
  * @example
289
262
  * ```ts
290
- * import { isBranch } from '@orkestrel/scaffold/server'
263
+ * import { matchesSensitivePath } from '@orkestrel/scaffold/server'
291
264
  *
292
- * isBranch('main') // true
293
- * isBranch('main/../etc') // false
265
+ * matchesSensitivePath('.npmrc') // true
266
+ * matchesSensitivePath('.claude/settings.local.json') // true
267
+ * matchesSensitivePath('.claude/settings.json') // false
294
268
  * ```
295
269
  */
296
- var isBranch = (0, _orkestrel_contract.stringOf)({
297
- min: 1,
298
- max: 255,
299
- pattern: BRANCH_PATTERN
300
- });
270
+ function matchesSensitivePath(path) {
271
+ const normalized = path.replaceAll("\\", "/");
272
+ if (matchesGitPath(normalized)) return true;
273
+ return /(?:^|\/)(?:(?:\.ssh|\.aws|\.azure|\.docker|\.kube|\.gnupg|\.env(?:\.[^/]*)?)(?:\/|$)|(?:\.npmrc|\.pypirc|\.netrc|\.git-credentials|settings\.local\.json|auth\.json|credentials(?:\.json)?|application_default_credentials\.json|id_rsa|id_ed25519|kubeconfig)$|\.config\/(?:gh|gcloud)(?:\/|$)|\.local\/share\/keyrings(?:\/|$)|[^/]*service-account[^/]*\.json$|[^/]*\.(?:jks|key|p12|pem|pfx|pkcs12)$)/i.test(normalized);
274
+ }
301
275
  /**
302
- * Narrows a value to a per-request timeout in milliseconds.
276
+ * Tests whether a vendored path is one a target receives executable.
303
277
  *
304
- * @remarks
305
- * A whole number of milliseconds, at least one and no more than
306
- * {@link MAX_UPSTREAM_TIMEOUT}. Zero is refused because a request that may not
307
- * take any time is a request that cannot succeed.
308
- */
309
- var isTimeout = (0, _orkestrel_contract.andOf)(_orkestrel_contract.isInteger, (0, _orkestrel_contract.boundsOf)(1, MAX_UPSTREAM_TIMEOUT));
310
- /**
311
- * Narrows a value to a bounded list of `@orkestrel` package names.
278
+ * @param path - The target-relative path to classify; either separator is read.
279
+ * @returns True if the path is declared in {@link EXECUTABLE_PATHS}; false otherwise.
312
280
  *
313
281
  * @remarks
314
- * Composed from the core collection and dependency-name guards rather than
315
- * restated, so the scope law that keeps a derived guide mirror inside its
316
- * directory has exactly one home.
282
+ * The declaration is the whole answer, and deliberately so. Reading the staging
283
+ * host's mode instead makes the manifest depend on where the package was built:
284
+ * Windows carries no executable bit, so a host staged there declares every entry
285
+ * non-executable and every target it later fills receives hooks at `0644`. One
286
+ * checkout stages one manifest on every host because this predicate never
287
+ * consults the filesystem.
317
288
  *
318
289
  * @example
319
290
  * ```ts
320
- * import { isDependencyNames } from '@orkestrel/scaffold/server'
291
+ * import { matchesExecutablePath } from '@orkestrel/scaffold/server'
321
292
  *
322
- * isDependencyNames(['@orkestrel/router']) // true
323
- * isDependencyNames(['router']) // false
293
+ * matchesExecutablePath('scripts/codex.sh') // true
294
+ * matchesExecutablePath('scripts\\deps.sh') // true
295
+ * matchesExecutablePath('AGENTS.md') // false
324
296
  * ```
325
297
  */
326
- var isDependencyNames = (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isDependencyName));
298
+ function matchesExecutablePath(path) {
299
+ return _src_core.EXECUTABLE_PATHS.includes(path.replaceAll("\\", "/"));
300
+ }
327
301
  /**
328
- * Narrows a value to a bounded list of target-relative paths.
302
+ * Projects a target-relative path to the storage name a vendored host holds it under.
303
+ *
304
+ * @param path - The target-relative path the file is written to.
305
+ * @returns The storage name beneath the host root.
329
306
  *
330
307
  * @remarks
331
- * Composed from the core collection and path guards rather than restated, so
332
- * the containment law that keeps a caller-supplied path inside its target has
333
- * exactly one home. It bounds what a caller may hand a public method, which is
334
- * why it is not {@link isInventory}: that one bounds what a checkout may hold.
308
+ * A staged host is a plain directory that npm packs, and npm's own ignore rules
309
+ * would drop a leading-dot entry from the tarball. So every dot that opens a
310
+ * segment comes off, and a dotted file at the root moves under `dotfiles/` to
311
+ * keep it from colliding with an undotted sibling of the same name. The mapping
312
+ * is one direction only: a staged host's manifest records the destination each
313
+ * storage name answers for, so the reader never re-derives this.
335
314
  *
336
315
  * @example
337
316
  * ```ts
338
- * import { isPaths } from '@orkestrel/scaffold/server'
317
+ * import { pathToStorage } from '@orkestrel/scaffold/server'
339
318
  *
340
- * isPaths(['AGENTS.md']) // true
341
- * isPaths(['../secrets']) // false
319
+ * pathToStorage('.gitignore') // 'dotfiles/gitignore'
320
+ * pathToStorage('.claude/rules/names.md') // 'claude/rules/names.md'
321
+ * pathToStorage('AGENTS.md') // 'AGENTS.md'
342
322
  * ```
343
323
  */
344
- var isPaths = (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isPath));
345
- /** Narrows a value to a bounded list of declared runtime dependencies. */
346
- var isDependencies = (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isDependency));
324
+ function pathToStorage(path) {
325
+ const segments = path.split("/");
326
+ if (segments.length === 1) return path.startsWith(".") ? `dotfiles/${path.slice(1)}` : path;
327
+ return segments.map((segment) => segment.startsWith(".") ? segment.slice(1) : segment).join("/");
328
+ }
347
329
  /**
348
- * Narrows a value to one {@link ManifestRegionSet}.
330
+ * Computes the SHA-256 digest of text.
331
+ *
332
+ * @param content - The text to digest.
333
+ * @returns Sixty-four lowercase hexadecimal digits.
349
334
  *
350
335
  * @remarks
351
- * The whole closed record a manifest-writing method accepts, so a caller
352
- * naming a region the writer does not carry is refused before any byte moves.
353
- * Each region is bounded by the same collection law its own list guard applies.
336
+ * The identity the server face states, and the reason it differs from core's:
337
+ * core settled on a folded 64-bit identity because compilation is synchronous by
338
+ * contract and the only cryptographic digest a host-independent scope reaches is
339
+ * asynchronous. A Node host reaches the real one synchronously, and
340
+ * `HostManifest.digest` is documented as SHA-256, so this is what the server
341
+ * uses everywhere a digest is claimed.
354
342
  *
355
343
  * @example
356
344
  * ```ts
357
- * import { isManifestRegionSet } from '@orkestrel/scaffold/server'
345
+ * import { computeDigest } from '@orkestrel/scaffold/server'
358
346
  *
359
- * isManifestRegionSet({ pins: { runtime: [], development: [] }, scripts: [] }) // true
360
- * isManifestRegionSet({ pins: { runtime: [], development: [] } }) // false
347
+ * computeDigest('hi\n') // '98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4'
361
348
  * ```
362
349
  */
363
- var isManifestRegionSet = (0, _orkestrel_contract.recordOf)({
364
- pins: (0, _orkestrel_contract.recordOf)({
365
- runtime: isDependencies,
366
- development: isDependencies
367
- }),
368
- scripts: (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isManifestScript))
369
- });
370
- /** Narrows a value to a bounded list of fetched guide mirrors. */
371
- var isMirrors = (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isMirror));
372
- /** Narrows a value to a bounded list of fleet catalog rows. */
373
- var isCatalogEntries = (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isCatalogEntry));
350
+ function computeDigest(content) {
351
+ return (0, node_crypto.createHash)("sha256").update(content, "utf8").digest("hex");
352
+ }
374
353
  /**
375
- * Narrows a value to one {@link ManifestEntry}.
354
+ * Projects exact bytes stated in hexadecimal to their SHA-256 digest.
376
355
  *
377
- * @remarks
378
- * Both paths are measured by the core path law, because a vendored host's
379
- * storage name and the destination it maps to are each a path inside a
380
- * workspace. That is what stops a hand-edited manifest from mapping a vendored
381
- * file to a destination outside the target.
356
+ * @param hex - The exact lowercase hexadecimal bytes to digest.
357
+ * @returns Sixty-four lowercase hexadecimal digits.
358
+ * @throws `ScaffoldError('INVALID', …)` when `hex` is not exact bounded
359
+ * lowercase hexadecimal text.
382
360
  *
383
361
  * @example
384
362
  * ```ts
385
- * import { isManifestEntry } from '@orkestrel/scaffold/server'
363
+ * import { hexToDigest } from '@orkestrel/scaffold/server'
386
364
  *
387
- * isManifestEntry({
388
- * storage: 'AGENTS.md',
389
- * destination: 'AGENTS.md',
390
- * executable: false,
391
- * digest: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
392
- * }) // true
365
+ * hexToDigest('68690a') // '98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4'
393
366
  * ```
394
367
  */
395
- var isManifestEntry = (0, _orkestrel_contract.recordOf)({
396
- storage: _src_core.isPath,
397
- destination: _src_core.isPath,
398
- executable: _orkestrel_contract.isBoolean,
399
- digest: isDigest
400
- });
401
- /**
402
- * Narrows a value to one {@link HostManifest}.
403
- *
404
- * @remarks
405
- * The manifest is read from a directory a caller named, so it is the least
406
- * trusted value the server face handles and is guarded whole: every entry, every
407
- * declared root, and the digest that authenticates their membership.
408
- */
409
- var isHostManifest = (0, _orkestrel_contract.recordOf)({
410
- entries: (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(isManifestEntry)),
411
- roots: (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isPath)),
412
- digest: isDigest
413
- });
368
+ function hexToDigest(hex) {
369
+ if (!(0, _src_core.isHex)(hex)) throw new _src_core.ScaffoldError("INVALID", "Digest input is not exact hexadecimal bytes", { hex });
370
+ return (0, node_crypto.createHash)("sha256").update(Buffer.from(hex, "hex")).digest("hex");
371
+ }
414
372
  /**
415
- * Narrows a value to one {@link Host}.
416
- *
417
- * @remarks
418
- * A whole vendored host handed in as a value is as untrusted as one read from a
419
- * directory a caller named, so both halves are guarded: the manifest by the same
420
- * membership law a read root is held to, and the bytes by the core snapshot law,
421
- * which bounds the fill and reads every key as a path and every value as exact
422
- * lowercase hexadecimal. Whether those halves agree with each other is the
423
- * reader's question rather than this one's, because a guard has only `false` to
424
- * say and a mismatch has a path to name.
425
- *
426
- * @example
427
- * ```ts
428
- * import { isHost } from '@orkestrel/scaffold/server'
429
- *
430
- * const digest = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
373
+ * Computes the digest of a vendored host's declared membership.
431
374
  *
432
- * isHost({ manifest: { entries: [], roots: [], digest }, bytes: {} }) // true
433
- * isHost({ manifest: { entries: [], roots: [], digest } }) // false
434
- * ```
435
- */
436
- var isHost = (0, _orkestrel_contract.recordOf)({
437
- manifest: isHostManifest,
438
- bytes: _src_core.isSnapshot
439
- });
440
- /**
441
- * Narrows a value to a {@link Worktree}.
375
+ * @param entries - The ordered file membership declarations.
376
+ * @param roots - The ordered directory membership declarations.
377
+ * @param surface - The ordered Surface collisions and their ordered guide owners.
378
+ * @returns The SHA-256 of that exact membership, in that exact order.
442
379
  *
443
380
  * @remarks
444
- * Both path lists are target-relative, so both are measured by the core path
445
- * law: a reported path that is not one this package could have planned is not a
446
- * path it will delete. The inventory guard bounds the lists, because a checkout
447
- * is legitimately far larger than any collection a caller hands a method.
381
+ * Independent of the manifest's own `digest` field, which is what lets a reader
382
+ * detect a membership edit that did not update it. Order is part of the claim
383
+ * rather than normalized away, because a staged manifest sorts its entries and
384
+ * roots once and a reordered copy is a different file. Each entry is projected
385
+ * to exactly the declared fields, so a hand-added property cannot ride
386
+ * into the digest and cannot change it either.
448
387
  *
449
388
  * @example
450
389
  * ```ts
451
- * import { isWorktree } from '@orkestrel/scaffold/server'
390
+ * import { computeManifestDigest } from '@orkestrel/scaffold/server'
452
391
  *
453
- * isWorktree({ tracked: ['AGENTS.md'], dirty: [] }) // true
454
- * isWorktree({ tracked: ['../secrets'], dirty: [] }) // false
392
+ * computeManifestDigest([], [], []) // the digest of the empty membership
455
393
  * ```
456
394
  */
457
- var isWorktree = (0, _orkestrel_contract.recordOf)({
458
- tracked: (0, _orkestrel_contract.andOf)(isInventory, (0, _orkestrel_contract.arrayOf)(_src_core.isPath)),
459
- dirty: (0, _orkestrel_contract.andOf)(isInventory, (0, _orkestrel_contract.arrayOf)(_src_core.isPath))
460
- });
395
+ function computeManifestDigest(entries, roots, surface) {
396
+ return computeDigest(JSON.stringify({
397
+ entries: entries.map((entry) => ({
398
+ storage: entry.storage,
399
+ destination: entry.destination,
400
+ executable: entry.executable,
401
+ digest: entry.digest
402
+ })),
403
+ roots: [...roots],
404
+ surface: surface.map(({ name, owners }) => ({
405
+ name,
406
+ owners: [...owners]
407
+ }))
408
+ }));
409
+ }
461
410
  /**
462
- * Narrows a value to the materializer's initial listener record.
411
+ * Tests whether a path is a physical file this package will read or replace.
463
412
  *
464
- * @remarks
465
- * Every event is optional and every declared value is a function. A key outside
466
- * the materializer's event map is refused, so a listener wired to a misspelled
467
- * event fails at construction instead of never firing.
468
- */
469
- var isMaterializerHooks = (0, _orkestrel_contract.recordOf)({
470
- write: _orkestrel_contract.isFunction,
471
- remove: _orkestrel_contract.isFunction,
472
- finish: _orkestrel_contract.isFunction,
473
- error: _orkestrel_contract.isFunction,
474
- destroy: _orkestrel_contract.isFunction
475
- }, true);
476
- /**
477
- * Narrows a value to {@link MaterializerOptions}.
413
+ * @param path - The resolved host path to inspect, without following links.
414
+ * @returns True if the path is a regular file that is neither a link nor hard-linked
415
+ * elsewhere; false otherwise.
478
416
  *
479
417
  * @remarks
480
- * `host` admits both representations of one vendored root: a directory path and
481
- * a whole {@link Host} value. They share a key because they are one setting
482
- * stated two ways rather than two settings, so nothing downstream has to
483
- * reconcile a pair that could disagree.
418
+ * The link tests are the point. A symbolic link is a path pointing somewhere
419
+ * else, so writing through one writes outside the target; a hard link means a
420
+ * second name shares the same bytes, so replacing them changes a file nobody
421
+ * asked about. Both are refused rather than followed.
484
422
  *
485
423
  * @example
486
424
  * ```ts
487
- * import { isMaterializerOptions } from '@orkestrel/scaffold/server'
425
+ * import { isPhysicalFile } from '@orkestrel/scaffold/server'
488
426
  *
489
- * isMaterializerOptions({}) // true
490
- * isMaterializerOptions({ host: 'dist/host*' }) // false
427
+ * isPhysicalFile('/tmp/project/AGENTS.md') // true for a plain file
491
428
  * ```
492
429
  */
493
- var isMaterializerOptions = (0, _orkestrel_contract.recordOf)({
494
- host: (0, _orkestrel_contract.unionOf)(isFilesystemPath, isHost),
495
- on: isMaterializerHooks,
496
- error: _orkestrel_contract.isFunction
497
- }, true);
430
+ function isPhysicalFile(path) {
431
+ const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(path));
432
+ return status.success && status.value.isFile() && !status.value.isSymbolicLink() && status.value.nlink === 1;
433
+ }
498
434
  /**
499
- * Narrows a value to the upstream reader's initial listener record.
435
+ * Tests whether a path is a physical file with exact on-disk casing.
500
436
  *
501
- * @remarks
502
- * Closed to the reader's own events for the same reason the materializer's
503
- * record is closed to its own.
504
- */
505
- var isUpstreamHooks = (0, _orkestrel_contract.recordOf)({
506
- release: _orkestrel_contract.isFunction,
507
- mirror: _orkestrel_contract.isFunction,
508
- file: _orkestrel_contract.isFunction,
509
- error: _orkestrel_contract.isFunction,
510
- destroy: _orkestrel_contract.isFunction
511
- }, true);
512
- /**
513
- * Narrows a value to {@link UpstreamOptions}.
437
+ * @param path - The host path to inspect segment by segment.
438
+ * @returns True if the path is a physical file whose requested segments exactly match
439
+ * the names each parent directory stores; false otherwise.
514
440
  *
515
441
  * @remarks
516
- * Each grouped endpoint is closed to its own leaves, so a setting written under
517
- * the wrong entity is refused rather than ignored. Every numeric leaf is a whole
518
- * number inside a ceiling: an unbounded concurrency, retry count, response
519
- * limit, or call budget is a way to exhaust the caller, so the ceiling is stated
520
- * here rather than left to the reader. The byte ceilings are the core
521
- * artifact and total-artifact limits, because a fetched guide is an artifact and
522
- * a whole call retains no more than a whole plan.
442
+ * A direct file lookup follows the host's case-folding rules on Windows and
443
+ * common macOS filesystems. Reading each parent directory supplies the stored
444
+ * names, so this predicate can enforce the package's exact-case structural
445
+ * contract on every supported host.
523
446
  *
524
447
  * @example
525
448
  * ```ts
526
- * import { isUpstreamOptions } from '@orkestrel/scaffold/server'
449
+ * import { isExactCaseFile } from '@orkestrel/scaffold/server'
527
450
  *
528
- * isUpstreamOptions({ repository: { branch: 'main' }, concurrency: 4 }) // true
529
- * isUpstreamOptions({ concurrency: 0 }) // false
451
+ * isExactCaseFile('/tmp/project/src/bin/main.ts') // true only for that exact spelling
530
452
  * ```
531
453
  */
532
- var isUpstreamOptions = (0, _orkestrel_contract.recordOf)({
533
- repository: (0, _orkestrel_contract.recordOf)({
534
- base: isEndpoint,
535
- branch: isBranch,
536
- timeout: isTimeout
537
- }, true),
538
- registry: (0, _orkestrel_contract.recordOf)({
539
- base: isEndpoint,
540
- timeout: isTimeout
541
- }, true),
542
- concurrency: (0, _orkestrel_contract.andOf)(_orkestrel_contract.isInteger, (0, _orkestrel_contract.boundsOf)(1, 64)),
543
- retries: (0, _orkestrel_contract.andOf)(_orkestrel_contract.isInteger, (0, _orkestrel_contract.boundsOf)(0, 5)),
544
- limit: (0, _orkestrel_contract.andOf)(_orkestrel_contract.isInteger, (0, _orkestrel_contract.boundsOf)(1, _src_core.MAX_ARTIFACT_BYTES)),
545
- budget: (0, _orkestrel_contract.andOf)(_orkestrel_contract.isInteger, (0, _orkestrel_contract.boundsOf)(1, _src_core.MAX_TOTAL_ARTIFACT_BYTES)),
546
- on: isUpstreamHooks,
547
- error: _orkestrel_contract.isFunction
548
- }, true);
549
- //#endregion
550
- //#region src/server/helpers.ts
454
+ function isExactCaseFile(path) {
455
+ const full = (0, node_path.resolve)(path);
456
+ if (!isPhysicalFile(full)) return false;
457
+ const root = (0, node_path.parse)(full).root;
458
+ const segments = (0, node_path.relative)(root, full).split(node_path.sep);
459
+ let parent = root;
460
+ for (const segment of segments) {
461
+ const entries = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readdirSync)(parent));
462
+ if (entries.success) {
463
+ if (!entries.value.includes(segment)) return false;
464
+ } else {
465
+ const actual = (0, _orkestrel_contract.attempt)(() => node_fs.realpathSync.native((0, node_path.join)(parent, segment)));
466
+ if (!actual.success || (0, node_path.basename)(actual.value) !== segment) return false;
467
+ }
468
+ parent = (0, node_path.join)(parent, segment);
469
+ }
470
+ return true;
471
+ }
551
472
  /**
552
- * Tests whether a caught filesystem error reports an absent path.
473
+ * Tests whether a path is a physical directory this package will read or write into.
553
474
  *
554
- * @param error - The caught value.
555
- * @returns True if `error` is an `Error` whose `code` is exactly `ENOENT`; false otherwise.
475
+ * @param path - The resolved host path to inspect, without following links.
476
+ * @returns True if the path is a directory that is not a link; false otherwise.
556
477
  *
557
478
  * @remarks
558
- * The one place absence is told apart from failure. Every read here answers
559
- * `undefined` or an empty result for a path that is not there and reports a path
560
- * that is there but unreadable, so they must never be read from the same
561
- * caught value by eye. Total for any caught value, including a hostile one.
479
+ * A junction and a directory symbolic link both report as directories after
480
+ * they are followed, so the inspection deliberately does not follow: a redirected
481
+ * directory is refused here rather than silently accepted as the one the caller
482
+ * named.
562
483
  *
563
484
  * @example
564
485
  * ```ts
565
- * import { matchesMissingPath } from '@orkestrel/scaffold/server'
486
+ * import { isPhysicalDirectory } from '@orkestrel/scaffold/server'
566
487
  *
567
- * matchesMissingPath(Object.assign(new Error('gone'), { code: 'ENOENT' })) // true
568
- * matchesMissingPath(new Error('gone')) // false
488
+ * isPhysicalDirectory('/tmp/project') // true for a plain directory
569
489
  * ```
570
490
  */
571
- function matchesMissingPath(error) {
572
- return (0, _orkestrel_contract.holds)(() => (0, _orkestrel_contract.isError)(error) && Reflect.get(error, "code") === "ENOENT");
491
+ function isPhysicalDirectory(path) {
492
+ const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(path));
493
+ return status.success && status.value.isDirectory() && !status.value.isSymbolicLink();
573
494
  }
574
495
  /**
575
- * Tests whether a path addresses a target's own repository metadata.
496
+ * Computes the SHA-256 digest of one file's exact bytes.
576
497
  *
577
- * @param path - The path to classify; either separator is read.
578
- * @returns True if the path is `.git` or sits beneath it; false otherwise.
498
+ * @param path - The resolved host path to digest.
499
+ * @returns The digest, or `undefined` when the path is not a physical file, is
500
+ * past the artifact ceiling, or moved while it was being read.
579
501
  *
580
502
  * @remarks
581
- * The one home of the `.git` membership rule, read in either direction. A target
582
- * holding nothing but this directory is still vacant, because a checkout of an
583
- * empty repository is where a fresh workspace legitimately starts. A path
584
- * beneath it is never removed and never vendored, because deleting a target's
585
- * history is not a repair.
503
+ * Read in bounded chunks rather than loaded whole, so digesting a large file
504
+ * costs one buffer instead of its size. The file's identity and size are
505
+ * measured before and after the read and a mismatch answers `undefined`, so a
506
+ * digest is either of one settled file or is not produced at all.
586
507
  *
587
508
  * @example
588
509
  * ```ts
589
- * import { matchesGitPath } from '@orkestrel/scaffold/server'
510
+ * import { computeFileDigest } from '@orkestrel/scaffold/server'
590
511
  *
591
- * matchesGitPath('.git') // true
592
- * matchesGitPath('.git/config') // true
593
- * matchesGitPath('.gitignore') // false
512
+ * computeFileDigest('/tmp/project/AGENTS.md') // the file's SHA-256
513
+ * computeFileDigest('/tmp/project/absent.md') // undefined
594
514
  * ```
595
515
  */
596
- function matchesGitPath(path) {
597
- return /(?:^|\/)\.git(?:\/|$)/i.test(path.replaceAll("\\", "/"));
516
+ function computeFileDigest(path) {
517
+ if (!isPhysicalFile(path)) return void 0;
518
+ const opened = (0, _orkestrel_contract.attempt)(() => (0, node_fs.openSync)(path, "r"));
519
+ if (!opened.success) return void 0;
520
+ const handle = opened.value;
521
+ const read = (0, _orkestrel_contract.attempt)(() => {
522
+ const before = (0, node_fs.fstatSync)(handle);
523
+ if (!before.isFile() || before.nlink !== 1 || before.size > _src_core.MAX_ARTIFACT_BYTES) return void 0;
524
+ const hash = (0, node_crypto.createHash)("sha256");
525
+ const buffer = Buffer.alloc(65536);
526
+ let size = 0;
527
+ for (;;) {
528
+ const length = (0, node_fs.readSync)(handle, buffer, 0, buffer.byteLength, null);
529
+ if (length === 0) break;
530
+ size += length;
531
+ if (size > _src_core.MAX_ARTIFACT_BYTES) return void 0;
532
+ hash.update(buffer.subarray(0, length));
533
+ }
534
+ const after = (0, node_fs.fstatSync)(handle);
535
+ if (size !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs) return;
536
+ return hash.digest("hex");
537
+ });
538
+ (0, _orkestrel_contract.attempt)(() => (0, node_fs.closeSync)(handle));
539
+ return read.success ? read.value : void 0;
598
540
  }
599
541
  /**
600
- * Tests whether a target-relative path is one no verb may delete.
542
+ * Resolves a path through the real filesystem, keeping the part that does not exist yet.
601
543
  *
602
- * @param path - The target-relative path to classify.
603
- * @returns True if the path must survive every verb this package runs; false otherwise.
544
+ * @param path - The absolute or relative host path to resolve.
545
+ * @returns The lexical resolution of `path`, with its existing prefix then
546
+ * resolved through every link, or `undefined` when the text is not a host path,
547
+ * no bounded existing ancestor resolves, a link target cannot be read, a link
548
+ * target carries a `..` segment, or an ancestor cannot be read.
604
549
  *
605
550
  * @remarks
606
- * The deletion deny-list, stated as a rule over paths rather than as a list of
607
- * directories. It is the inversion the contract asks for: the candidate set
608
- * is re-derived from the plan and narrowed by what git tracks, and the audit
609
- * must agree with that derivation rather than supply the set itself.
610
- * Git metadata is protected because losing history is not a repair,
611
- * and a target's own `src` and `app` trees are protected because a
612
- * workspace's source is the one thing scaffold never plans and never owns. A
613
- * plan the compiler emits never maps a protected root, so this guard exists
614
- * for the caller-authored plan a consumer can still supply.
615
- *
616
- * @example
617
- * ```ts
618
- * import { matchesProtectedPath } from '@orkestrel/scaffold/server'
551
+ * A containment decision has to be made about a destination that does not exist
552
+ * yet, and a lexical answer is not enough: a link anywhere in the existing
553
+ * prefix moves the destination somewhere the text never named. So the deepest
554
+ * existing ancestor is resolved and the remaining segments are re-joined onto
555
+ * it. The climb is bounded by the path-depth ceiling, so an adversarial path
556
+ * cannot make it walk indefinitely.
619
557
  *
620
- * matchesProtectedPath('src/core/index.ts') // true
621
- * matchesProtectedPath('.git/config') // true
622
- * matchesProtectedPath('.claude/agents/rogue.md') // false
623
- * ```
624
- */
625
- function matchesProtectedPath(path) {
626
- const normalized = path.replaceAll("\\", "/");
627
- if (matchesGitPath(normalized)) return true;
628
- if (normalized === "src" || normalized === "app") return true;
629
- return normalized.startsWith("src/") || normalized.startsWith("app/");
630
- }
631
- /**
632
- * Tests whether a path names local configuration or a credential.
558
+ * The caller's own text is collapsed first, which is what `resolve` does with a
559
+ * `..` the caller wrote: it cancels the segment before it as text, before any
560
+ * link in that segment is read. So `<root>/hop/..` answers `<root>` even where
561
+ * `hop` links elsewhere, rather than the directory holding what `hop` points at.
562
+ * The collapse only ever shortens the caller's path, so nothing reaches outside
563
+ * it by this; the answer is that lexical location resolved through links, not
564
+ * the physical location the links lead to. {@link resolveContainedPath} passes
565
+ * its `root` through here, so a root written with a parent segment is contained
566
+ * against its collapsed spelling.
633
567
  *
634
- * @param path - The path to classify; either separator is read.
635
- * @returns True if the path must never be copied into a vendored host; false otherwise.
568
+ * `realpath` answers `ENOENT` both for a name that is not there and for a link
569
+ * whose target is not there. The name is therefore inspected without following
570
+ * it: a dangling link redirects the walk to its target, while a genuinely absent
571
+ * name is retained as one segment of the unresolved suffix. A dangling link
572
+ * target containing a `..` segment is refused. Resolving that target as one
573
+ * lexical string could discard a preceding link before the filesystem gives
574
+ * `..` its physical meaning.
636
575
  *
637
- * @remarks
638
- * The vendoring deny-list. A host root is staged from a real checkout, so the
639
- * refusal is stated over the path rather than over the file's content: a
640
- * credential is recognizable by where it sits and what it is called long before
641
- * anything reads it. Git metadata is included through
642
- * {@link matchesGitPath}, so one call answers the whole question and no caller
643
- * has to remember to ask twice.
576
+ * That target is split on both separators on every host, which is the reading
577
+ * `isPath` already gives a planned path. A POSIX filename legally containing a
578
+ * backslash is therefore refused with it: `weird\..\name` is one name to the
579
+ * host and three segments here. The package keeps one separator law rather than
580
+ * a host-dependent second one, and this is the conservative side of it.
644
581
  *
645
582
  * @example
646
583
  * ```ts
647
- * import { matchesSensitivePath } from '@orkestrel/scaffold/server'
584
+ * import { resolveRealPath } from '@orkestrel/scaffold/server'
648
585
  *
649
- * matchesSensitivePath('.npmrc') // true
650
- * matchesSensitivePath('.claude/settings.local.json') // true
651
- * matchesSensitivePath('.claude/settings.json') // false
586
+ * resolveRealPath('./packages/new/src') // the real path of `packages`, plus `new/src`
652
587
  * ```
653
588
  */
654
- function matchesSensitivePath(path) {
655
- const normalized = path.replaceAll("\\", "/");
656
- if (matchesGitPath(normalized)) return true;
657
- return /(?:^|\/)(?:(?:\.ssh|\.aws|\.azure|\.docker|\.kube|\.gnupg|\.env(?:\.[^/]*)?)(?:\/|$)|(?:\.npmrc|\.pypirc|\.netrc|\.git-credentials|settings\.local\.json|auth\.json|credentials(?:\.json)?|application_default_credentials\.json|id_rsa|id_ed25519|kubeconfig)$|\.config\/(?:gh|gcloud)(?:\/|$)|\.local\/share\/keyrings(?:\/|$)|[^/]*service-account[^/]*\.json$|[^/]*\.(?:jks|key|p12|pem|pfx|pkcs12)$)/i.test(normalized);
589
+ function resolveRealPath(path) {
590
+ if (!isFilesystemPath(path)) return void 0;
591
+ let current = (0, node_path.resolve)(path);
592
+ const pending = [];
593
+ for (let depth = 0; depth <= 64; depth += 1) {
594
+ const real = (0, _orkestrel_contract.attempt)(() => (0, node_fs.realpathSync)(current));
595
+ if (real.success) {
596
+ let physical = real.value;
597
+ for (const segment of pending) physical = (0, node_path.join)(physical, segment);
598
+ return physical;
599
+ }
600
+ if (!matchesMissingPath(real.error)) return void 0;
601
+ const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(current));
602
+ if (status.success) {
603
+ if (!status.value.isSymbolicLink()) return void 0;
604
+ const target = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readlinkSync)(current));
605
+ if (!target.success) return void 0;
606
+ if (target.value.split(/[\\/]/u).includes("..")) return void 0;
607
+ current = (0, node_path.resolve)((0, node_path.dirname)(current), target.value);
608
+ continue;
609
+ }
610
+ if (!matchesMissingPath(status.error)) return void 0;
611
+ const parent = (0, node_path.dirname)(current);
612
+ if (parent === current) return void 0;
613
+ pending.unshift((0, node_path.relative)(parent, current));
614
+ current = parent;
615
+ }
658
616
  }
659
617
  /**
660
- * Tests whether a vendored path is one a target receives executable.
618
+ * Resolves a root-relative path and refuses one that leaves its root.
661
619
  *
662
- * @param path - The target-relative path to classify; either separator is read.
663
- * @returns True if the path is declared in {@link EXECUTABLE_PATHS}; false otherwise.
620
+ * @param root - The containing host directory.
621
+ * @param path - The portable root-relative path.
622
+ * @returns The destination as this package will address it, or `undefined` when
623
+ * either argument is off contract or the destination lies outside `root`.
664
624
  *
665
625
  * @remarks
666
- * The declaration is the whole answer, and deliberately so. Reading the staging
667
- * host's mode instead makes the manifest depend on where the package was built:
668
- * Windows carries no executable bit, so a host staged there declares every entry
669
- * non-executable and every target it later fills receives hooks at `0644`. One
670
- * checkout stages one manifest on every host because this predicate never
671
- * consults the filesystem.
626
+ * The containment law, and the one door every read in this module goes through.
627
+ * Both sides are resolved through the real filesystem before they are compared.
628
+ * A dangling link is followed only when its raw target contains no parent
629
+ * traversal. The answer is then the lexical join of `root` and `path` — an
630
+ * absolute path under `root`, not a root-relative one — so the caller operates
631
+ * on the path it named rather than on a resolved form the target may not
632
+ * recognize. A `root` written with a parent segment is collapsed by that
633
+ * resolution before anything is read, so containment is measured against the
634
+ * directory the caller's text names.
635
+ *
636
+ * Comparison is exact text, which fails closed on a case-insensitive
637
+ * filesystem: a root and a path spelled with different case resolve to
638
+ * different strings there and are refused, never wrongly admitted.
639
+ *
640
+ * The answer describes the namespace this call read. The contract excludes a
641
+ * concurrent rename or link swap during the call or before the caller finishes
642
+ * using the returned path. This helper returns a string, not a filesystem
643
+ * handle, so it cannot bind its containment check to a later operation. A caller
644
+ * that admits hostile concurrent namespace mutation needs a handle-bound
645
+ * operation instead.
672
646
  *
673
647
  * @example
674
648
  * ```ts
675
- * import { matchesExecutablePath } from '@orkestrel/scaffold/server'
649
+ * import { resolveContainedPath } from '@orkestrel/scaffold/server'
676
650
  *
677
- * matchesExecutablePath('scripts/codex.sh') // true
678
- * matchesExecutablePath('scripts\\deps.sh') // true
679
- * matchesExecutablePath('AGENTS.md') // false
651
+ * resolveContainedPath('/tmp/project', 'guides/router.md')?.endsWith('router.md') // true
652
+ * resolveContainedPath('/tmp/project', '../secrets') // undefined
680
653
  * ```
681
654
  */
682
- function matchesExecutablePath(path) {
683
- return _src_core.EXECUTABLE_PATHS.includes(path.replaceAll("\\", "/"));
655
+ function resolveContainedPath(root, path) {
656
+ if (!isFilesystemPath(root) || !(0, _src_core.isPath)(path)) return void 0;
657
+ const base = (0, node_path.resolve)(root);
658
+ const destination = (0, node_path.join)(base, path);
659
+ const physicalRoot = resolveRealPath(base);
660
+ const physical = resolveRealPath(destination);
661
+ if (physicalRoot === void 0 || physical === void 0) return void 0;
662
+ if (physical !== physicalRoot && !physical.startsWith(physicalRoot + node_path.sep)) return void 0;
663
+ return destination;
684
664
  }
685
665
  /**
686
- * Projects a target-relative path to the storage name a vendored host holds it under.
666
+ * Tests whether a target is safe to write a fresh workspace into.
687
667
  *
688
- * @param path - The target-relative path the file is written to.
689
- * @returns The storage name beneath the host root.
668
+ * @param target - The candidate target directory.
669
+ * @returns True if the target is absent, empty, or holds nothing but its own `.git`
670
+ * directory; false otherwise.
690
671
  *
691
672
  * @remarks
692
- * A staged host is a plain directory that npm packs, and npm's own ignore rules
693
- * would drop a leading-dot entry from the tarball. So every dot that opens a
694
- * segment comes off, and a dotted file at the root moves under `dotfiles/` to
695
- * keep it from colliding with an undotted sibling of the same name. The mapping
696
- * is one direction only: a staged host's manifest records the destination each
697
- * storage name answers for, so the reader never re-derives this.
673
+ * The green-field law. A checkout of an empty repository is where a new
674
+ * workspace legitimately starts, so that one directory is admitted and nothing
675
+ * else is; anything more means the caller is repairing a workspace rather than
676
+ * creating one. Only the first two entries are read, so the answer costs the
677
+ * same on an empty directory and on a full one.
698
678
  *
699
679
  * @example
700
680
  * ```ts
701
- * import { pathToStorage } from '@orkestrel/scaffold/server'
681
+ * import { isVacant } from '@orkestrel/scaffold/server'
702
682
  *
703
- * pathToStorage('.gitignore') // 'dotfiles/gitignore'
704
- * pathToStorage('.claude/rules/names.md') // 'claude/rules/names.md'
705
- * pathToStorage('AGENTS.md') // 'AGENTS.md'
683
+ * isVacant('./packages/router-new') // true when absent, empty, or `.git` only
706
684
  * ```
707
685
  */
708
- function pathToStorage(path) {
709
- const segments = path.split("/");
710
- if (segments.length === 1) return path.startsWith(".") ? `dotfiles/${path.slice(1)}` : path;
711
- return segments.map((segment) => segment.startsWith(".") ? segment.slice(1) : segment).join("/");
686
+ function isVacant(target) {
687
+ if (!isFilesystemPath(target)) return false;
688
+ const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(target));
689
+ if (!status.success) return matchesMissingPath(status.error);
690
+ if (!status.value.isDirectory() || status.value.isSymbolicLink()) return false;
691
+ const opened = (0, _orkestrel_contract.attempt)(() => (0, node_fs.opendirSync)(target));
692
+ if (!opened.success) return false;
693
+ const handle = opened.value;
694
+ const read = (0, _orkestrel_contract.attempt)(() => {
695
+ const first = handle.readSync();
696
+ if (first === null) return true;
697
+ if (handle.readSync() !== null) return false;
698
+ return matchesGitPath(first.name) && first.isDirectory() && !first.isSymbolicLink();
699
+ });
700
+ (0, _orkestrel_contract.attempt)(() => handle.closeSync());
701
+ return read.success && read.value;
712
702
  }
713
703
  /**
714
- * Computes the SHA-256 digest of text.
704
+ * Lists a directory's files as sorted root-relative paths.
715
705
  *
716
- * @param content - The text to digest.
717
- * @returns Sixty-four lowercase hexadecimal digits.
706
+ * @param root - The directory to inventory.
707
+ * @returns Every descendant file as a `/`-separated root-relative path, in
708
+ * code-unit order, and `[]` when `root` is absent.
709
+ * @throws `ScaffoldError('INVALID', …)` when `root` is not a host path.
710
+ * @throws `ScaffoldError('TARGET', …)` when `root` is present but is not a
711
+ * physical directory, cannot be read, holds a name this package could not plan,
712
+ * or carries more entries or more nesting than one inventory may report.
718
713
  *
719
714
  * @remarks
720
- * The identity the server face states, and the reason it differs from core's:
721
- * core settled on a folded 64-bit identity because compilation is synchronous by
722
- * contract and the only cryptographic digest a host-independent scope reaches is
723
- * asynchronous. A Node host reaches the real one synchronously, and
724
- * `HostManifest.digest` is documented as SHA-256, so this is what the server
725
- * uses everywhere a digest is claimed.
715
+ * A whole-tree answer throws where a single-path answer returns `undefined`, and
716
+ * the reason is that a partial inventory reads exactly like a complete one. A
717
+ * caller comparing a target against a plan would treat a truncated listing as
718
+ * proof that the missing files are not there.
719
+ *
720
+ * Absence is the one exception: nothing to inventory is a complete answer, so it
721
+ * is the empty list. Links are listed as files rather than followed, so no
722
+ * traversal can leave the root and no cycle can form.
726
723
  *
727
724
  * @example
728
725
  * ```ts
729
- * import { computeDigest } from '@orkestrel/scaffold/server'
726
+ * import { listFiles } from '@orkestrel/scaffold/server'
730
727
  *
731
- * computeDigest('hi\n') // '98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4'
728
+ * listFiles('./dist/host') // ['AGENTS.md', 'CLAUDE.md', 'LICENSE', …]
732
729
  * ```
733
730
  */
734
- function computeDigest(content) {
735
- return (0, node_crypto.createHash)("sha256").update(content, "utf8").digest("hex");
731
+ function listFiles(root) {
732
+ if (!isFilesystemPath(root)) throw new _src_core.ScaffoldError("INVALID", "Listing root is not a host path", { root });
733
+ const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(root));
734
+ if (!status.success) {
735
+ if (matchesMissingPath(status.error)) return [];
736
+ throw new _src_core.ScaffoldError("TARGET", `Listing root cannot be inspected at ${root}`, {
737
+ root,
738
+ error: status.error
739
+ });
740
+ }
741
+ if (!status.value.isDirectory() || status.value.isSymbolicLink()) throw new _src_core.ScaffoldError("TARGET", `Listing root is not a physical directory at ${root}`, { root });
742
+ const files = [];
743
+ const pending = [{
744
+ full: root,
745
+ path: "",
746
+ depth: 0
747
+ }];
748
+ let visited = 0;
749
+ while (pending.length > 0) {
750
+ const current = pending.pop();
751
+ if (current === void 0) break;
752
+ const opened = (0, _orkestrel_contract.attempt)(() => (0, node_fs.opendirSync)(current.full));
753
+ if (!opened.success) throw new _src_core.ScaffoldError("TARGET", `Listing directory cannot be read at ${current.full}`, {
754
+ root,
755
+ path: current.path,
756
+ error: opened.error
757
+ });
758
+ const handle = opened.value;
759
+ const walked = (0, _orkestrel_contract.attempt)(() => {
760
+ for (;;) {
761
+ const entry = handle.readSync();
762
+ if (entry === null) break;
763
+ visited += 1;
764
+ if (visited > 1e5) throw new _src_core.ScaffoldError("TARGET", "Listing exceeds the paths one inventory may report", {
765
+ root,
766
+ limit: MAX_INVENTORY_PATHS
767
+ });
768
+ const path = current.path === "" ? entry.name : `${current.path}/${entry.name}`;
769
+ if (!(0, _src_core.isPath)(path)) throw new _src_core.ScaffoldError("TARGET", `Listing found a path this package cannot plan at ${path}`, {
770
+ root,
771
+ path
772
+ });
773
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
774
+ files.push(path);
775
+ continue;
776
+ }
777
+ const depth = current.depth + 1;
778
+ if (depth >= 64) throw new _src_core.ScaffoldError("TARGET", `Listing exceeds the depth one path may carry at ${path}`, {
779
+ root,
780
+ path,
781
+ limit: 64
782
+ });
783
+ pending.push({
784
+ full: (0, node_path.join)(current.full, entry.name),
785
+ path,
786
+ depth
787
+ });
788
+ }
789
+ });
790
+ (0, _orkestrel_contract.attempt)(() => handle.closeSync());
791
+ if (!walked.success) throw walked.error;
792
+ }
793
+ return files.sort();
736
794
  }
737
795
  /**
738
- * Projects exact bytes stated in hexadecimal to their SHA-256 digest.
796
+ * Lists a directory's descendant directories as sorted root-relative paths.
739
797
  *
740
- * @param hex - The exact lowercase hexadecimal bytes to digest.
741
- * @returns Sixty-four lowercase hexadecimal digits.
742
- * @throws `ScaffoldError('INVALID', …)` when `hex` is not exact bounded
743
- * lowercase hexadecimal text.
798
+ * @param root - The directory to inventory.
799
+ * @returns Every descendant directory as a `/`-separated root-relative path, in
800
+ * code-unit order, and `[]` when `root` is absent.
801
+ * @throws `ScaffoldError('INVALID', …)` when `root` is not a host path.
802
+ * @throws `ScaffoldError('TARGET', …)` when `root` is present but is not a
803
+ * physical directory, cannot be read, holds a name this package could not plan,
804
+ * or carries more entries or more nesting than one inventory may report.
805
+ *
806
+ * @remarks
807
+ * The sibling of {@link listFiles}, under the same bounds and the same refusals,
808
+ * and it exists because a directory holding no file is invisible to a file walk.
809
+ * That is the half a vendored host's `roots` declares and the half a file
810
+ * inventory cannot check, so a stager needs both walks to state a complete
811
+ * membership.
812
+ *
813
+ * `root` itself is not listed, because the answer is root-relative and the root
814
+ * has no root-relative name. A redirected directory is not listed and is not
815
+ * walked into, so no traversal can leave the root and no cycle can form.
744
816
  *
745
817
  * @example
746
818
  * ```ts
747
- * import { hexToDigest } from '@orkestrel/scaffold/server'
819
+ * import { listDirectories } from '@orkestrel/scaffold/server'
748
820
  *
749
- * hexToDigest('68690a') // '98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4'
821
+ * listDirectories('./.claude') // ['agents', 'rules', 'skills', …]
750
822
  * ```
751
823
  */
752
- function hexToDigest(hex) {
753
- if (!(0, _src_core.isHex)(hex)) throw new _src_core.ScaffoldError("INVALID", "Digest input is not exact hexadecimal bytes", { hex });
754
- return (0, node_crypto.createHash)("sha256").update(Buffer.from(hex, "hex")).digest("hex");
824
+ function listDirectories(root) {
825
+ if (!isFilesystemPath(root)) throw new _src_core.ScaffoldError("INVALID", "Listing root is not a host path", { root });
826
+ const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(root));
827
+ if (!status.success) {
828
+ if (matchesMissingPath(status.error)) return [];
829
+ throw new _src_core.ScaffoldError("TARGET", `Listing root cannot be inspected at ${root}`, {
830
+ root,
831
+ error: status.error
832
+ });
833
+ }
834
+ if (!status.value.isDirectory() || status.value.isSymbolicLink()) throw new _src_core.ScaffoldError("TARGET", `Listing root is not a physical directory at ${root}`, { root });
835
+ const directories = [];
836
+ const pending = [{
837
+ full: root,
838
+ path: "",
839
+ depth: 0
840
+ }];
841
+ let visited = 0;
842
+ while (pending.length > 0) {
843
+ const current = pending.pop();
844
+ if (current === void 0) break;
845
+ const opened = (0, _orkestrel_contract.attempt)(() => (0, node_fs.opendirSync)(current.full));
846
+ if (!opened.success) throw new _src_core.ScaffoldError("TARGET", `Listing directory cannot be read at ${current.full}`, {
847
+ root,
848
+ path: current.path,
849
+ error: opened.error
850
+ });
851
+ const handle = opened.value;
852
+ const walked = (0, _orkestrel_contract.attempt)(() => {
853
+ for (;;) {
854
+ const entry = handle.readSync();
855
+ if (entry === null) break;
856
+ visited += 1;
857
+ if (visited > 1e5) throw new _src_core.ScaffoldError("TARGET", "Listing exceeds the paths one inventory may report", {
858
+ root,
859
+ limit: MAX_INVENTORY_PATHS
860
+ });
861
+ const path = current.path === "" ? entry.name : `${current.path}/${entry.name}`;
862
+ if (!(0, _src_core.isPath)(path)) throw new _src_core.ScaffoldError("TARGET", `Listing found a path this package cannot plan at ${path}`, {
863
+ root,
864
+ path
865
+ });
866
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
867
+ const depth = current.depth + 1;
868
+ if (depth >= 64) throw new _src_core.ScaffoldError("TARGET", `Listing exceeds the depth one path may carry at ${path}`, {
869
+ root,
870
+ path,
871
+ limit: 64
872
+ });
873
+ directories.push(path);
874
+ pending.push({
875
+ full: (0, node_path.join)(current.full, entry.name),
876
+ path,
877
+ depth
878
+ });
879
+ }
880
+ });
881
+ (0, _orkestrel_contract.attempt)(() => handle.closeSync());
882
+ if (!walked.success) throw walked.error;
883
+ }
884
+ return directories.sort();
755
885
  }
756
886
  /**
757
- * Computes the digest of a vendored host's declared membership.
887
+ * Lists the canon paths a target holds, filtered to a plan's groups.
758
888
  *
759
- * @param entries - The ordered file membership declarations.
760
- * @param roots - The ordered directory membership declarations.
761
- * @returns The SHA-256 of that exact membership, in that exact order.
889
+ * @param target - The target directory to inspect.
890
+ * @param groups - The artifact groups the plan covers; a held path whose group
891
+ * is outside them is not listed.
892
+ * @returns Every held canon path as a `/`-separated target-relative path, a
893
+ * directory member expanded to the files beneath it, and `[]` when the target
894
+ * holds none or cannot be resolved.
895
+ * @throws `ScaffoldError('TARGET', …)` when a held canon directory cannot be
896
+ * inventoried; {@link listFiles} states each refusal.
762
897
  *
763
898
  * @remarks
764
- * Independent of the manifest's own `digest` field, which is what lets a reader
765
- * detect a membership edit that did not update it. Order is part of the claim
766
- * rather than normalized away, because a staged manifest sorts its entries and
767
- * roots once and a reordered copy is a different file. Each entry is projected
768
- * to exactly the declared fields, so a hand-added property cannot ride
769
- * into the digest and cannot change it either.
899
+ * A release stages the canon for reading rather than for a target, so a copy
900
+ * sitting in one is an artifact of a release that vendored it, and reading it
901
+ * here is what lets the deletion verb take it.
902
+ *
903
+ * A directory member is read by file, which is what pairs the paths a plan
904
+ * claims inside the canon with their artifacts and leaves only the rest foreign,
905
+ * with no case for a planned file. The plan's own selection gates the reading,
906
+ * the same rule that decides a vendored path's group, so a scoped audit reports
907
+ * nothing outside its groups. A member that cannot resolve inside the target is
908
+ * not held by it.
770
909
  *
771
910
  * @example
772
911
  * ```ts
773
- * import { computeManifestDigest } from '@orkestrel/scaffold/server'
912
+ * import { listCanonPaths } from '@orkestrel/scaffold/server'
774
913
  *
775
- * computeManifestDigest([], []) // the digest of the empty membership
914
+ * listCanonPaths('vacant', ['orchestration']) // []
776
915
  * ```
777
916
  */
778
- function computeManifestDigest(entries, roots) {
779
- return computeDigest(JSON.stringify({
780
- entries: entries.map((entry) => ({
781
- storage: entry.storage,
782
- destination: entry.destination,
783
- executable: entry.executable,
784
- digest: entry.digest
785
- })),
786
- roots: [...roots]
787
- }));
917
+ function listCanonPaths(target, groups) {
918
+ const held = [];
919
+ for (const member of _src_core.CANON_PATHS) {
920
+ const full = resolveContainedPath(target, member);
921
+ if (full === void 0) continue;
922
+ if (isPhysicalFile(full)) held.push(member);
923
+ else if (isPhysicalDirectory(full)) for (const name of listFiles(full)) held.push(`${member}/${name}`);
924
+ }
925
+ return held.filter((path) => groups.includes((0, _src_core.inferGroup)(path)));
788
926
  }
789
927
  /**
790
- * Tests whether a path is a physical file this package will read or replace.
928
+ * Removes every directory one set of deletions emptied.
791
929
  *
792
- * @param path - The resolved host path to inspect, without following links.
793
- * @returns True if the path is a regular file that is neither a link nor hard-linked
794
- * elsewhere; false otherwise.
930
+ * @param target - The directory the deleted paths are relative to.
931
+ * @param removed - The `/`-separated target-relative paths the deletion took.
932
+ * @returns Every removed directory as a target-relative path, in the order they
933
+ * were taken, and `[]` when the target is absent or nothing it holds was
934
+ * emptied.
795
935
  *
796
936
  * @remarks
797
- * The link tests are the point. A symbolic link is a path pointing somewhere
798
- * else, so writing through one writes outside the target; a hard link means a
799
- * second name shares the same bytes, so replacing them changes a file nobody
800
- * asked about. Both are refused rather than followed.
937
+ * A deletion that takes the last file out of a directory leaves the directory
938
+ * standing, and git records no directory, so a swept target keeps the shape of a
939
+ * set it no longer holds while every reading of it reports clean. Only an
940
+ * ancestor of a path this deletion took is a candidate, so nothing the deletion
941
+ * did not reach is inspected, and the target itself is never a candidate.
942
+ *
943
+ * Candidates are taken deepest first, which is what lets a whole chain go: the
944
+ * directory holding nothing but the emptied directory is empty in turn by the
945
+ * time it is read. Each one is resolved through the containment law and left
946
+ * standing when it escapes the target, is not a physical directory, still holds
947
+ * an entry, or refuses removal, and a directory left standing is absent from the
948
+ * answer.
801
949
  *
802
950
  * @example
803
951
  * ```ts
804
- * import { isPhysicalFile } from '@orkestrel/scaffold/server'
952
+ * import { pruneEmptiedDirectories } from '@orkestrel/scaffold/server'
805
953
  *
806
- * isPhysicalFile('/tmp/project/AGENTS.md') // true for a plain file
954
+ * pruneEmptiedDirectories('vacant', ['notes/entry.md']) // []
807
955
  * ```
808
956
  */
809
- function isPhysicalFile(path) {
810
- const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(path));
811
- return status.success && status.value.isFile() && !status.value.isSymbolicLink() && status.value.nlink === 1;
812
- }
813
- /**
814
- * Tests whether a path is a physical file with exact on-disk casing.
815
- *
816
- * @param path - The host path to inspect segment by segment.
817
- * @returns True if the path is a physical file whose requested segments exactly match
818
- * the names each parent directory stores; false otherwise.
819
- *
820
- * @remarks
821
- * A direct file lookup follows the host's case-folding rules on Windows and
822
- * common macOS filesystems. Reading each parent directory supplies the stored
823
- * names, so this predicate can enforce the package's exact-case structural
824
- * contract on every supported host.
825
- *
826
- * @example
827
- * ```ts
828
- * import { isExactCaseFile } from '@orkestrel/scaffold/server'
829
- *
830
- * isExactCaseFile('/tmp/project/src/bin/main.ts') // true only for that exact spelling
831
- * ```
832
- */
833
- function isExactCaseFile(path) {
834
- const full = (0, node_path.resolve)(path);
835
- if (!isPhysicalFile(full)) return false;
836
- const root = (0, node_path.parse)(full).root;
837
- const segments = (0, node_path.relative)(root, full).split(node_path.sep);
838
- let parent = root;
839
- for (const segment of segments) {
840
- const entries = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readdirSync)(parent));
841
- if (entries.success) {
842
- if (!entries.value.includes(segment)) return false;
843
- } else {
844
- const actual = (0, _orkestrel_contract.attempt)(() => node_fs.realpathSync.native((0, node_path.join)(parent, segment)));
845
- if (!actual.success || (0, node_path.basename)(actual.value) !== segment) return false;
957
+ function pruneEmptiedDirectories(target, removed) {
958
+ const candidates = /* @__PURE__ */ new Set();
959
+ for (const path of removed) {
960
+ let parent = (0, node_path.dirname)(path);
961
+ while (parent !== "." && parent !== (0, node_path.dirname)(parent) && !candidates.has(parent)) {
962
+ candidates.add(parent);
963
+ parent = (0, node_path.dirname)(parent);
846
964
  }
847
- parent = (0, node_path.join)(parent, segment);
848
965
  }
849
- return true;
850
- }
851
- /**
852
- * Tests whether a path is a physical directory this package will read or write into.
853
- *
854
- * @param path - The resolved host path to inspect, without following links.
855
- * @returns True if the path is a directory that is not a link; false otherwise.
856
- *
857
- * @remarks
858
- * A junction and a directory symbolic link both report as directories after
859
- * they are followed, so the inspection deliberately does not follow: a redirected
860
- * directory is refused here rather than silently accepted as the one the caller
861
- * named.
862
- *
863
- * @example
864
- * ```ts
865
- * import { isPhysicalDirectory } from '@orkestrel/scaffold/server'
866
- *
867
- * isPhysicalDirectory('/tmp/project') // true for a plain directory
868
- * ```
869
- */
870
- function isPhysicalDirectory(path) {
871
- const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(path));
872
- return status.success && status.value.isDirectory() && !status.value.isSymbolicLink();
966
+ const ordered = [...candidates].sort((left, right) => (0, _orkestrel_contract.compareValues)(right.split("/").length, left.split("/").length) || (0, _orkestrel_contract.compareValues)(left, right));
967
+ const pruned = [];
968
+ for (const candidate of ordered) {
969
+ const directory = resolveContainedPath(target, candidate);
970
+ if (directory === void 0 || !isPhysicalDirectory(directory)) continue;
971
+ const entries = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readdirSync)(directory));
972
+ if (!entries.success || entries.value.length > 0) continue;
973
+ if ((0, _orkestrel_contract.attempt)(() => (0, node_fs.rmdirSync)(directory)).success) pruned.push(candidate);
974
+ }
975
+ return pruned;
873
976
  }
874
977
  /**
875
- * Computes the SHA-256 digest of one file's exact bytes.
978
+ * Reads one contained file as its exact bytes in lowercase hexadecimal.
876
979
  *
877
- * @param path - The resolved host path to digest.
878
- * @returns The digest, or `undefined` when the path is not a physical file, is
879
- * past the artifact ceiling, or moved while it was being read.
980
+ * @param root - The containing host directory.
981
+ * @param path - The portable root-relative file path.
982
+ * @param limit - The most bytes this read accepts; the artifact ceiling by default.
983
+ * @returns The exact bytes as hexadecimal, or `undefined` when the file is
984
+ * absent, is not a physical readable file, is past `limit`, or moved while it
985
+ * was being read.
986
+ * @throws `ScaffoldError('INVALID', …)` when the arguments are off contract or
987
+ * `path` leaves `root`.
880
988
  *
881
989
  * @remarks
882
- * Read in bounded chunks rather than loaded whole, so digesting a large file
883
- * costs one buffer instead of its size. The file's identity and size are
884
- * measured before and after the read and a mismatch answers `undefined`, so a
885
- * digest is either of one settled file or is not produced at all.
990
+ * Hexadecimal rather than text, because this is what a byte comparison is stated
991
+ * in everywhere in this package: a plan's artifact, an audit finding, and a
992
+ * snapshot all compare as the same digits. The file's identity and size are
993
+ * measured before and after the read, and one extra byte is requested past the
994
+ * declared size, so a file that grew or was replaced mid-read answers
995
+ * `undefined` rather than half of one file and half of another.
886
996
  *
887
997
  * @example
888
998
  * ```ts
889
- * import { computeFileDigest } from '@orkestrel/scaffold/server'
999
+ * import { readFileHex } from '@orkestrel/scaffold/server'
890
1000
  *
891
- * computeFileDigest('/tmp/project/AGENTS.md') // the file's SHA-256
892
- * computeFileDigest('/tmp/project/absent.md') // undefined
1001
+ * readFileHex('/tmp/project', 'AGENTS.md') // '2320416765…'
893
1002
  * ```
894
1003
  */
895
- function computeFileDigest(path) {
896
- if (!isPhysicalFile(path)) return void 0;
897
- const opened = (0, _orkestrel_contract.attempt)(() => (0, node_fs.openSync)(path, "r"));
1004
+ function readFileHex(root, path, limit = _src_core.MAX_ARTIFACT_BYTES) {
1005
+ if (!Number.isSafeInteger(limit) || limit < 0 || limit > _src_core.MAX_ARTIFACT_BYTES) throw new _src_core.ScaffoldError("INVALID", `Byte limit is outside the artifact ceiling at ${path}`, {
1006
+ root,
1007
+ path,
1008
+ limit
1009
+ });
1010
+ const full = resolveContainedPath(root, path);
1011
+ if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Path is off contract or leaves its root at ${path}`, {
1012
+ root,
1013
+ path
1014
+ });
1015
+ if (!isPhysicalFile(full)) return void 0;
1016
+ const opened = (0, _orkestrel_contract.attempt)(() => (0, node_fs.openSync)(full, "r"));
898
1017
  if (!opened.success) return void 0;
899
1018
  const handle = opened.value;
900
1019
  const read = (0, _orkestrel_contract.attempt)(() => {
901
1020
  const before = (0, node_fs.fstatSync)(handle);
902
- if (!before.isFile() || before.nlink !== 1 || before.size > _src_core.MAX_ARTIFACT_BYTES) return void 0;
903
- const hash = (0, node_crypto.createHash)("sha256");
904
- const buffer = Buffer.alloc(65536);
905
- let size = 0;
906
- for (;;) {
907
- const length = (0, node_fs.readSync)(handle, buffer, 0, buffer.byteLength, null);
1021
+ if (!before.isFile() || before.nlink !== 1 || before.size > limit) return void 0;
1022
+ const bytes = Buffer.alloc(before.size);
1023
+ let offset = 0;
1024
+ while (offset < bytes.byteLength) {
1025
+ const length = (0, node_fs.readSync)(handle, bytes, offset, bytes.byteLength - offset, offset);
908
1026
  if (length === 0) break;
909
- size += length;
910
- if (size > _src_core.MAX_ARTIFACT_BYTES) return void 0;
911
- hash.update(buffer.subarray(0, length));
1027
+ offset += length;
912
1028
  }
1029
+ const overflow = (0, node_fs.readSync)(handle, Buffer.alloc(1), 0, 1, offset);
913
1030
  const after = (0, node_fs.fstatSync)(handle);
914
- if (size !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs) return;
915
- return hash.digest("hex");
1031
+ if (offset !== bytes.byteLength || overflow !== 0 || after.size !== before.size || after.mtimeMs !== before.mtimeMs) return;
1032
+ return (0, _src_core.bytesToHex)(bytes);
916
1033
  });
917
1034
  (0, _orkestrel_contract.attempt)(() => (0, node_fs.closeSync)(handle));
918
1035
  return read.success ? read.value : void 0;
919
1036
  }
920
1037
  /**
921
- * Resolves a path through the real filesystem, keeping the part that does not exist yet.
1038
+ * Reads one contained file as bounded UTF-8 text.
922
1039
  *
923
- * @param path - The absolute or relative host path to resolve.
924
- * @returns The lexical resolution of `path`, with its existing prefix then
925
- * resolved through every link, or `undefined` when the text is not a host path,
926
- * no bounded existing ancestor resolves, a link target cannot be read, a link
927
- * target carries a `..` segment, or an ancestor cannot be read.
1040
+ * @param root - The containing host directory.
1041
+ * @param path - The portable root-relative file path.
1042
+ * @param limit - The most bytes this read accepts; the artifact ceiling by default.
1043
+ * @returns The decoded text, or `undefined` when {@link readFileHex} answers
1044
+ * nothing or the bytes are not valid UTF-8.
1045
+ * @throws `ScaffoldError('INVALID', …)` when the arguments are off contract or
1046
+ * `path` leaves `root`.
928
1047
  *
929
1048
  * @remarks
930
- * A containment decision has to be made about a destination that does not exist
931
- * yet, and a lexical answer is not enough: a link anywhere in the existing
932
- * prefix moves the destination somewhere the text never named. So the deepest
933
- * existing ancestor is resolved and the remaining segments are re-joined onto
934
- * it. The climb is bounded by the path-depth ceiling, so an adversarial path
935
- * cannot make it walk indefinitely.
936
- *
937
- * The caller's own text is collapsed first, which is what `resolve` does with a
938
- * `..` the caller wrote: it cancels the segment before it as text, before any
939
- * link in that segment is read. So `<root>/hop/..` answers `<root>` even where
940
- * `hop` links elsewhere, rather than the directory holding what `hop` points at.
941
- * The collapse only ever shortens the caller's path, so nothing reaches outside
942
- * it by this; the answer is that lexical location resolved through links, not
943
- * the physical location the links lead to. {@link resolveContainedPath} passes
944
- * its `root` through here, so a root written with a parent segment is contained
945
- * against its collapsed spelling.
946
- *
947
- * `realpath` answers `ENOENT` both for a name that is not there and for a link
948
- * whose target is not there. The name is therefore inspected without following
949
- * it: a dangling link redirects the walk to its target, while a genuinely absent
950
- * name is retained as one segment of the unresolved suffix. A dangling link
951
- * target containing a `..` segment is refused. Resolving that target as one
952
- * lexical string could discard a preceding link before the filesystem gives
953
- * `..` its physical meaning.
954
- *
955
- * That target is split on both separators on every host, which is the reading
956
- * `isPath` already gives a planned path. A POSIX filename legally containing a
957
- * backslash is therefore refused with it: `weird\..\name` is one name to the
958
- * host and three segments here. The package keeps one separator law rather than
959
- * a host-dependent second one, and this is the conservative side of it.
1049
+ * Decoding is strict, so a file carrying an invalid sequence answers `undefined`
1050
+ * rather than text carrying replacement characters. That matters because the
1051
+ * text is parsed next: a manifest silently repaired into valid JSON by lossy
1052
+ * decoding would be trusted.
960
1053
  *
961
1054
  * @example
962
1055
  * ```ts
963
- * import { resolveRealPath } from '@orkestrel/scaffold/server'
1056
+ * import { readFileText } from '@orkestrel/scaffold/server'
964
1057
  *
965
- * resolveRealPath('./packages/new/src') // the real path of `packages`, plus `new/src`
1058
+ * readFileText('/tmp/project', 'package.json') // '{ "name": "@orkestrel/router", … }'
966
1059
  * ```
967
1060
  */
968
- function resolveRealPath(path) {
969
- if (!isFilesystemPath(path)) return void 0;
970
- let current = (0, node_path.resolve)(path);
971
- const pending = [];
972
- for (let depth = 0; depth <= 64; depth += 1) {
973
- const real = (0, _orkestrel_contract.attempt)(() => (0, node_fs.realpathSync)(current));
974
- if (real.success) {
975
- let physical = real.value;
976
- for (const segment of pending) physical = (0, node_path.join)(physical, segment);
977
- return physical;
978
- }
979
- if (!matchesMissingPath(real.error)) return void 0;
980
- const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(current));
981
- if (status.success) {
982
- if (!status.value.isSymbolicLink()) return void 0;
983
- const target = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readlinkSync)(current));
984
- if (!target.success) return void 0;
985
- if (target.value.split(/[\\/]/u).includes("..")) return void 0;
986
- current = (0, node_path.resolve)((0, node_path.dirname)(current), target.value);
987
- continue;
988
- }
989
- if (!matchesMissingPath(status.error)) return void 0;
990
- const parent = (0, node_path.dirname)(current);
991
- if (parent === current) return void 0;
992
- pending.unshift((0, node_path.relative)(parent, current));
993
- current = parent;
994
- }
1061
+ function readFileText(root, path, limit = _src_core.MAX_ARTIFACT_BYTES) {
1062
+ const hex = readFileHex(root, path, limit);
1063
+ if (hex === void 0) return void 0;
1064
+ const decoded = (0, _orkestrel_contract.attempt)(() => new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(hex, "hex")));
1065
+ return decoded.success ? decoded.value : void 0;
995
1066
  }
996
1067
  /**
997
- * Resolves a root-relative path and refuses one that leaves its root.
1068
+ * Reads a target's current bytes at the paths a plan claims.
998
1069
  *
999
- * @param root - The containing host directory.
1000
- * @param path - The portable root-relative path.
1001
- * @returns The destination as this package will address it, or `undefined` when
1002
- * either argument is off contract or the destination lies outside `root`.
1070
+ * @param target - The target directory to read.
1071
+ * @param paths - The plan-relative paths to probe.
1072
+ * @returns One entry per path that is there: a file maps to its exact bytes as
1073
+ * hexadecimal and a directory maps to `''`, which records presence with no bytes
1074
+ * to compare. An absent path is omitted.
1075
+ * @throws `ScaffoldError('INVALID', …)` when `target` is not a host path or
1076
+ * `paths` is not a bounded list of plannable paths.
1077
+ * @throws `ScaffoldError('TARGET', …)` when a path that is there cannot be read,
1078
+ * or when the whole read would retain more bytes than one plan may.
1003
1079
  *
1004
1080
  * @remarks
1005
- * The containment law, and the one door every read in this module goes through.
1006
- * Both sides are resolved through the real filesystem before they are compared.
1007
- * A dangling link is followed only when its raw target contains no parent
1008
- * traversal. The answer is then the lexical join of `root` and `path` — an
1009
- * absolute path under `root`, not a root-relative one — so the caller operates
1010
- * on the path it named rather than on a resolved form the target may not
1011
- * recognize. A `root` written with a parent segment is collapsed by that
1012
- * resolution before anything is read, so containment is measured against the
1013
- * directory the caller's text names.
1014
- *
1015
- * Comparison is exact text, which fails closed on a case-insensitive
1016
- * filesystem: a root and a path spelled with different case resolve to
1017
- * different strings there and are refused, never wrongly admitted.
1018
- *
1019
- * The answer describes the namespace this call read. The contract excludes a
1020
- * concurrent rename or link swap during the call or before the caller finishes
1021
- * using the returned path. This helper returns a string, not a filesystem
1022
- * handle, so it cannot bind its containment check to a later operation. A caller
1023
- * that admits hostile concurrent namespace mutation needs a handle-bound
1024
- * operation instead.
1081
+ * The one door from a real directory into the vocabulary an audit compares in.
1082
+ * Absence is omission rather than an empty value, because core reads a missing
1083
+ * key as a missing destination and an empty string as a present directory;
1084
+ * they are different verdicts. A path that is there but unreadable throws instead
1085
+ * of being omitted, because omission would report it as missing and a repair
1086
+ * would then overwrite whatever is actually sitting there.
1025
1087
  *
1026
1088
  * @example
1027
1089
  * ```ts
1028
- * import { resolveContainedPath } from '@orkestrel/scaffold/server'
1090
+ * import { readSnapshot } from '@orkestrel/scaffold/server'
1029
1091
  *
1030
- * resolveContainedPath('/tmp/project', 'guides/router.md')?.endsWith('router.md') // true
1031
- * resolveContainedPath('/tmp/project', '../secrets') // undefined
1032
- * ```
1033
- */
1034
- function resolveContainedPath(root, path) {
1035
- if (!isFilesystemPath(root) || !(0, _src_core.isPath)(path)) return void 0;
1036
- const base = (0, node_path.resolve)(root);
1037
- const destination = (0, node_path.join)(base, path);
1038
- const physicalRoot = resolveRealPath(base);
1039
- const physical = resolveRealPath(destination);
1040
- if (physicalRoot === void 0 || physical === void 0) return void 0;
1041
- if (physical !== physicalRoot && !physical.startsWith(physicalRoot + node_path.sep)) return void 0;
1042
- return destination;
1043
- }
1044
- /**
1045
- * Tests whether a target is safe to write a fresh workspace into.
1046
- *
1047
- * @param target - The candidate target directory.
1048
- * @returns True if the target is absent, empty, or holds nothing but its own `.git`
1049
- * directory; false otherwise.
1050
- *
1051
- * @remarks
1052
- * The green-field law. A checkout of an empty repository is where a new
1053
- * workspace legitimately starts, so that one directory is admitted and nothing
1054
- * else is; anything more means the caller is repairing a workspace rather than
1055
- * creating one. Only the first two entries are read, so the answer costs the
1056
- * same on an empty directory and on a full one.
1057
- *
1058
- * @example
1059
- * ```ts
1060
- * import { isVacant } from '@orkestrel/scaffold/server'
1061
- *
1062
- * isVacant('./packages/router-new') // true when absent, empty, or `.git` only
1092
+ * readSnapshot('./packages/router', ['package.json', 'guides'])
1093
+ * // { 'package.json': '7b226e…', guides: '' }
1063
1094
  * ```
1064
1095
  */
1065
- function isVacant(target) {
1066
- if (!isFilesystemPath(target)) return false;
1067
- const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(target));
1068
- if (!status.success) return matchesMissingPath(status.error);
1069
- if (!status.value.isDirectory() || status.value.isSymbolicLink()) return false;
1070
- const opened = (0, _orkestrel_contract.attempt)(() => (0, node_fs.opendirSync)(target));
1071
- if (!opened.success) return false;
1072
- const handle = opened.value;
1073
- const read = (0, _orkestrel_contract.attempt)(() => {
1074
- const first = handle.readSync();
1075
- if (first === null) return true;
1076
- if (handle.readSync() !== null) return false;
1077
- return matchesGitPath(first.name) && first.isDirectory() && !first.isSymbolicLink();
1096
+ function readSnapshot(target, paths) {
1097
+ if (!isFilesystemPath(target)) throw new _src_core.ScaffoldError("INVALID", "Snapshot target is not a host path", { target });
1098
+ if (!(0, _src_core.isCollection)(paths) || !paths.every((path) => (0, _src_core.isPath)(path))) throw new _src_core.ScaffoldError("INVALID", "Snapshot paths are not a bounded list of plannable paths", {
1099
+ target,
1100
+ limit: _src_core.MAX_COLLECTION_ITEMS
1078
1101
  });
1079
- (0, _orkestrel_contract.attempt)(() => handle.closeSync());
1080
- return read.success && read.value;
1102
+ let remaining = _src_core.MAX_TOTAL_ARTIFACT_BYTES;
1103
+ const snapshot = {};
1104
+ for (const path of paths) {
1105
+ const full = resolveContainedPath(target, path);
1106
+ if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Snapshot path leaves its target at ${path}`, {
1107
+ target,
1108
+ path
1109
+ });
1110
+ const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(full));
1111
+ if (!status.success) {
1112
+ if (matchesMissingPath(status.error)) continue;
1113
+ throw new _src_core.ScaffoldError("TARGET", `Snapshot path cannot be inspected at ${path}`, {
1114
+ target,
1115
+ path,
1116
+ error: status.error
1117
+ });
1118
+ }
1119
+ if (isPhysicalDirectory(full)) {
1120
+ snapshot[path] = "";
1121
+ continue;
1122
+ }
1123
+ const hex = readFileHex(target, path, Math.min(_src_core.MAX_ARTIFACT_BYTES, remaining));
1124
+ if (hex === void 0) throw new _src_core.ScaffoldError("TARGET", `Snapshot path is not a readable file at ${path}`, {
1125
+ target,
1126
+ path,
1127
+ limit: _src_core.MAX_TOTAL_ARTIFACT_BYTES
1128
+ });
1129
+ remaining -= hex.length / 2;
1130
+ snapshot[path] = hex;
1131
+ }
1132
+ return snapshot;
1081
1133
  }
1082
1134
  /**
1083
- * Lists a directory's files as sorted root-relative paths.
1135
+ * Reads a vendored host's manifest, when it carries one.
1084
1136
  *
1085
- * @param root - The directory to inventory.
1086
- * @returns Every descendant file as a `/`-separated root-relative path, in
1087
- * code-unit order, and `[]` when `root` is absent.
1088
- * @throws `ScaffoldError('INVALID', …)` when `root` is not a host path.
1089
- * @throws `ScaffoldError('TARGET', …)` when `root` is present but is not a
1090
- * physical directory, cannot be read, holds a name this package could not plan,
1091
- * or carries more entries or more nesting than one inventory may report.
1137
+ * @param host - The vendored host root to read.
1138
+ * @param name - The root-relative manifest path. Default: `manifest.json`.
1139
+ * @returns The manifest, or `undefined` when the host carries none.
1140
+ * @throws `ScaffoldError('INVALID', …)` when `host` is not a host path.
1141
+ * @throws `ScaffoldError('TARGET', …)` when the manifest is there but cannot be
1142
+ * read, is not the declared shape, or does not match its own membership.
1092
1143
  *
1093
1144
  * @remarks
1094
- * A whole-tree answer throws where a single-path answer returns `undefined`, and
1095
- * the reason is that a partial inventory reads exactly like a complete one. A
1096
- * caller comparing a target against a plan would treat a truncated listing as
1097
- * proof that the missing files are not there.
1145
+ * The failures are held apart deliberately. A host with no manifest is a
1146
+ * raw checkout, and a caller reads it by mapping each path one to one. A host
1147
+ * with a manifest that does not verify is a staged host that has been edited,
1148
+ * and answering `undefined` there would degrade it to that same one-to-one
1149
+ * mapping — which is how an edited manifest would get a caller to read files it
1150
+ * never declared. So absence answers and corruption throws.
1098
1151
  *
1099
- * Absence is the one exception: nothing to inventory is a complete answer, so it
1100
- * is the empty list. Links are listed as files rather than followed, so no
1101
- * traversal can leave the root and no cycle can form.
1152
+ * Verification here is the manifest's own self-consistency: the digest against
1153
+ * the exact membership beside it. Whether that membership matches the files
1154
+ * actually stored is a separate question, and it belongs to the reader that
1155
+ * walks the host.
1102
1156
  *
1103
1157
  * @example
1104
1158
  * ```ts
1105
- * import { listFiles } from '@orkestrel/scaffold/server'
1159
+ * import { readHostManifest } from '@orkestrel/scaffold/server'
1106
1160
  *
1107
- * listFiles('./dist/host') // ['AGENTS.md', 'CLAUDE.md', 'LICENSE', …]
1161
+ * readHostManifest('./dist/host') // the manifest, or undefined for a raw root
1108
1162
  * ```
1109
1163
  */
1110
- function listFiles(root) {
1111
- if (!isFilesystemPath(root)) throw new _src_core.ScaffoldError("INVALID", "Listing root is not a host path", { root });
1112
- const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(root));
1164
+ function readHostManifest(host, name = MANIFEST_NAME) {
1165
+ if (!isFilesystemPath(host)) throw new _src_core.ScaffoldError("INVALID", "Host root is not a host path", { host });
1166
+ const full = resolveContainedPath(host, name);
1167
+ if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Host manifest leaves its root at ${host}`, { host });
1168
+ const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(full));
1113
1169
  if (!status.success) {
1114
- if (matchesMissingPath(status.error)) return [];
1115
- throw new _src_core.ScaffoldError("TARGET", `Listing root cannot be inspected at ${root}`, {
1116
- root,
1170
+ if (matchesMissingPath(status.error)) return void 0;
1171
+ throw new _src_core.ScaffoldError("TARGET", `Host manifest cannot be inspected at ${full}`, {
1172
+ host,
1117
1173
  error: status.error
1118
1174
  });
1119
1175
  }
1120
- if (!status.value.isDirectory() || status.value.isSymbolicLink()) throw new _src_core.ScaffoldError("TARGET", `Listing root is not a physical directory at ${root}`, { root });
1121
- const files = [];
1122
- const pending = [{
1123
- full: root,
1124
- path: "",
1125
- depth: 0
1126
- }];
1127
- let visited = 0;
1128
- while (pending.length > 0) {
1129
- const current = pending.pop();
1130
- if (current === void 0) break;
1131
- const opened = (0, _orkestrel_contract.attempt)(() => (0, node_fs.opendirSync)(current.full));
1132
- if (!opened.success) throw new _src_core.ScaffoldError("TARGET", `Listing directory cannot be read at ${current.full}`, {
1133
- root,
1134
- path: current.path,
1135
- error: opened.error
1136
- });
1137
- const handle = opened.value;
1138
- const walked = (0, _orkestrel_contract.attempt)(() => {
1139
- for (;;) {
1140
- const entry = handle.readSync();
1141
- if (entry === null) break;
1142
- visited += 1;
1143
- if (visited > 1e5) throw new _src_core.ScaffoldError("TARGET", "Listing exceeds the paths one inventory may report", {
1144
- root,
1145
- limit: MAX_INVENTORY_PATHS
1146
- });
1147
- const path = current.path === "" ? entry.name : `${current.path}/${entry.name}`;
1148
- if (!(0, _src_core.isPath)(path)) throw new _src_core.ScaffoldError("TARGET", `Listing found a path this package cannot plan at ${path}`, {
1149
- root,
1150
- path
1151
- });
1152
- if (!entry.isDirectory() || entry.isSymbolicLink()) {
1153
- files.push(path);
1154
- continue;
1155
- }
1156
- const depth = current.depth + 1;
1157
- if (depth >= 64) throw new _src_core.ScaffoldError("TARGET", `Listing exceeds the depth one path may carry at ${path}`, {
1158
- root,
1159
- path,
1160
- limit: 64
1161
- });
1162
- pending.push({
1163
- full: (0, node_path.join)(current.full, entry.name),
1164
- path,
1165
- depth
1166
- });
1167
- }
1168
- });
1169
- (0, _orkestrel_contract.attempt)(() => handle.closeSync());
1170
- if (!walked.success) throw walked.error;
1171
- }
1172
- return files.sort();
1176
+ const text = readFileText(host, name, _src_core.MAX_MANIFEST_BYTES);
1177
+ if (text === void 0) throw new _src_core.ScaffoldError("TARGET", `Host manifest is not readable text at ${full}`, { host });
1178
+ const manifest = (0, _orkestrel_contract.parseJSONAs)(text, isHostManifest);
1179
+ if (manifest === void 0) throw new _src_core.ScaffoldError("TARGET", `Host manifest is malformed at ${full}`, { host });
1180
+ if (manifest.digest !== computeManifestDigest(manifest.entries, manifest.roots, manifest.surface)) throw new _src_core.ScaffoldError("TARGET", `Host manifest membership is corrupted at ${full}`, { host });
1181
+ return manifest;
1173
1182
  }
1174
1183
  /**
1175
- * Lists a directory's descendant directories as sorted root-relative paths.
1184
+ * Reads the installed vendored host floor as a value.
1176
1185
  *
1177
- * @param root - The directory to inventory.
1178
- * @returns Every descendant directory as a `/`-separated root-relative path, in
1179
- * code-unit order, and `[]` when `root` is absent.
1180
- * @throws `ScaffoldError('INVALID', …)` when `root` is not a host path.
1181
- * @throws `ScaffoldError('TARGET', …)` when `root` is present but is not a
1182
- * physical directory, cannot be read, holds a name this package could not plan,
1183
- * or carries more entries or more nesting than one inventory may report.
1186
+ * @param root - The vendored host root. Default: the installed package's
1187
+ * vendored root, resolved from this module's location.
1188
+ * @returns The verified manifest and the exact bytes of every declared entry.
1189
+ * @throws `ScaffoldError('TARGET', …)` when the root is not a readable physical
1190
+ * directory, its manifest is absent or unreadable, the manifest does not verify,
1191
+ * or a declared file is unreadable or misses its digest.
1184
1192
  *
1185
1193
  * @remarks
1186
- * The sibling of {@link listFiles}, under the same bounds and the same refusals,
1187
- * and it exists because a directory holding no file is invisible to a file walk.
1188
- * That is the half a vendored host's `roots` declares and the half a file
1189
- * inventory cannot check, so a stager needs both walks to state a complete
1190
- * membership.
1191
- *
1192
- * `root` itself is not listed, because the answer is root-relative and the root
1193
- * has no root-relative name. A redirected directory is not listed and is not
1194
- * walked into, so no traversal can leave the root and no cycle can form.
1194
+ * Reads the same default floor the {@link Materializer} uses. Each declared
1195
+ * file is addressed through the manifest's storage name and retained under its
1196
+ * destination, so the returned value has the same shape as the installed root.
1197
+ * When this module executes from TypeScript source, the committed inventory is
1198
+ * the manifest and each checkout destination supplies its bytes. The emitted
1199
+ * module reads the staged `manifest.json` file and each storage path instead.
1195
1200
  *
1196
1201
  * @example
1197
1202
  * ```ts
1198
- * import { listDirectories } from '@orkestrel/scaffold/server'
1203
+ * import { readHostFloor } from '@orkestrel/scaffold/server'
1199
1204
  *
1200
- * listDirectories('./.claude') // ['agents', 'rules', 'skills', …]
1205
+ * readHostFloor().manifest // the installed floor's verified membership
1201
1206
  * ```
1202
1207
  */
1203
- function listDirectories(root) {
1204
- if (!isFilesystemPath(root)) throw new _src_core.ScaffoldError("INVALID", "Listing root is not a host path", { root });
1205
- const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(root));
1206
- if (!status.success) {
1207
- if (matchesMissingPath(status.error)) return [];
1208
- throw new _src_core.ScaffoldError("TARGET", `Listing root cannot be inspected at ${root}`, {
1209
- root,
1210
- error: status.error
1208
+ function readHostFloor(root) {
1209
+ const location = (0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href);
1210
+ const module = (0, node_path.dirname)(location);
1211
+ const source = root === void 0 && (0, node_path.extname)(location) === ".ts";
1212
+ const host = root ?? (0, node_path.resolve)(module, source ? "../.." : "../../host");
1213
+ if (!isPhysicalDirectory(host)) throw new _src_core.ScaffoldError("TARGET", `The vendored host root is not readable at ${host}`, { host });
1214
+ const manifest = readHostManifest(host, source ? _src_core.HOST_INVENTORY_PATH : MANIFEST_NAME);
1215
+ if (manifest === void 0) throw new _src_core.ScaffoldError("TARGET", `The vendored host carries no manifest at ${host}`, { host });
1216
+ const bytes = {};
1217
+ for (const entry of manifest.entries) {
1218
+ const path = source ? entry.destination : entry.storage;
1219
+ const hex = readFileHex(host, path);
1220
+ if (hex === void 0 || hexToDigest(hex) !== entry.digest) throw new _src_core.ScaffoldError("TARGET", `The vendored host cannot read the declared file at ${path}`, {
1221
+ host,
1222
+ path,
1223
+ destination: entry.destination
1211
1224
  });
1225
+ bytes[entry.destination] = hex;
1212
1226
  }
1213
- if (!status.value.isDirectory() || status.value.isSymbolicLink()) throw new _src_core.ScaffoldError("TARGET", `Listing root is not a physical directory at ${root}`, { root });
1214
- const directories = [];
1215
- const pending = [{
1216
- full: root,
1217
- path: "",
1218
- depth: 0
1219
- }];
1220
- let visited = 0;
1221
- while (pending.length > 0) {
1222
- const current = pending.pop();
1223
- if (current === void 0) break;
1224
- const opened = (0, _orkestrel_contract.attempt)(() => (0, node_fs.opendirSync)(current.full));
1225
- if (!opened.success) throw new _src_core.ScaffoldError("TARGET", `Listing directory cannot be read at ${current.full}`, {
1226
- root,
1227
- path: current.path,
1228
- error: opened.error
1229
- });
1230
- const handle = opened.value;
1231
- const walked = (0, _orkestrel_contract.attempt)(() => {
1232
- for (;;) {
1233
- const entry = handle.readSync();
1234
- if (entry === null) break;
1235
- visited += 1;
1236
- if (visited > 1e5) throw new _src_core.ScaffoldError("TARGET", "Listing exceeds the paths one inventory may report", {
1237
- root,
1238
- limit: MAX_INVENTORY_PATHS
1239
- });
1240
- const path = current.path === "" ? entry.name : `${current.path}/${entry.name}`;
1241
- if (!(0, _src_core.isPath)(path)) throw new _src_core.ScaffoldError("TARGET", `Listing found a path this package cannot plan at ${path}`, {
1242
- root,
1243
- path
1244
- });
1245
- if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
1246
- const depth = current.depth + 1;
1247
- if (depth >= 64) throw new _src_core.ScaffoldError("TARGET", `Listing exceeds the depth one path may carry at ${path}`, {
1248
- root,
1249
- path,
1250
- limit: 64
1251
- });
1252
- directories.push(path);
1253
- pending.push({
1254
- full: (0, node_path.join)(current.full, entry.name),
1255
- path,
1256
- depth
1257
- });
1258
- }
1259
- });
1260
- (0, _orkestrel_contract.attempt)(() => handle.closeSync());
1261
- if (!walked.success) throw walked.error;
1262
- }
1263
- return directories.sort();
1227
+ return {
1228
+ manifest,
1229
+ bytes
1230
+ };
1264
1231
  }
1265
1232
  /**
1266
- * Lists the canon paths a target holds, filtered to a plan's groups.
1233
+ * Derives one vendored-host manifest entry from a file in a checkout.
1267
1234
  *
1268
- * @param target - The target directory to inspect.
1269
- * @param groups - The artifact groups the plan covers; a held path whose group
1270
- * is outside them is not listed.
1271
- * @returns Every held canon path as a `/`-separated target-relative path, a
1272
- * directory member expanded to the files beneath it, and `[]` when the target
1273
- * holds none or cannot be resolved.
1274
- * @throws `ScaffoldError('TARGET', …)` when a held canon directory cannot be
1275
- * inventoried; {@link listFiles} states each refusal.
1235
+ * @param destination - The target-relative path the file is written to.
1236
+ * @param source - The resolved host path the bytes are read from.
1237
+ * @returns The entry, or `undefined` when `source` is not a physical file this
1238
+ * package will vendor or carries more bytes than one artifact may.
1276
1239
  *
1277
1240
  * @remarks
1278
- * A release stages the canon for reading rather than for a target, so a copy
1279
- * sitting in one is an artifact of a release that vendored it, and reading it
1280
- * here is what lets the deletion verb take it.
1241
+ * The one place the declared fields are decided together, because they are
1242
+ * readings of one path: {@link pathToStorage} decides where it is stored,
1243
+ * the destination is the path it answers for, and {@link matchesExecutablePath}
1244
+ * decides whether a target receives it executable.
1281
1245
  *
1282
- * A directory member is read by file, which is what pairs the paths a plan
1283
- * claims inside the canon with their artifacts and leaves only the rest foreign,
1284
- * with no case for a planned file. The plan's own selection gates the reading,
1285
- * the same rule that decides a vendored path's group, so a scoped audit reports
1286
- * nothing outside its groups. A member that cannot resolve inside the target is
1287
- * not held by it.
1246
+ * The bit is read from that declaration rather than from the source's mode, so
1247
+ * the entry does not depend on where the package was staged. A Windows host
1248
+ * reports no executable bit at all, and reading the mode there declared every
1249
+ * entry non-executable and shipped consumers hooks they could not run.
1288
1250
  *
1289
1251
  * @example
1290
1252
  * ```ts
1291
- * import { listCanonPaths } from '@orkestrel/scaffold/server'
1253
+ * import { readManifestEntry } from '@orkestrel/scaffold/server'
1292
1254
  *
1293
- * listCanonPaths('vacant', ['orchestration']) // []
1255
+ * readManifestEntry('.gitignore', '/tmp/checkout/.gitignore')
1256
+ * // { storage: 'dotfiles/gitignore', destination: '.gitignore', executable: false, digest: '...' }
1294
1257
  * ```
1295
1258
  */
1296
- function listCanonPaths(target, groups) {
1297
- const held = [];
1298
- for (const member of _src_core.CANON_PATHS) {
1299
- const full = resolveContainedPath(target, member);
1300
- if (full === void 0) continue;
1301
- if (isPhysicalFile(full)) held.push(member);
1302
- else if (isPhysicalDirectory(full)) for (const name of listFiles(full)) held.push(`${member}/${name}`);
1303
- }
1304
- return held.filter((path) => groups.includes((0, _src_core.inferGroup)(path)));
1259
+ function readManifestEntry(destination, source) {
1260
+ const digest = computeFileDigest(source);
1261
+ if (digest === void 0) return void 0;
1262
+ return {
1263
+ storage: pathToStorage(destination),
1264
+ destination,
1265
+ executable: matchesExecutablePath(destination),
1266
+ digest
1267
+ };
1305
1268
  }
1306
1269
  /**
1307
- * Removes every directory one set of deletions emptied.
1270
+ * Assembles a whole vendored host from live files and the installed floor.
1308
1271
  *
1309
- * @param target - The directory the deleted paths are relative to.
1310
- * @param removed - The `/`-separated target-relative paths the deletion took.
1311
- * @returns Every removed directory as a target-relative path, in the order they
1312
- * were taken, and `[]` when the target is absent or nothing it holds was
1313
- * emptied.
1272
+ * @param files - The host-owned vendored files read from the repository, one
1273
+ * row per path.
1274
+ * @param floor - The installed host floor, which fixes the membership a fill
1275
+ * may draw from and supplies the bytes owned by another surface.
1276
+ * @returns The assembled host, or `undefined` when any row produced no answer or
1277
+ * names a path the floor does not declare, or when a host-owned path is absent.
1314
1278
  *
1315
1279
  * @remarks
1316
- * A deletion that takes the last file out of a directory leaves the directory
1317
- * standing, and git records no directory, so a swept target keeps the shape of a
1318
- * set it no longer holds while every reading of it reports clean. Only an
1319
- * ancestor of a path this deletion took is a candidate, so nothing the deletion
1320
- * did not reach is inspected, and the target itself is never a candidate.
1280
+ * The one place the host-owned all-or-nothing rule is decided, so no verb
1281
+ * restates it. The host surface contributes one baseline: a fill carries live
1282
+ * bytes for every path that surface writes, or it is nothing. A row that failed,
1283
+ * went missing, names an undeclared path, or leaves a host-owned path absent
1284
+ * answers `undefined`. Every {@link isFloorPath} destination keeps the installed
1285
+ * floor's bytes instead, claimed or not, so a fill carrying no row for one is
1286
+ * complete rather than spoiled. One `Host` can therefore carry live host bytes
1287
+ * beside floor bytes without mixing baselines within a surface.
1321
1288
  *
1322
- * Candidates are taken deepest first, which is what lets a whole chain go: the
1323
- * directory holding nothing but the emptied directory is empty in turn by the
1324
- * time it is read. Each one is resolved through the containment law and left
1325
- * standing when it escapes the target, is not a physical directory, still holds
1326
- * an entry, or refuses removal, and a directory left standing is absent from the
1327
- * answer.
1289
+ * The emitted entries keep the release's own order and its storage and
1290
+ * executable declarations, and carry digests recomputed over the bytes the fill
1291
+ * actually holds. That is what lets a reader verify the value against itself,
1292
+ * and it is why an undeclared path is refused rather than added: membership
1293
+ * moves with a release, never with a fetch.
1328
1294
  *
1329
1295
  * @example
1330
1296
  * ```ts
1331
- * import { pruneEmptiedDirectories } from '@orkestrel/scaffold/server'
1297
+ * import { filesToHost } from '@orkestrel/scaffold/server'
1332
1298
  *
1333
- * pruneEmptiedDirectories('vacant', ['notes/entry.md']) // []
1299
+ * // A floor declaring the host-owned `scripts/codex.sh` path and the canon
1300
+ * // `AGENTS.md` destination. The script's live bytes are taken; the canon
1301
+ * // destination keeps the floor's.
1302
+ * filesToHost([{ path: 'scripts/codex.sh', lookup: 'found', hex: '23212f62696e2f73680a' }], floor)
1303
+ * // { manifest: { entries: [ … ], roots: [ … ], digest: '…' },
1304
+ * // bytes: { 'scripts/codex.sh': '23212f62696e2f73680a', 'AGENTS.md': floor.bytes['AGENTS.md'] } }
1334
1305
  * ```
1335
1306
  */
1336
- function pruneEmptiedDirectories(target, removed) {
1337
- const candidates = /* @__PURE__ */ new Set();
1338
- for (const path of removed) {
1339
- let parent = (0, node_path.dirname)(path);
1340
- while (parent !== "." && parent !== (0, node_path.dirname)(parent) && !candidates.has(parent)) {
1341
- candidates.add(parent);
1342
- parent = (0, node_path.dirname)(parent);
1343
- }
1307
+ function filesToHost(files, floor) {
1308
+ const declared = new Set(floor.manifest.entries.map((entry) => entry.destination));
1309
+ const held = /* @__PURE__ */ new Map();
1310
+ for (const file of files) {
1311
+ if (file.lookup !== "found" || !declared.has(file.path)) return void 0;
1312
+ if (!(0, _src_core.isFloorPath)(file.path)) held.set(file.path, file.hex);
1344
1313
  }
1345
- const ordered = [...candidates].sort((left, right) => (0, _orkestrel_contract.compareValues)(right.split("/").length, left.split("/").length) || (0, _orkestrel_contract.compareValues)(left, right));
1346
- const pruned = [];
1347
- for (const candidate of ordered) {
1348
- const directory = resolveContainedPath(target, candidate);
1349
- if (directory === void 0 || !isPhysicalDirectory(directory)) continue;
1350
- const entries = (0, _orkestrel_contract.attempt)(() => (0, node_fs.readdirSync)(directory));
1351
- if (!entries.success || entries.value.length > 0) continue;
1352
- if ((0, _orkestrel_contract.attempt)(() => (0, node_fs.rmdirSync)(directory)).success) pruned.push(candidate);
1314
+ const entries = [];
1315
+ const bytes = {};
1316
+ for (const entry of floor.manifest.entries) {
1317
+ const hex = (0, _src_core.isFloorPath)(entry.destination) ? floor.bytes[entry.destination] : held.get(entry.destination);
1318
+ if (hex === void 0) return void 0;
1319
+ entries.push({
1320
+ storage: entry.storage,
1321
+ destination: entry.destination,
1322
+ executable: entry.executable,
1323
+ digest: hexToDigest(hex)
1324
+ });
1325
+ bytes[entry.destination] = hex;
1353
1326
  }
1354
- return pruned;
1327
+ return {
1328
+ manifest: {
1329
+ entries,
1330
+ roots: floor.manifest.roots,
1331
+ surface: floor.manifest.surface,
1332
+ digest: computeManifestDigest(entries, floor.manifest.roots, floor.manifest.surface)
1333
+ },
1334
+ bytes
1335
+ };
1355
1336
  }
1356
1337
  /**
1357
- * Reads one contained file as its exact bytes in lowercase hexadecimal.
1338
+ * Stages the named destinations of a value host into a private root.
1358
1339
  *
1359
- * @param root - The containing host directory.
1360
- * @param path - The portable root-relative file path.
1361
- * @param limit - The most bytes this read accepts; the artifact ceiling by default.
1362
- * @returns The exact bytes as hexadecimal, or `undefined` when the file is
1363
- * absent, is not a physical readable file, is past `limit`, or moved while it
1364
- * was being read.
1365
- * @throws `ScaffoldError('INVALID', …)` when the arguments are off contract or
1366
- * `path` leaves `root`.
1340
+ * @param host - The host whose bytes are written, keyed by destination.
1341
+ * @param root - The private directory to fill; it must already be a directory
1342
+ * this process may write into.
1343
+ * @param destinations - The destinations to stage, each declared by `host`.
1344
+ * @returns The entry staged for each destination, in the order requested.
1345
+ * @throws `ScaffoldError('INVALID', …)` when `root` is not a host path or a
1346
+ * storage name leaves it.
1347
+ * @throws `ScaffoldError('TARGET', …)` when a destination is one the host does
1348
+ * not declare, carries no bytes, or carries bytes that miss its declared digest.
1349
+ * @throws `ScaffoldError('WRITE', …)` when a file cannot be written or does not
1350
+ * read back as the bytes it was given.
1367
1351
  *
1368
1352
  * @remarks
1369
- * Hexadecimal rather than text, because this is what a byte comparison is stated
1370
- * in everywhere in this package: a plan's artifact, an audit finding, and a
1371
- * snapshot all compare as the same digits. The file's identity and size are
1372
- * measured before and after the read, and one extra byte is requested past the
1373
- * declared size, so a file that grew or was replaced mid-read answers
1374
- * `undefined` rather than half of one file and half of another.
1353
+ * Each file lands under the storage name the manifest declares and takes the
1354
+ * executable bit that manifest records, so a root filled from a value is the
1355
+ * same shape as one staged from a checkout and a reader cannot tell them apart.
1356
+ * That is what lets a mutation copy real files with real modes from bytes a
1357
+ * caller supplied, instead of degrading them to plain text writes.
1358
+ *
1359
+ * The bytes are digested before the write and the staged file after it, so a
1360
+ * value that disagrees with its own manifest is told apart from a write that
1361
+ * did not land.
1375
1362
  *
1376
1363
  * @example
1377
1364
  * ```ts
1378
- * import { readFileHex } from '@orkestrel/scaffold/server'
1365
+ * import { stageBytes } from '@orkestrel/scaffold/server'
1379
1366
  *
1380
- * readFileHex('/tmp/project', 'AGENTS.md') // '2320416765…'
1367
+ * stageBytes(host, '/tmp/orkestrel-host-a1b2', ['scripts/codex.sh'])
1368
+ * // [{ storage: 'scripts/codex.sh', destination: 'scripts/codex.sh', executable: true, digest: '…' }]
1381
1369
  * ```
1382
1370
  */
1383
- function readFileHex(root, path, limit = _src_core.MAX_ARTIFACT_BYTES) {
1384
- if (!Number.isSafeInteger(limit) || limit < 0 || limit > _src_core.MAX_ARTIFACT_BYTES) throw new _src_core.ScaffoldError("INVALID", `Byte limit is outside the artifact ceiling at ${path}`, {
1385
- root,
1386
- path,
1387
- limit
1388
- });
1389
- const full = resolveContainedPath(root, path);
1390
- if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Path is off contract or leaves its root at ${path}`, {
1391
- root,
1392
- path
1393
- });
1394
- if (!isPhysicalFile(full)) return void 0;
1395
- const opened = (0, _orkestrel_contract.attempt)(() => (0, node_fs.openSync)(full, "r"));
1396
- if (!opened.success) return void 0;
1397
- const handle = opened.value;
1398
- const read = (0, _orkestrel_contract.attempt)(() => {
1399
- const before = (0, node_fs.fstatSync)(handle);
1400
- if (!before.isFile() || before.nlink !== 1 || before.size > limit) return void 0;
1401
- const bytes = Buffer.alloc(before.size);
1402
- let offset = 0;
1403
- while (offset < bytes.byteLength) {
1404
- const length = (0, node_fs.readSync)(handle, bytes, offset, bytes.byteLength - offset, offset);
1405
- if (length === 0) break;
1406
- offset += length;
1407
- }
1408
- const overflow = (0, node_fs.readSync)(handle, Buffer.alloc(1), 0, 1, offset);
1409
- const after = (0, node_fs.fstatSync)(handle);
1410
- if (offset !== bytes.byteLength || overflow !== 0 || after.size !== before.size || after.mtimeMs !== before.mtimeMs) return;
1411
- return (0, _src_core.bytesToHex)(bytes);
1412
- });
1413
- (0, _orkestrel_contract.attempt)(() => (0, node_fs.closeSync)(handle));
1414
- return read.success ? read.value : void 0;
1371
+ function stageBytes(host, root, destinations) {
1372
+ if (!isFilesystemPath(root)) throw new _src_core.ScaffoldError("INVALID", "Staging host root is not a host path", { host: root });
1373
+ const declared = new Map(host.manifest.entries.map((entry) => [entry.destination, entry]));
1374
+ const staged = [];
1375
+ for (const destination of destinations) {
1376
+ const entry = declared.get(destination);
1377
+ const hex = host.bytes[destination];
1378
+ if (entry === void 0 || hex === void 0) throw new _src_core.ScaffoldError("TARGET", `The host carries no bytes for ${destination}`, {
1379
+ host: root,
1380
+ destination
1381
+ });
1382
+ if (hexToDigest(hex) !== entry.digest) throw new _src_core.ScaffoldError("TARGET", `The host bytes for ${destination} miss its digest`, {
1383
+ host: root,
1384
+ destination
1385
+ });
1386
+ const full = resolveContainedPath(root, entry.storage);
1387
+ if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Host storage leaves its root at ${entry.storage}`, {
1388
+ host: root,
1389
+ storage: entry.storage
1390
+ });
1391
+ const written = (0, _orkestrel_contract.attempt)(() => {
1392
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(full), { recursive: true });
1393
+ (0, node_fs.writeFileSync)(full, Buffer.from(hex, "hex"), { flag: "wx" });
1394
+ if (entry.executable) (0, node_fs.chmodSync)(full, 493);
1395
+ });
1396
+ if (!written.success) throw new _src_core.ScaffoldError("WRITE", `Host bytes could not be staged at ${entry.storage}`, {
1397
+ host: root,
1398
+ storage: entry.storage,
1399
+ error: written.error
1400
+ });
1401
+ if (computeFileDigest(full) !== entry.digest) throw new _src_core.ScaffoldError("WRITE", `Staged host bytes at ${entry.storage} did not read back`, {
1402
+ host: root,
1403
+ storage: entry.storage
1404
+ });
1405
+ staged.push(entry);
1406
+ }
1407
+ return staged;
1415
1408
  }
1416
1409
  /**
1417
- * Reads one contained file as bounded UTF-8 text.
1410
+ * Stages a vendored host root from a real checkout.
1418
1411
  *
1419
- * @param root - The containing host directory.
1420
- * @param path - The portable root-relative file path.
1421
- * @param limit - The most bytes this read accepts; the artifact ceiling by default.
1422
- * @returns The decoded text, or `undefined` when {@link readFileHex} answers
1423
- * nothing or the bytes are not valid UTF-8.
1424
- * @throws `ScaffoldError('INVALID', …)` when the arguments are off contract or
1425
- * `path` leaves `root`.
1412
+ * @param checkout - The checkout the vendored paths are read from.
1413
+ * @param host - The vendored host root to fill; it must be absent or empty.
1414
+ * @param options - The inventory selection, establishment switch, and reporting callback.
1415
+ * Default: no reporting.
1416
+ * @returns One entry per staged file, sorted by storage name.
1417
+ * @throws `ScaffoldError('INVALID', …)` when either argument is not a host path
1418
+ * or a vendored path leaves the checkout or the host root.
1419
+ * @throws `ScaffoldError('TARGET', …)` when the checkout is not a directory, the
1420
+ * host root is not vacant, the checkout does not carry every vendored path, two
1421
+ * vendored files claim one storage name, a vendored file is not a plain file
1422
+ * within the artifact ceiling, published guides are unreadable, a staged
1423
+ * collision's owner set is not a subset of its published owner set, the inventory is absent without
1424
+ * `establish: true`, or the staged manifest does not read back.
1425
+ * @throws `ScaffoldError('WRITE', …)` when a staged file or the manifest cannot
1426
+ * be written.
1426
1427
  *
1427
1428
  * @remarks
1428
- * Decoding is strict, so a file carrying an invalid sequence answers `undefined`
1429
- * rather than text carrying replacement characters. That matters because the
1430
- * text is parsed next: a manifest silently repaired into valid JSON by lossy
1431
- * decoding would be trusted.
1429
+ * This is the producer half of the vendored host, and it is not the mutation
1430
+ * contract `MaterializerInterface` states. That contract owns **target**
1431
+ * writes: it materializes a compiled plan into a consumer's workspace, binds
1432
+ * every destination to what the caller observed, and rolls a failed commit back.
1433
+ * This reads this package's own checkout at build time and fills its own build
1434
+ * output. Different direction, different lifetime, no consumer target involved,
1435
+ * so they do not overlap and neither one belongs inside the other.
1432
1436
  *
1433
- * @example
1434
- * ```ts
1435
- * import { readFileText } from '@orkestrel/scaffold/server'
1437
+ * Staging is plain rather than transactional for the same reason. A
1438
+ * `WriteTransaction` exists to hold a directory that already holds work
1439
+ * still; a build output holds nothing, is deleted whole before every build, and
1440
+ * has no concurrent reader. What replaces it is refusing early and ordering the
1441
+ * writes: the whole membership is derived before anything is created, so a
1442
+ * membership refusal leaves no host root at all, and `manifest.json` is
1443
+ * written last, so a copy or Surface refusal leaves a root every
1444
+ * reader treats as a raw checkout and fails loudly on.
1436
1445
  *
1437
- * readFileText('/tmp/project', 'package.json') // '{ "name": "@orkestrel/router", … }'
1438
- * ```
1439
- */
1440
- function readFileText(root, path, limit = _src_core.MAX_ARTIFACT_BYTES) {
1441
- const hex = readFileHex(root, path, limit);
1442
- if (hex === void 0) return void 0;
1443
- const decoded = (0, _orkestrel_contract.attempt)(() => new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(hex, "hex")));
1444
- return decoded.success ? decoded.value : void 0;
1445
- }
1446
- /**
1447
- * Reads a target's current bytes at the paths a plan claims.
1446
+ * A missing vendored path is refused rather than staged around. A partial root
1447
+ * is not detectably partial: it fails later, in a consumer's terminal, on
1448
+ * whichever path the plan reached first. Refusing here fails the build that
1449
+ * produced it, where the maintainer can act, and it names every missing path at
1450
+ * once. A directory is the same case — declaring an absent directory as an empty
1451
+ * root would create an empty directory in every generated workspace.
1448
1452
  *
1449
- * @param target - The target directory to read.
1450
- * @param paths - The plan-relative paths to probe.
1451
- * @returns One entry per path that is there: a file maps to its exact bytes as
1452
- * hexadecimal and a directory maps to `''`, which records presence with no bytes
1453
- * to compare. An absent path is omitted.
1454
- * @throws `ScaffoldError('INVALID', …)` when `target` is not a host path or
1455
- * `paths` is not a bounded list of plannable paths.
1456
- * @throws `ScaffoldError('TARGET', …)` when a path that is there cannot be read,
1457
- * or when the whole read would retain more bytes than one plan may.
1453
+ * The walk covers `HOST_PATHS`, `CANON_PATHS`, and `REFERENCE_PATHS` together.
1454
+ * A plan selects the paths a target receives; the installed package also carries
1455
+ * the canon and reference files for reading. Every catalog package must have a
1456
+ * guide in the discovered membership before the host is written.
1457
+ * Copied guides must carry no collision whose name and exact owner set the
1458
+ * committed inventory lacks. An absent inventory requires `establish: true`;
1459
+ * an inventory without a valid recorded Surface is refused.
1460
+ * Everything downstream — the missing-path refusal, the
1461
+ * storage collision guard, the sort, the digests, and the root inventory — reads
1462
+ * the union, so every staged path follows the same law.
1458
1463
  *
1459
- * @remarks
1460
- * The one door from a real directory into the vocabulary an audit compares in.
1461
- * Absence is omission rather than an empty value, because core reads a missing
1462
- * key as a missing destination and an empty string as a present directory;
1463
- * they are different verdicts. A path that is there but unreadable throws instead
1464
- * of being omitted, because omission would report it as missing and a repair
1465
- * would then overwrite whatever is actually sitting there.
1464
+ * The vendoring deny-list applies to what the walk discovers beneath a staged
1465
+ * directory, where a maintainer's local credential can legitimately sit, and
1466
+ * such a path is skipped. A path a staging list names itself is curated data
1467
+ * rather than discovery, so it is staged or the stage is refused.
1466
1468
  *
1467
- * @example
1469
+ * @example Vendored data root
1468
1470
  * ```ts
1469
- * import { readSnapshot } from '@orkestrel/scaffold/server'
1471
+ * import { stageHost } from '@orkestrel/scaffold/server'
1470
1472
  *
1471
- * readSnapshot('./packages/router', ['package.json', 'guides'])
1472
- * // { 'package.json': '7b226e…', guides: '' }
1473
+ * stageHost(process.cwd(), 'dist/host') // one ManifestEntry per file staged
1473
1474
  * ```
1474
1475
  */
1475
- function readSnapshot(target, paths) {
1476
- if (!isFilesystemPath(target)) throw new _src_core.ScaffoldError("INVALID", "Snapshot target is not a host path", { target });
1477
- if (!(0, _src_core.isCollection)(paths) || !paths.every((path) => (0, _src_core.isPath)(path))) throw new _src_core.ScaffoldError("INVALID", "Snapshot paths are not a bounded list of plannable paths", {
1478
- target,
1479
- limit: _src_core.MAX_COLLECTION_ITEMS
1480
- });
1481
- let remaining = _src_core.MAX_TOTAL_ARTIFACT_BYTES;
1482
- const snapshot = {};
1483
- for (const path of paths) {
1484
- const full = resolveContainedPath(target, path);
1485
- if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Snapshot path leaves its target at ${path}`, {
1486
- target,
1476
+ function stageHost(checkout, host, options) {
1477
+ if (!isFilesystemPath(checkout)) throw new _src_core.ScaffoldError("INVALID", "Staging checkout is not a host path", { checkout });
1478
+ if (!isFilesystemPath(host)) throw new _src_core.ScaffoldError("INVALID", "Staging host root is not a host path", { host });
1479
+ const source = (0, node_path.resolve)(checkout);
1480
+ if (!isPhysicalDirectory(source)) throw new _src_core.ScaffoldError("TARGET", `Staging checkout is not a physical directory at ${source}`, { checkout: source });
1481
+ if (!isVacant(host)) throw new _src_core.ScaffoldError("TARGET", `Staging host root is not vacant at ${host}`, { host });
1482
+ const vendored = [];
1483
+ const roots = [];
1484
+ const missing = [];
1485
+ for (const path of [
1486
+ ..._src_core.HOST_PATHS,
1487
+ ..._src_core.CANON_PATHS,
1488
+ ..._src_core.REFERENCE_PATHS
1489
+ ]) {
1490
+ const full = resolveContainedPath(source, path);
1491
+ if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Vendored path leaves its checkout at ${path}`, {
1492
+ checkout: source,
1487
1493
  path
1488
1494
  });
1489
- const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(full));
1490
- if (!status.success) {
1491
- if (matchesMissingPath(status.error)) continue;
1492
- throw new _src_core.ScaffoldError("TARGET", `Snapshot path cannot be inspected at ${path}`, {
1493
- target,
1494
- path,
1495
- error: status.error
1496
- });
1495
+ if (isPhysicalFile(full)) {
1496
+ vendored.push(path);
1497
+ continue;
1497
1498
  }
1498
- if (isPhysicalDirectory(full)) {
1499
- snapshot[path] = "";
1499
+ if (!isPhysicalDirectory(full)) {
1500
+ missing.push(path);
1500
1501
  continue;
1501
1502
  }
1502
- const hex = readFileHex(target, path, Math.min(_src_core.MAX_ARTIFACT_BYTES, remaining));
1503
- if (hex === void 0) throw new _src_core.ScaffoldError("TARGET", `Snapshot path is not a readable file at ${path}`, {
1504
- target,
1505
- path,
1506
- limit: _src_core.MAX_TOTAL_ARTIFACT_BYTES
1503
+ roots.push(path);
1504
+ for (const nested of listDirectories(full)) {
1505
+ const rooted = `${path}/${nested}`;
1506
+ if (!matchesSensitivePath(rooted)) roots.push(rooted);
1507
+ }
1508
+ for (const name of listFiles(full)) {
1509
+ const destination = `${path}/${name}`;
1510
+ if (!matchesSensitivePath(destination)) vendored.push(destination);
1511
+ }
1512
+ }
1513
+ if (missing.length > 0) throw new _src_core.ScaffoldError("TARGET", "The checkout does not carry every vendored path", {
1514
+ checkout: source,
1515
+ missing
1516
+ });
1517
+ const catalog = readFileText(source, _src_core.CATALOG_AGENT_PATH);
1518
+ if (catalog === void 0) throw new _src_core.ScaffoldError("TARGET", `The checkout carries no catalog at ${_src_core.CATALOG_AGENT_PATH}`, { checkout: source });
1519
+ const guides = new Set(vendored);
1520
+ for (const table of (0, _orkestrel_markdown.createMarkdown)(catalog).filter(_orkestrel_markdown.isTableNode)) for (const row of table.rows) {
1521
+ const [cell] = row;
1522
+ if (cell === void 0) continue;
1523
+ const name = cell.map(_orkestrel_markdown.flattenText).join("").trim();
1524
+ if (!_src_core.DEPENDENCY_NAME_PATTERN.test(name)) continue;
1525
+ const path = (0, _src_core.nameToGuide)(name);
1526
+ if (!guides.has(path)) throw new _src_core.ScaffoldError("TARGET", `Catalog package ${name} has no staged guide at ${path}`, {
1527
+ checkout: source,
1528
+ name,
1529
+ path
1507
1530
  });
1508
- remaining -= hex.length / 2;
1509
- snapshot[path] = hex;
1510
1531
  }
1511
- return snapshot;
1512
- }
1513
- /**
1514
- * Reads a vendored host's manifest, when it carries one.
1515
- *
1516
- * @param host - The vendored host root to read.
1517
- * @param name - The root-relative manifest path. Default: `manifest.json`.
1518
- * @returns The manifest, or `undefined` when the host carries none.
1519
- * @throws `ScaffoldError('INVALID', …)` when `host` is not a host path.
1520
- * @throws `ScaffoldError('TARGET', …)` when the manifest is there but cannot be
1521
- * read, is not the declared shape, or does not match its own membership.
1522
- *
1523
- * @remarks
1524
- * The failures are held apart deliberately. A host with no manifest is a
1525
- * raw checkout, and a caller reads it by mapping each path one to one. A host
1526
- * with a manifest that does not verify is a staged host that has been edited,
1527
- * and answering `undefined` there would degrade it to that same one-to-one
1528
- * mapping — which is how an edited manifest would get a caller to read files it
1529
- * never declared. So absence answers and corruption throws.
1530
- *
1531
- * Verification here is the manifest's own self-consistency: the digest against
1532
- * the exact membership beside it. Whether that membership matches the files
1533
- * actually stored is a separate question, and it belongs to the reader that
1534
- * walks the host.
1535
- *
1536
- * @example
1537
- * ```ts
1538
- * import { readHostManifest } from '@orkestrel/scaffold/server'
1539
- *
1540
- * readHostManifest('./dist/host') // the manifest, or undefined for a raw root
1541
- * ```
1542
- */
1543
- function readHostManifest(host, name = MANIFEST_NAME) {
1544
- if (!isFilesystemPath(host)) throw new _src_core.ScaffoldError("INVALID", "Host root is not a host path", { host });
1545
- const full = resolveContainedPath(host, name);
1546
- if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Host manifest leaves its root at ${host}`, { host });
1547
- const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(full));
1548
- if (!status.success) {
1549
- if (matchesMissingPath(status.error)) return void 0;
1550
- throw new _src_core.ScaffoldError("TARGET", `Host manifest cannot be inspected at ${full}`, {
1551
- host,
1552
- error: status.error
1532
+ const stored = /* @__PURE__ */ new Set([MANIFEST_NAME]);
1533
+ const candidates = [];
1534
+ for (const destination of vendored) {
1535
+ const full = resolveContainedPath(source, destination);
1536
+ if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Vendored path leaves its checkout at ${destination}`, {
1537
+ checkout: source,
1538
+ path: destination
1553
1539
  });
1540
+ const entry = readManifestEntry(destination, full);
1541
+ if (entry === void 0) throw new _src_core.ScaffoldError("TARGET", `Vendored path is not a plain file within the artifact ceiling at ${destination}`, {
1542
+ checkout: source,
1543
+ path: destination,
1544
+ limit: _src_core.MAX_ARTIFACT_BYTES
1545
+ });
1546
+ if (stored.has(entry.storage)) throw new _src_core.ScaffoldError("TARGET", `Two vendored paths claim the storage name ${entry.storage}`, {
1547
+ checkout: source,
1548
+ path: destination,
1549
+ storage: entry.storage
1550
+ });
1551
+ stored.add(entry.storage);
1552
+ candidates.push(entry);
1554
1553
  }
1555
- const text = readFileText(host, name, _src_core.MAX_MANIFEST_BYTES);
1556
- if (text === void 0) throw new _src_core.ScaffoldError("TARGET", `Host manifest is not readable text at ${full}`, { host });
1557
- const manifest = (0, _orkestrel_contract.parseJSONAs)(text, isHostManifest);
1558
- if (manifest === void 0) throw new _src_core.ScaffoldError("TARGET", `Host manifest is malformed at ${full}`, { host });
1559
- if (manifest.digest !== computeManifestDigest(manifest.entries, manifest.roots)) throw new _src_core.ScaffoldError("TARGET", `Host manifest membership is corrupted at ${full}`, { host });
1560
- return manifest;
1554
+ candidates.sort((first, second) => first.storage < second.storage ? -1 : 1);
1555
+ roots.sort();
1556
+ const baseline = resolveContainedPath(source, options?.inventory ?? _src_core.HOST_INVENTORY_PATH);
1557
+ if (baseline === void 0) throw new _src_core.ScaffoldError("INVALID", "Surface inventory leaves its checkout", {
1558
+ checkout: source,
1559
+ inventory: options?.inventory
1560
+ });
1561
+ const recorded = readSurfaceBaseline(source, options?.inventory);
1562
+ if (recorded === void 0 && options?.establish !== true) throw new _src_core.ScaffoldError("TARGET", `Surface inventory is absent at ${baseline}; set establish: true to establish the baseline`, {
1563
+ checkout: source,
1564
+ inventory: baseline
1565
+ });
1566
+ const published = recorded === void 0 ? void 0 : new Map(recorded.map(({ name, owners }) => [name, owners]));
1567
+ const message = published === void 0 ? `Inventory Surface baseline absent at ${baseline}; this stage establishes the baseline` : `Inventory Surface baseline: ${baseline}`;
1568
+ options?.report?.(message);
1569
+ const root = (0, node_path.resolve)(host);
1570
+ const established = (0, _orkestrel_contract.attempt)(() => (0, node_fs.mkdirSync)(root, { recursive: true }));
1571
+ if (!established.success || !isPhysicalDirectory(root)) throw new _src_core.ScaffoldError("WRITE", `Staging host root could not be established at ${root}`, {
1572
+ host: root,
1573
+ ...established.success ? {} : { error: established.error }
1574
+ });
1575
+ const entries = [];
1576
+ for (const entry of candidates) {
1577
+ const origin = resolveContainedPath(source, entry.destination);
1578
+ const destination = resolveContainedPath(root, entry.storage);
1579
+ if (origin === void 0 || destination === void 0) throw new _src_core.ScaffoldError("INVALID", `Vendored path leaves its root at ${entry.destination}`, {
1580
+ checkout: source,
1581
+ host: root,
1582
+ path: entry.destination,
1583
+ storage: entry.storage
1584
+ });
1585
+ const copied = (0, _orkestrel_contract.attempt)(() => {
1586
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(destination), { recursive: true });
1587
+ (0, node_fs.copyFileSync)(origin, destination, node_fs.constants.COPYFILE_EXCL);
1588
+ if (entry.executable) (0, node_fs.chmodSync)(destination, 493);
1589
+ });
1590
+ if (!copied.success) throw new _src_core.ScaffoldError("WRITE", `Vendored file could not be staged at ${entry.storage}`, {
1591
+ host: root,
1592
+ storage: entry.storage,
1593
+ error: copied.error
1594
+ });
1595
+ const digest = computeFileDigest(destination);
1596
+ if (digest === void 0) throw new _src_core.ScaffoldError("WRITE", `Vendored file could not be verified at ${entry.storage}`, {
1597
+ host: root,
1598
+ storage: entry.storage
1599
+ });
1600
+ entries.push({
1601
+ ...entry,
1602
+ digest
1603
+ });
1604
+ }
1605
+ const collisions = readSurfaceCollisions((0, node_path.join)(root, "guides"));
1606
+ const growth = [];
1607
+ if (published !== void 0) for (const [name, owners] of collisions) {
1608
+ const record = published.get(name);
1609
+ if (!(record !== void 0 && owners.every((owner) => record.includes(owner)))) growth.push(`${name} staged (${owners.join(", ")}), recorded (${record?.join(", ") ?? "absent"})`);
1610
+ }
1611
+ if (growth.length > 0) throw new _src_core.ScaffoldError("TARGET", `Staged Surface collisions differ from the inventory: ${growth.join("; ")}`, {
1612
+ checkout: source,
1613
+ host: root,
1614
+ baseline,
1615
+ collisions: growth
1616
+ });
1617
+ const surface = [...collisions].map(([name, owners]) => ({
1618
+ name,
1619
+ owners
1620
+ }));
1621
+ const manifest = {
1622
+ entries,
1623
+ roots,
1624
+ surface,
1625
+ digest: computeManifestDigest(entries, roots, surface)
1626
+ };
1627
+ const metadata = resolveContainedPath(root, MANIFEST_NAME);
1628
+ if (metadata === void 0) throw new _src_core.ScaffoldError("INVALID", `Host manifest leaves its root at ${root}`, { host: root });
1629
+ const written = (0, _orkestrel_contract.attempt)(() => (0, node_fs.writeFileSync)(metadata, `${JSON.stringify(manifest, null, " ")}\n`, {
1630
+ encoding: "utf8",
1631
+ flag: "wx"
1632
+ }));
1633
+ if (!written.success) throw new _src_core.ScaffoldError("WRITE", `Host manifest could not be staged at ${metadata}`, {
1634
+ host: root,
1635
+ error: written.error
1636
+ });
1637
+ const verified = readHostManifest(root);
1638
+ if (verified === void 0 || verified.digest !== manifest.digest) throw new _src_core.ScaffoldError("TARGET", `The staged host manifest does not read back at ${root}`, { host: root });
1639
+ return entries;
1561
1640
  }
1562
1641
  /**
1563
- * Reads the installed vendored host floor as a value.
1642
+ * Reads bare Surface names claimed by distinct package guides.
1564
1643
  *
1565
- * @param root - The vendored host root. Default: the installed package's
1566
- * vendored root, resolved from this module's location.
1567
- * @returns The verified manifest and the exact bytes of every declared entry.
1568
- * @throws `ScaffoldError('TARGET', …)` when the root is not a readable physical
1569
- * directory, its manifest is absent or unreadable, the manifest does not verify,
1570
- * or a declared file is unreadable or misses its digest.
1644
+ * @param root - The physical directory containing the package guides.
1645
+ * @returns Colliding names and their distinct owners, sorted by name and owner.
1646
+ * @throws `ScaffoldError('TARGET', …)` when the directory or a guide cannot be read.
1647
+ * @throws `ScaffoldError('TARGET', …)` when `@orkestrel/guide` cannot be loaded.
1571
1648
  *
1572
1649
  * @remarks
1573
- * Reads the same default floor the {@link Materializer} uses. Each declared
1574
- * file is addressed through the manifest's storage name and retained under its
1575
- * destination, so the returned value has the same shape as the installed root.
1576
- * When this module executes from TypeScript source, the committed inventory is
1577
- * the manifest and each checkout destination supplies its bytes. The emitted
1578
- * module reads the staged `manifest.json` file and each storage path instead.
1650
+ * Reads immediate `.md` files except `README.md` through `createGuide().surface()`.
1651
+ * Requires `@orkestrel/guide` in this module's resolution path; Scaffold declares it
1652
+ * only for development. Loads it only when called, so importing the server entry
1653
+ * requires no guide tooling in a production install.
1579
1654
  *
1580
1655
  * @example
1581
1656
  * ```ts
1582
- * import { readHostFloor } from '@orkestrel/scaffold/server'
1657
+ * import { readSurfaceCollisions } from '@orkestrel/scaffold/server'
1583
1658
  *
1584
- * readHostFloor().manifest // the installed floor's verified membership
1659
+ * readSurfaceCollisions('./guides').get('Shared') // the guides claiming Shared, if it collides
1585
1660
  * ```
1586
1661
  */
1587
- function readHostFloor(root) {
1588
- const location = (0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href);
1589
- const module = (0, node_path.dirname)(location);
1590
- const source = root === void 0 && (0, node_path.extname)(location) === ".ts";
1591
- const host = root ?? (0, node_path.resolve)(module, source ? "../.." : "../../host");
1592
- if (!isPhysicalDirectory(host)) throw new _src_core.ScaffoldError("TARGET", `The vendored host root is not readable at ${host}`, { host });
1593
- const manifest = readHostManifest(host, source ? _src_core.HOST_INVENTORY_PATH : MANIFEST_NAME);
1594
- if (manifest === void 0) throw new _src_core.ScaffoldError("TARGET", `The vendored host carries no manifest at ${host}`, { host });
1595
- const bytes = {};
1596
- for (const entry of manifest.entries) {
1597
- const path = source ? entry.destination : entry.storage;
1598
- const hex = readFileHex(host, path);
1599
- if (hex === void 0 || hexToDigest(hex) !== entry.digest) throw new _src_core.ScaffoldError("TARGET", `The vendored host cannot read the declared file at ${path}`, {
1600
- host,
1601
- path,
1602
- destination: entry.destination
1662
+ function readSurfaceCollisions(root) {
1663
+ if (!isPhysicalDirectory(root)) throw new _src_core.ScaffoldError("TARGET", `Surface guide directory is not readable at ${root}`, { root });
1664
+ const loaded = (0, _orkestrel_contract.attempt)(() => (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("@orkestrel/guide"));
1665
+ if (!loaded.success) throw new _src_core.ScaffoldError("TARGET", "Surface reflection requires the module @orkestrel/guide", {
1666
+ root,
1667
+ error: loaded.error
1668
+ });
1669
+ const guide = loaded.value;
1670
+ const owners = /* @__PURE__ */ new Map();
1671
+ for (const path of listFiles(root)) {
1672
+ if (path.includes("/") || (0, node_path.extname)(path) !== ".md" || path === "README.md") continue;
1673
+ const text = readFileText(root, path);
1674
+ if (text === void 0) throw new _src_core.ScaffoldError("TARGET", `Surface guide cannot be read at ${path}`, {
1675
+ root,
1676
+ path
1603
1677
  });
1604
- bytes[entry.destination] = hex;
1678
+ for (const symbol of guide.createGuide(text).surface()) {
1679
+ const claimed = owners.get(symbol.name) ?? /* @__PURE__ */ new Set();
1680
+ claimed.add((0, node_path.basename)(path, ".md"));
1681
+ owners.set(symbol.name, claimed);
1682
+ }
1605
1683
  }
1606
- return {
1607
- manifest,
1608
- bytes
1609
- };
1684
+ const collisions = /* @__PURE__ */ new Map();
1685
+ for (const name of [...owners.keys()].sort()) {
1686
+ const claimed = owners.get(name);
1687
+ if (claimed !== void 0 && claimed.size > 1) collisions.set(name, [...claimed].sort());
1688
+ }
1689
+ return collisions;
1610
1690
  }
1611
1691
  /**
1612
- * Derives one vendored-host manifest entry from a file in a checkout.
1613
- *
1614
- * @param destination - The target-relative path the file is written to.
1615
- * @param source - The resolved host path the bytes are read from.
1616
- * @returns The entry, or `undefined` when `source` is not a physical file this
1617
- * package will vendor or carries more bytes than one artifact may.
1618
- *
1619
- * @remarks
1620
- * The one place the declared fields are decided together, because they are
1621
- * readings of one path: {@link pathToStorage} decides where it is stored,
1622
- * the destination is the path it answers for, and {@link matchesExecutablePath}
1623
- * decides whether a target receives it executable.
1692
+ * Reads the Surface collision baseline from a committed inventory.
1624
1693
  *
1625
- * The bit is read from that declaration rather than from the source's mode, so
1626
- * the entry does not depend on where the package was staged. A Windows host
1627
- * reports no executable bit at all, and reading the mode there declared every
1628
- * entry non-executable and shipped consumers hooks they could not run.
1694
+ * @param root - The checkout's host path.
1695
+ * @param name - The checkout-relative inventory path. Default: `host.json`.
1696
+ * @returns The recorded collisions, or `undefined` when the inventory is absent.
1697
+ * @throws `ScaffoldError('INVALID', …)` when `root` is not a host path or `name` leaves it.
1698
+ * @throws `ScaffoldError('TARGET', …)` when an existing inventory is unreadable,
1699
+ * malformed, or inconsistent with its digest.
1629
1700
  *
1630
1701
  * @example
1631
1702
  * ```ts
1632
- * import { readManifestEntry } from '@orkestrel/scaffold/server'
1703
+ * import { readSurfaceBaseline } from '@orkestrel/scaffold/server'
1633
1704
  *
1634
- * readManifestEntry('.gitignore', '/tmp/checkout/.gitignore')
1635
- * // { storage: 'dotfiles/gitignore', destination: '.gitignore', executable: false, digest: '...' }
1705
+ * readSurfaceBaseline('.') // the recorded collisions, if the inventory exists
1636
1706
  * ```
1637
1707
  */
1638
- function readManifestEntry(destination, source) {
1639
- const digest = computeFileDigest(source);
1640
- if (digest === void 0) return void 0;
1641
- return {
1642
- storage: pathToStorage(destination),
1643
- destination,
1644
- executable: matchesExecutablePath(destination),
1645
- digest
1646
- };
1708
+ function readSurfaceBaseline(root, name = _src_core.HOST_INVENTORY_PATH) {
1709
+ return readHostManifest(root, name)?.surface;
1647
1710
  }
1648
1711
  /**
1649
- * Assembles a whole vendored host from live files and the installed floor.
1712
+ * Stages the committed inventory of the files a vendored host carries.
1650
1713
  *
1651
- * @param files - The host-owned vendored files read from the repository, one
1652
- * row per path.
1653
- * @param floor - The installed host floor, which fixes the membership a fill
1654
- * may draw from and supplies the bytes owned by another surface.
1655
- * @returns The assembled host, or `undefined` when any row produced no answer or
1656
- * names a path the floor does not declare, or when a host-owned path is absent.
1714
+ * @param checkout - The checkout whose vendored paths are inventoried.
1715
+ * @param path - The host path where the JSON inventory is written.
1716
+ * @returns The validated manifest written to `path`.
1717
+ * @throws `ScaffoldError('INVALID', …)` when `path` is not a host path.
1718
+ * @throws `ScaffoldError('WRITE', …)` when a temporary host or the inventory
1719
+ * cannot be written or removed.
1720
+ * @throws `ScaffoldError('TARGET', …)` when the staged inventory does not read
1721
+ * back through the manifest validator.
1657
1722
  *
1658
1723
  * @remarks
1659
- * The one place the host-owned all-or-nothing rule is decided, so no verb
1660
- * restates it. The host surface contributes one baseline: a fill carries live
1661
- * bytes for every path that surface writes, or it is nothing. A row that failed,
1662
- * went missing, names an undeclared path, or leaves a host-owned path absent
1663
- * answers `undefined`. Every {@link isFloorPath} destination keeps the installed
1664
- * floor's bytes instead, claimed or not, so a fill carrying no row for one is
1665
- * complete rather than spoiled. One `Host` can therefore carry live host bytes
1666
- * beside floor bytes without mixing baselines within a surface.
1667
- *
1668
- * The emitted entries keep the release's own order and its storage and
1669
- * executable declarations, and carry digests recomputed over the bytes the fill
1670
- * actually holds. That is what lets a reader verify the value against itself,
1671
- * and it is why an undeclared path is refused rather than added: membership
1672
- * moves with a release, never with a fetch.
1724
+ * Uses {@link stageHost} as the single vendored-path expansion. The temporary
1725
+ * host supplies the same entries, roots, per-file digests, and membership
1726
+ * digest as the published host while the requested output remains one JSON
1727
+ * file.
1673
1728
  *
1674
1729
  * @example
1675
1730
  * ```ts
1676
- * import { filesToHost } from '@orkestrel/scaffold/server'
1731
+ * import { stageInventory } from '@orkestrel/scaffold/server'
1677
1732
  *
1678
- * // A floor declaring the host-owned `scripts/codex.sh` path and the canon
1679
- * // `AGENTS.md` destination. The script's live bytes are taken; the canon
1680
- * // destination keeps the floor's.
1681
- * filesToHost([{ path: 'scripts/codex.sh', lookup: 'found', hex: '23212f62696e2f73680a' }], floor)
1682
- * // { manifest: { entries: [ … ], roots: [ … ], digest: '…' },
1683
- * // bytes: { 'scripts/codex.sh': '23212f62696e2f73680a', 'AGENTS.md': floor.bytes['AGENTS.md'] } }
1733
+ * stageInventory(process.cwd(), 'host.json') // the committed host inventory
1684
1734
  * ```
1685
1735
  */
1686
- function filesToHost(files, floor) {
1687
- const declared = new Set(floor.manifest.entries.map((entry) => entry.destination));
1688
- const held = /* @__PURE__ */ new Map();
1689
- for (const file of files) {
1690
- if (file.lookup !== "found" || !declared.has(file.path)) return void 0;
1691
- if (!(0, _src_core.isFloorPath)(file.path)) held.set(file.path, file.hex);
1692
- }
1693
- const entries = [];
1694
- const bytes = {};
1695
- for (const entry of floor.manifest.entries) {
1696
- const hex = (0, _src_core.isFloorPath)(entry.destination) ? floor.bytes[entry.destination] : held.get(entry.destination);
1697
- if (hex === void 0) return void 0;
1698
- entries.push({
1699
- storage: entry.storage,
1700
- destination: entry.destination,
1701
- executable: entry.executable,
1702
- digest: hexToDigest(hex)
1736
+ function stageInventory(checkout, path = _src_core.HOST_INVENTORY_PATH) {
1737
+ if (!isFilesystemPath(path)) throw new _src_core.ScaffoldError("INVALID", "Inventory destination is not a host path", { path });
1738
+ const temporary = (0, _orkestrel_contract.attempt)(() => (0, node_fs.mkdtempSync)((0, node_path.join)((0, node_os.tmpdir)(), "orkestrel-scaffold-host-")));
1739
+ if (!temporary.success) throw new _src_core.ScaffoldError("WRITE", "Inventory staging root could not be established", {
1740
+ path,
1741
+ error: temporary.error
1742
+ });
1743
+ const staged = (0, _orkestrel_contract.attempt)(() => {
1744
+ stageHost(checkout, temporary.value);
1745
+ const manifest = readHostManifest(temporary.value);
1746
+ if (manifest === void 0) throw new _src_core.ScaffoldError("TARGET", "The staged inventory carries no manifest", { path });
1747
+ const target = (0, node_path.resolve)(path);
1748
+ const published = (0, _orkestrel_contract.attempt)(() => {
1749
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(target), { recursive: true });
1750
+ (0, node_fs.writeFileSync)(target, `${JSON.stringify(manifest, null, " ")}\n`, "utf8");
1703
1751
  });
1704
- bytes[entry.destination] = hex;
1705
- }
1706
- return {
1707
- manifest: {
1708
- entries,
1709
- roots: floor.manifest.roots,
1710
- digest: computeManifestDigest(entries, floor.manifest.roots)
1711
- },
1712
- bytes
1713
- };
1752
+ if (!published.success) throw new _src_core.ScaffoldError("WRITE", `Inventory could not be written at ${target}`, {
1753
+ path: target,
1754
+ error: published.error
1755
+ });
1756
+ const text = readFileText((0, node_path.dirname)(target), (0, node_path.basename)(target), _src_core.MAX_MANIFEST_BYTES);
1757
+ const verified = text === void 0 ? void 0 : (0, _orkestrel_contract.parseJSONAs)(text, isHostManifest);
1758
+ if (verified === void 0 || verified.digest !== computeManifestDigest(verified.entries, verified.roots, verified.surface)) throw new _src_core.ScaffoldError("TARGET", `Inventory does not read back at ${target}`, { path: target });
1759
+ return verified;
1760
+ });
1761
+ const removed = (0, _orkestrel_contract.attempt)(() => (0, node_fs.rmSync)(temporary.value, {
1762
+ recursive: true,
1763
+ force: true
1764
+ }));
1765
+ if (!removed.success) throw new _src_core.ScaffoldError("WRITE", `Inventory staging root could not be removed`, {
1766
+ path: temporary.value,
1767
+ error: removed.error
1768
+ });
1769
+ if (!staged.success) throw staged.error;
1770
+ return staged.value;
1714
1771
  }
1715
1772
  /**
1716
- * Stages the named destinations of a value host into a private root.
1773
+ * Captures one directory's physical identity.
1717
1774
  *
1718
- * @param host - The host whose bytes are written, keyed by destination.
1719
- * @param root - The private directory to fill; it must already be a directory
1720
- * this process may write into.
1721
- * @param destinations - The destinations to stage, each declared by `host`.
1722
- * @returns The entry staged for each destination, in the order requested.
1723
- * @throws `ScaffoldError('INVALID', …)` when `root` is not a host path or a
1724
- * storage name leaves it.
1725
- * @throws `ScaffoldError('TARGET', …)` when a destination is one the host does
1726
- * not declare, carries no bytes, or carries bytes that miss its declared digest.
1727
- * @throws `ScaffoldError('WRITE', …)` when a file cannot be written or does not
1728
- * read back as the bytes it was given.
1775
+ * @param path - The resolved directory path to capture.
1776
+ * @returns The anchor, or `undefined` when the path is not a physical directory.
1729
1777
  *
1730
1778
  * @remarks
1731
- * Each file lands under the storage name the manifest declares and takes the
1732
- * executable bit that manifest records, so a root filled from a value is the
1733
- * same shape as one staged from a checkout and a reader cannot tell them apart.
1734
- * That is what lets a mutation copy real files with real modes from bytes a
1735
- * caller supplied, instead of degrading them to plain text writes.
1736
- *
1737
- * The bytes are digested before the write and the staged file after it, so a
1738
- * value that disagrees with its own manifest is told apart from a write that
1739
- * did not land.
1779
+ * Device and inode rather than the path, because the path is the thing that can
1780
+ * be swapped underneath a write. An anchor captured before a mutation and
1781
+ * checked again after it proves the directory written into sits where the
1782
+ * inspected one sat, not that it is the one that was inspected.
1740
1783
  *
1741
1784
  * @example
1742
1785
  * ```ts
1743
- * import { stageBytes } from '@orkestrel/scaffold/server'
1786
+ * import { readAnchor } from '@orkestrel/scaffold/server'
1744
1787
  *
1745
- * stageBytes(host, '/tmp/orkestrel-host-a1b2', ['scripts/codex.sh'])
1746
- * // [{ storage: 'scripts/codex.sh', destination: 'scripts/codex.sh', executable: true, digest: '…' }]
1788
+ * readAnchor('/tmp/project') // { path: '/tmp/project', device: 1, inode: 2 }
1747
1789
  * ```
1748
1790
  */
1749
- function stageBytes(host, root, destinations) {
1750
- if (!isFilesystemPath(root)) throw new _src_core.ScaffoldError("INVALID", "Staging host root is not a host path", { host: root });
1751
- const declared = new Map(host.manifest.entries.map((entry) => [entry.destination, entry]));
1752
- const staged = [];
1753
- for (const destination of destinations) {
1754
- const entry = declared.get(destination);
1755
- const hex = host.bytes[destination];
1756
- if (entry === void 0 || hex === void 0) throw new _src_core.ScaffoldError("TARGET", `The host carries no bytes for ${destination}`, {
1757
- host: root,
1758
- destination
1759
- });
1760
- if (hexToDigest(hex) !== entry.digest) throw new _src_core.ScaffoldError("TARGET", `The host bytes for ${destination} miss its digest`, {
1761
- host: root,
1762
- destination
1763
- });
1764
- const full = resolveContainedPath(root, entry.storage);
1765
- if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Host storage leaves its root at ${entry.storage}`, {
1766
- host: root,
1767
- storage: entry.storage
1768
- });
1769
- const written = (0, _orkestrel_contract.attempt)(() => {
1770
- (0, node_fs.mkdirSync)((0, node_path.dirname)(full), { recursive: true });
1771
- (0, node_fs.writeFileSync)(full, Buffer.from(hex, "hex"), { flag: "wx" });
1772
- if (entry.executable) (0, node_fs.chmodSync)(full, 493);
1773
- });
1774
- if (!written.success) throw new _src_core.ScaffoldError("WRITE", `Host bytes could not be staged at ${entry.storage}`, {
1775
- host: root,
1776
- storage: entry.storage,
1777
- error: written.error
1778
- });
1779
- if (computeFileDigest(full) !== entry.digest) throw new _src_core.ScaffoldError("WRITE", `Staged host bytes at ${entry.storage} did not read back`, {
1780
- host: root,
1781
- storage: entry.storage
1782
- });
1783
- staged.push(entry);
1784
- }
1785
- return staged;
1791
+ function readAnchor(path) {
1792
+ const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(path));
1793
+ if (!status.success || !status.value.isDirectory() || status.value.isSymbolicLink()) return;
1794
+ return {
1795
+ path,
1796
+ device: status.value.dev,
1797
+ inode: status.value.ino
1798
+ };
1786
1799
  }
1787
1800
  /**
1788
- * Stages a vendored host root from a real checkout.
1801
+ * Tests whether a captured directory is still the same directory.
1789
1802
  *
1790
- * @param checkout - The checkout the vendored paths are read from.
1791
- * @param host - The vendored host root to fill; it must be absent or empty.
1792
- * @returns One entry per staged file, sorted by storage name.
1793
- * @throws `ScaffoldError('INVALID', …)` when either argument is not a host path
1794
- * or a vendored path leaves the checkout or the host root.
1795
- * @throws `ScaffoldError('TARGET', …)` when the checkout is not a directory, the
1796
- * host root is not vacant, the checkout does not carry every vendored path, two
1797
- * vendored files claim one storage name, a vendored file is not a plain file
1798
- * within the artifact ceiling, or the staged manifest does not read back.
1799
- * @throws `ScaffoldError('WRITE', …)` when a staged file or the manifest cannot
1800
- * be written.
1803
+ * @param anchor - The identity captured earlier.
1804
+ * @returns True if the path still holds a physical directory of that exact device and
1805
+ * inode; false otherwise.
1801
1806
  *
1802
1807
  * @remarks
1803
- * This is the producer half of the vendored host, and it is not the mutation
1804
- * contract `MaterializerInterface` states. That contract owns **target**
1805
- * writes: it materializes a compiled plan into a consumer's workspace, binds
1806
- * every destination to what the caller observed, and rolls a failed commit back.
1807
- * This reads this package's own checkout at build time and fills its own build
1808
- * output. Different direction, different lifetime, no consumer target involved,
1809
- * so they do not overlap and neither one belongs inside the other.
1810
- *
1811
- * Staging is plain rather than transactional for the same reason. A
1812
- * `WriteTransaction` exists to hold a directory that already holds work
1813
- * still; a build output holds nothing, is deleted whole before every build, and
1814
- * has no concurrent reader. What replaces it is refusing early and ordering the
1815
- * writes: the whole membership is derived before anything is created, so a
1816
- * checkout this refuses leaves no host root at all, and `manifest.json` is
1817
- * written last, so a stage that failed part way through leaves a root every
1818
- * reader treats as a raw checkout and fails loudly on.
1819
- *
1820
- * A missing vendored path is refused rather than staged around. A partial root
1821
- * is not detectably partial: it fails later, in a consumer's terminal, on
1822
- * whichever path the plan reached first. Refusing here fails the build that
1823
- * produced it, where the maintainer can act, and it names every missing path at
1824
- * once. A directory is the same case — declaring an absent directory as an empty
1825
- * root would create an empty directory in every generated workspace.
1826
- *
1827
- * The walk covers `HOST_PATHS` and `CANON_PATHS` together, because a release
1828
- * ships both: a target receives the first set, and reads the second one out of
1829
- * the installed package. Everything downstream — the missing-path refusal, the
1830
- * storage collision guard, the sort, the digests, and the root inventory — reads
1831
- * the union, so a canon path is staged under exactly the law a vendored one is.
1832
- *
1833
- * The vendoring deny-list applies to what the walk discovers beneath a staged
1834
- * directory, where a maintainer's local credential can legitimately sit, and
1835
- * such a path is skipped. A path either list names itself is curated data
1836
- * rather than discovery, so it is staged or the stage is refused.
1808
+ * This binds location rather than history. `true` means the path still resolves
1809
+ * to the same physical directory on the same device, so the next write lands
1810
+ * where the last one did. A path holding nothing, a file, or a symlink
1811
+ * answers `false`; a directory swapped in by `rename` also answers `false`
1812
+ * because the replacement carries its own inode. A directory deleted and made
1813
+ * again under the same name can receive the old inode back and answers `true`,
1814
+ * which nothing here detects.
1837
1815
  *
1838
- * @example Vendored data root
1816
+ * @example
1839
1817
  * ```ts
1840
- * import { stageHost } from '@orkestrel/scaffold/server'
1818
+ * import { matchesAnchor, readAnchor } from '@orkestrel/scaffold/server'
1841
1819
  *
1842
- * stageHost(process.cwd(), 'dist/host') // one ManifestEntry per file staged
1820
+ * const anchor = readAnchor('/tmp/project')
1821
+ * anchor !== undefined && matchesAnchor(anchor) // true while it is untouched
1843
1822
  * ```
1844
1823
  */
1845
- function stageHost(checkout, host) {
1846
- if (!isFilesystemPath(checkout)) throw new _src_core.ScaffoldError("INVALID", "Staging checkout is not a host path", { checkout });
1847
- if (!isFilesystemPath(host)) throw new _src_core.ScaffoldError("INVALID", "Staging host root is not a host path", { host });
1848
- const source = (0, node_path.resolve)(checkout);
1849
- if (!isPhysicalDirectory(source)) throw new _src_core.ScaffoldError("TARGET", `Staging checkout is not a physical directory at ${source}`, { checkout: source });
1850
- if (!isVacant(host)) throw new _src_core.ScaffoldError("TARGET", `Staging host root is not vacant at ${host}`, { host });
1851
- const vendored = [];
1852
- const roots = [];
1853
- const missing = [];
1854
- for (const path of [..._src_core.HOST_PATHS, ..._src_core.CANON_PATHS]) {
1855
- const full = resolveContainedPath(source, path);
1856
- if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Vendored path leaves its checkout at ${path}`, {
1857
- checkout: source,
1858
- path
1859
- });
1860
- if (isPhysicalFile(full)) {
1861
- vendored.push(path);
1862
- continue;
1863
- }
1864
- if (!isPhysicalDirectory(full)) {
1865
- missing.push(path);
1866
- continue;
1867
- }
1868
- roots.push(path);
1869
- for (const nested of listDirectories(full)) {
1870
- const rooted = `${path}/${nested}`;
1871
- if (!matchesSensitivePath(rooted)) roots.push(rooted);
1872
- }
1873
- for (const name of listFiles(full)) {
1874
- const destination = `${path}/${name}`;
1875
- if (!matchesSensitivePath(destination)) vendored.push(destination);
1876
- }
1877
- }
1878
- if (missing.length > 0) throw new _src_core.ScaffoldError("TARGET", "The checkout does not carry every vendored path", {
1879
- checkout: source,
1880
- missing
1881
- });
1882
- const stored = /* @__PURE__ */ new Set([MANIFEST_NAME]);
1883
- const candidates = [];
1884
- for (const destination of vendored) {
1885
- const full = resolveContainedPath(source, destination);
1886
- if (full === void 0) throw new _src_core.ScaffoldError("INVALID", `Vendored path leaves its checkout at ${destination}`, {
1887
- checkout: source,
1888
- path: destination
1889
- });
1890
- const entry = readManifestEntry(destination, full);
1891
- if (entry === void 0) throw new _src_core.ScaffoldError("TARGET", `Vendored path is not a plain file within the artifact ceiling at ${destination}`, {
1892
- checkout: source,
1893
- path: destination,
1894
- limit: _src_core.MAX_ARTIFACT_BYTES
1895
- });
1896
- if (stored.has(entry.storage)) throw new _src_core.ScaffoldError("TARGET", `Two vendored paths claim the storage name ${entry.storage}`, {
1897
- checkout: source,
1898
- path: destination,
1899
- storage: entry.storage
1900
- });
1901
- stored.add(entry.storage);
1902
- candidates.push(entry);
1903
- }
1904
- candidates.sort((first, second) => first.storage < second.storage ? -1 : 1);
1905
- roots.sort();
1906
- const root = (0, node_path.resolve)(host);
1907
- const established = (0, _orkestrel_contract.attempt)(() => (0, node_fs.mkdirSync)(root, { recursive: true }));
1908
- if (!established.success || !isPhysicalDirectory(root)) throw new _src_core.ScaffoldError("WRITE", `Staging host root could not be established at ${root}`, {
1909
- host: root,
1910
- ...established.success ? {} : { error: established.error }
1911
- });
1912
- const entries = [];
1913
- for (const entry of candidates) {
1914
- const origin = resolveContainedPath(source, entry.destination);
1915
- const destination = resolveContainedPath(root, entry.storage);
1916
- if (origin === void 0 || destination === void 0) throw new _src_core.ScaffoldError("INVALID", `Vendored path leaves its root at ${entry.destination}`, {
1917
- checkout: source,
1918
- host: root,
1919
- path: entry.destination,
1920
- storage: entry.storage
1921
- });
1922
- const copied = (0, _orkestrel_contract.attempt)(() => {
1923
- (0, node_fs.mkdirSync)((0, node_path.dirname)(destination), { recursive: true });
1924
- (0, node_fs.copyFileSync)(origin, destination, node_fs.constants.COPYFILE_EXCL);
1925
- if (entry.executable) (0, node_fs.chmodSync)(destination, 493);
1926
- });
1927
- if (!copied.success) throw new _src_core.ScaffoldError("WRITE", `Vendored file could not be staged at ${entry.storage}`, {
1928
- host: root,
1929
- storage: entry.storage,
1930
- error: copied.error
1931
- });
1932
- const digest = computeFileDigest(destination);
1933
- if (digest === void 0) throw new _src_core.ScaffoldError("WRITE", `Vendored file could not be verified at ${entry.storage}`, {
1934
- host: root,
1935
- storage: entry.storage
1936
- });
1937
- entries.push({
1938
- ...entry,
1939
- digest
1940
- });
1941
- }
1942
- const manifest = {
1943
- entries,
1944
- roots,
1945
- digest: computeManifestDigest(entries, roots)
1946
- };
1947
- const metadata = resolveContainedPath(root, MANIFEST_NAME);
1948
- if (metadata === void 0) throw new _src_core.ScaffoldError("INVALID", `Host manifest leaves its root at ${root}`, { host: root });
1949
- const published = (0, _orkestrel_contract.attempt)(() => (0, node_fs.writeFileSync)(metadata, `${JSON.stringify(manifest, null, " ")}\n`, {
1950
- encoding: "utf8",
1951
- flag: "wx"
1952
- }));
1953
- if (!published.success) throw new _src_core.ScaffoldError("WRITE", `Host manifest could not be staged at ${metadata}`, {
1954
- host: root,
1955
- error: published.error
1956
- });
1957
- const verified = readHostManifest(root);
1958
- if (verified === void 0 || verified.digest !== manifest.digest) throw new _src_core.ScaffoldError("TARGET", `The staged host manifest does not read back at ${root}`, { host: root });
1959
- return entries;
1824
+ function matchesAnchor(anchor) {
1825
+ const current = readAnchor(anchor.path);
1826
+ return current !== void 0 && current.device === anchor.device && current.inode === anchor.inode;
1960
1827
  }
1961
1828
  /**
1962
- * Stages the committed inventory of the files a vendored host carries.
1829
+ * Captures what one destination holds before a write.
1963
1830
  *
1964
- * @param checkout - The checkout whose vendored paths are inventoried.
1965
- * @param path - The host path where the JSON inventory is written.
1966
- * @returns The validated manifest written to `path`.
1967
- * @throws `ScaffoldError('INVALID', …)` when `path` is not a host path.
1968
- * @throws `ScaffoldError('WRITE', …)` when a temporary host or the inventory
1969
- * cannot be written or removed.
1970
- * @throws `ScaffoldError('TARGET', …)` when the staged inventory does not read
1971
- * back through the manifest validator.
1831
+ * @param path - The resolved destination path to capture.
1832
+ * @returns The expectation, or `undefined` when the destination is a link or a
1833
+ * shape this package will not write over.
1972
1834
  *
1973
1835
  * @remarks
1974
- * Uses {@link stageHost} as the single vendored-path expansion. The temporary
1975
- * host supplies the same entries, roots, per-file digests, and membership
1976
- * digest as the published host while the requested output remains one JSON
1977
- * file.
1836
+ * Absence is a captured state rather than a failure, because most writes expect
1837
+ * exactly that. Each shape carries only the facts it supplies: a directory
1838
+ * carries its identity, a file carries its identity, size, and bytes, and an
1839
+ * absent destination carries nothing at all. A file past the artifact ceiling
1840
+ * carries no digest and is bound by its identity, size, and modification time
1841
+ * alone, which is the strongest honest claim about bytes nobody read.
1978
1842
  *
1979
1843
  * @example
1980
1844
  * ```ts
1981
- * import { stageInventory } from '@orkestrel/scaffold/server'
1845
+ * import { readExpectation } from '@orkestrel/scaffold/server'
1982
1846
  *
1983
- * stageInventory(process.cwd(), 'host.json') // the committed host inventory
1847
+ * readExpectation('/tmp/project/absent.md') // { path: …, shape: 'absent' }
1984
1848
  * ```
1985
1849
  */
1986
- function stageInventory(checkout, path) {
1987
- if (!isFilesystemPath(path)) throw new _src_core.ScaffoldError("INVALID", "Inventory destination is not a host path", { path });
1988
- const temporary = (0, _orkestrel_contract.attempt)(() => (0, node_fs.mkdtempSync)((0, node_path.join)((0, node_os.tmpdir)(), "orkestrel-scaffold-host-")));
1989
- if (!temporary.success) throw new _src_core.ScaffoldError("WRITE", "Inventory staging root could not be established", {
1990
- path,
1991
- error: temporary.error
1992
- });
1993
- const staged = (0, _orkestrel_contract.attempt)(() => {
1994
- stageHost(checkout, temporary.value);
1995
- const manifest = readHostManifest(temporary.value);
1996
- if (manifest === void 0) throw new _src_core.ScaffoldError("TARGET", "The staged inventory carries no manifest", { path });
1997
- const target = (0, node_path.resolve)(path);
1998
- const published = (0, _orkestrel_contract.attempt)(() => {
1999
- (0, node_fs.mkdirSync)((0, node_path.dirname)(target), { recursive: true });
2000
- (0, node_fs.writeFileSync)(target, `${JSON.stringify(manifest, null, " ")}\n`, "utf8");
2001
- });
2002
- if (!published.success) throw new _src_core.ScaffoldError("WRITE", `Inventory could not be written at ${target}`, {
2003
- path: target,
2004
- error: published.error
2005
- });
2006
- const text = readFileText((0, node_path.dirname)(target), (0, node_path.basename)(target), _src_core.MAX_MANIFEST_BYTES);
2007
- const verified = text === void 0 ? void 0 : (0, _orkestrel_contract.parseJSONAs)(text, isHostManifest);
2008
- if (verified === void 0 || verified.digest !== computeManifestDigest(verified.entries, verified.roots)) throw new _src_core.ScaffoldError("TARGET", `Inventory does not read back at ${target}`, { path: target });
2009
- return verified;
2010
- });
2011
- const removed = (0, _orkestrel_contract.attempt)(() => (0, node_fs.rmSync)(temporary.value, {
2012
- recursive: true,
2013
- force: true
2014
- }));
2015
- if (!removed.success) throw new _src_core.ScaffoldError("WRITE", `Inventory staging root could not be removed`, {
2016
- path: temporary.value,
2017
- error: removed.error
1850
+ function readExpectation(path) {
1851
+ const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(path));
1852
+ if (!status.success) return matchesMissingPath(status.error) ? {
1853
+ path,
1854
+ shape: "absent"
1855
+ } : void 0;
1856
+ const stats = status.value;
1857
+ if (stats.isSymbolicLink()) return void 0;
1858
+ if (stats.isDirectory()) return {
1859
+ path,
1860
+ shape: "directory",
1861
+ device: stats.dev,
1862
+ inode: stats.ino,
1863
+ modified: stats.mtimeMs
1864
+ };
1865
+ if (!stats.isFile() || stats.nlink !== 1) return void 0;
1866
+ const digest = computeFileDigest(path);
1867
+ return {
1868
+ path,
1869
+ shape: "file",
1870
+ device: stats.dev,
1871
+ inode: stats.ino,
1872
+ modified: stats.mtimeMs,
1873
+ size: stats.size,
1874
+ ...digest === void 0 ? {} : { digest }
1875
+ };
1876
+ }
1877
+ /**
1878
+ * Tests whether a destination still holds what was captured of it.
1879
+ *
1880
+ * @param expectation - The state captured earlier.
1881
+ * @returns True if re-reading the destination produces that same state; false otherwise.
1882
+ *
1883
+ * @remarks
1884
+ * Compared field for field against a fresh {@link readExpectation}, so an
1885
+ * expectation recorded without a digest matches only a destination that still
1886
+ * has no digest to give. That is what keeps the comparison honest in both
1887
+ * directions: nothing is treated as satisfied because it was never measured.
1888
+ *
1889
+ * @example
1890
+ * ```ts
1891
+ * import { matchesExpectation, readExpectation } from '@orkestrel/scaffold/server'
1892
+ *
1893
+ * const expectation = readExpectation('/tmp/project/AGENTS.md')
1894
+ * expectation !== undefined && matchesExpectation(expectation) // true while untouched
1895
+ * ```
1896
+ */
1897
+ function matchesExpectation(expectation) {
1898
+ const current = readExpectation(expectation.path);
1899
+ if (current === void 0) return false;
1900
+ return current.shape === expectation.shape && current.device === expectation.device && current.inode === expectation.inode && current.modified === expectation.modified && current.size === expectation.size && current.digest === expectation.digest;
1901
+ }
1902
+ /**
1903
+ * Tests whether a destination still matches the narrower state a caller observed.
1904
+ *
1905
+ * @param precondition - The caller-observed state the write is held to.
1906
+ * @returns True if the destination is absent as stated, or holds a physical file whose
1907
+ * bytes digest to the stated value; false otherwise.
1908
+ *
1909
+ * @remarks
1910
+ * Narrower than {@link matchesExpectation} on purpose. A caller observed bytes,
1911
+ * not inodes and timestamps, so binding a write to a device identity it never
1912
+ * saw would refuse writes that are perfectly safe — a file rewritten to
1913
+ * identical bytes by an editor is still the file the caller read. A precondition
1914
+ * that states no digest claims presence only.
1915
+ *
1916
+ * @example
1917
+ * ```ts
1918
+ * import { matchesPrecondition } from '@orkestrel/scaffold/server'
1919
+ *
1920
+ * matchesPrecondition({ path: '/tmp/project/new.md', shape: 'absent' }) // true while absent
1921
+ * ```
1922
+ */
1923
+ function matchesPrecondition(precondition) {
1924
+ const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(precondition.path));
1925
+ if (!status.success) return precondition.shape === "absent" && matchesMissingPath(status.error);
1926
+ if (precondition.shape !== "file" || !isPhysicalFile(precondition.path)) return false;
1927
+ if (precondition.digest === void 0) return true;
1928
+ return computeFileDigest(precondition.path) === precondition.digest;
1929
+ }
1930
+ //#endregion
1931
+ //#region src/server/validators.ts
1932
+ /**
1933
+ * Narrows a value to a path naming a location on this host.
1934
+ *
1935
+ * @param value - The candidate host path.
1936
+ * @returns True if the value is a bounded absolute or relative path whose every segment
1937
+ * is portable across the supported filesystems; false otherwise.
1938
+ *
1939
+ * @remarks
1940
+ * The counterpart to the core path law, not a copy of it. A target directory and
1941
+ * the vendored host root are locations on the machine rather than paths inside a
1942
+ * workspace, so a drive prefix, a UNC share, and a backslash separator are all
1943
+ * admitted here and `..` is a legitimate way to name a sibling directory.
1944
+ * Containment is still enforced, but by the core law over the artifact paths
1945
+ * written beneath the target, not by this one.
1946
+ *
1947
+ * What it does refuse is a segment no supported filesystem can hold: an empty
1948
+ * one, a reserved Windows device name, a trailing dot or space, a wildcard or
1949
+ * redirection character, a colon anywhere but the drive prefix, and a name past
1950
+ * the byte ceiling. The character ceiling is read first so an oversized string is
1951
+ * refused before it is split.
1952
+ *
1953
+ * The spellings of an empty segment are answered differently. A trailing
1954
+ * separator terminates a directory rather than opening a segment, and every
1955
+ * supported filesystem and every Node path API reads `project/` and `project` as
1956
+ * one location, so it is admitted. A doubled separator is a genuine empty
1957
+ * segment, so `project//src` is refused. Nothing normalizes the argument first —
1958
+ * every server entry point guards the caller's text and resolves it afterwards —
1959
+ * so a directory taken from a shell completion arrives carrying the separator the
1960
+ * shell appended and names the directory it appears to name.
1961
+ *
1962
+ * @example
1963
+ * ```ts
1964
+ * import { isFilesystemPath } from '@orkestrel/scaffold/server'
1965
+ *
1966
+ * isFilesystemPath('C:/Users/sample/project') // true
1967
+ * isFilesystemPath('../sibling') // true
1968
+ * isFilesystemPath('project/') // true
1969
+ * isFilesystemPath('project//src') // false
1970
+ * isFilesystemPath('project/nul') // false
1971
+ * ```
1972
+ */
1973
+ function isFilesystemPath(value) {
1974
+ return (0, _orkestrel_contract.holds)(() => {
1975
+ if (!(0, _orkestrel_contract.isString)(value) || value.length === 0 || value.length > _src_core.MAX_PATH_LENGTH) return false;
1976
+ if (_src_core.CONTROL_CHARACTER_PATTERN.test(value)) return false;
1977
+ const normalized = value.replaceAll("\\", "/");
1978
+ const rooted = normalized.startsWith("//") ? normalized.slice(2) : normalized.startsWith("/") ? normalized.slice(1) : normalized;
1979
+ const segments = (rooted.endsWith("/") ? rooted.slice(0, -1) : rooted).split("/");
1980
+ if (segments.length > 64) return false;
1981
+ for (const [index, segment] of segments.entries()) {
1982
+ if (segment === "." || segment === "..") continue;
1983
+ if (index === 0 && DRIVE_PATTERN.test(segment)) continue;
1984
+ if (segment.length === 0) return false;
1985
+ if (INVALID_SEGMENT_CHARACTER_PATTERN.test(segment)) return false;
1986
+ if (segment.endsWith(".") || segment.endsWith(" ")) return false;
1987
+ if ((0, _src_core.computeBytes)(segment) > 255) return false;
1988
+ if (RESERVED_SEGMENT_PATTERN.test(segment)) return false;
1989
+ }
1990
+ return true;
2018
1991
  });
2019
- if (!staged.success) throw staged.error;
2020
- return staged.value;
2021
1992
  }
2022
1993
  /**
2023
- * Captures one directory's physical identity.
1994
+ * Narrows a value to one exact SHA-256 digest.
1995
+ *
1996
+ * @remarks
1997
+ * The identity a vendored host manifest and a write precondition are both stated
1998
+ * in. Fixed at sixty-four lowercase digits, so the value either is a digest of
1999
+ * that algorithm or is refused; there is no shorter or longer accepted form.
2000
+ *
2001
+ * @example
2002
+ * ```ts
2003
+ * import { isDigest } from '@orkestrel/scaffold/server'
2004
+ *
2005
+ * isDigest('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') // true
2006
+ * isDigest('E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855') // false
2007
+ * ```
2008
+ */
2009
+ var isDigest = (0, _orkestrel_contract.stringOf)({ pattern: DIGEST_PATTERN });
2010
+ /**
2011
+ * Narrows a value to a working-tree inventory within the limit one target may report.
2012
+ *
2013
+ * @param value - The candidate inventory.
2014
+ * @returns True if the value is an array of no more than
2015
+ * `MAX_INVENTORY_PATHS` items; false otherwise.
2016
+ *
2017
+ * @remarks
2018
+ * Compose this ahead of an element guard exactly as the core collection guard is
2019
+ * composed, and for the same reason: the item count is settled before anything
2020
+ * walks the items, and a hostile `length` accessor answers `false` rather than
2021
+ * escaping as a throw. It exists beside that guard rather than reusing it
2022
+ * because they bound different things — one bounds what a caller may hand a
2023
+ * public method, this one bounds what a checkout may contain.
2024
+ *
2025
+ * @example
2026
+ * ```ts
2027
+ * import { isInventory } from '@orkestrel/scaffold/server'
2028
+ *
2029
+ * isInventory(['AGENTS.md']) // true
2030
+ * isInventory('AGENTS.md') // false
2031
+ * ```
2032
+ */
2033
+ function isInventory(value) {
2034
+ return (0, _orkestrel_contract.holds)(() => (0, _orkestrel_contract.isArray)(value) && value.length <= 1e5);
2035
+ }
2036
+ /**
2037
+ * Narrows a value to a bounded upstream endpoint.
2038
+ *
2039
+ * @remarks
2040
+ * Length only. Which schemes and hosts an endpoint may name is the reader's law,
2041
+ * because it builds the request and can report why one was refused, where a
2042
+ * guard has only `false` to say.
2043
+ */
2044
+ var isEndpoint = (0, _orkestrel_contract.stringOf)({
2045
+ min: 1,
2046
+ max: MAX_ENDPOINT_LENGTH
2047
+ });
2048
+ /**
2049
+ * Narrows a value to a Git branch the repository endpoint accepts.
2050
+ *
2051
+ * @remarks
2052
+ * A branch reaches the repository URL's path, so the syntax is closed rather than
2053
+ * merely bounded and no `..` is admitted anywhere in it.
2054
+ *
2055
+ * @example
2056
+ * ```ts
2057
+ * import { isBranch } from '@orkestrel/scaffold/server'
2058
+ *
2059
+ * isBranch('main') // true
2060
+ * isBranch('main/../etc') // false
2061
+ * ```
2062
+ */
2063
+ var isBranch = (0, _orkestrel_contract.stringOf)({
2064
+ min: 1,
2065
+ max: 255,
2066
+ pattern: BRANCH_PATTERN
2067
+ });
2068
+ /**
2069
+ * Narrows a value to a per-request timeout in milliseconds.
2070
+ *
2071
+ * @remarks
2072
+ * A whole number of milliseconds, at least one and no more than
2073
+ * {@link MAX_UPSTREAM_TIMEOUT}. Zero is refused because a request that may not
2074
+ * take any time is a request that cannot succeed.
2075
+ */
2076
+ var isTimeout = (0, _orkestrel_contract.andOf)(_orkestrel_contract.isInteger, (0, _orkestrel_contract.boundsOf)(1, MAX_UPSTREAM_TIMEOUT));
2077
+ /**
2078
+ * Narrows a value to a bounded list of `@orkestrel` package names.
2079
+ *
2080
+ * @remarks
2081
+ * Composed from the core collection and dependency-name guards rather than
2082
+ * restated, so the scope law that keeps a derived guide mirror inside its
2083
+ * directory has exactly one home.
2084
+ *
2085
+ * @example
2086
+ * ```ts
2087
+ * import { isDependencyNames } from '@orkestrel/scaffold/server'
2088
+ *
2089
+ * isDependencyNames(['@orkestrel/router']) // true
2090
+ * isDependencyNames(['router']) // false
2091
+ * ```
2092
+ */
2093
+ var isDependencyNames = (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isDependencyName));
2094
+ /**
2095
+ * Narrows a value to a bounded list of target-relative paths.
2096
+ *
2097
+ * @remarks
2098
+ * Composed from the core collection and path guards rather than restated, so
2099
+ * the containment law that keeps a caller-supplied path inside its target has
2100
+ * exactly one home. It bounds what a caller may hand a public method, which is
2101
+ * why it is not {@link isInventory}: that one bounds what a checkout may hold.
2102
+ *
2103
+ * @example
2104
+ * ```ts
2105
+ * import { isPaths } from '@orkestrel/scaffold/server'
2106
+ *
2107
+ * isPaths(['AGENTS.md']) // true
2108
+ * isPaths(['../secrets']) // false
2109
+ * ```
2110
+ */
2111
+ var isPaths = (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isPath));
2112
+ /** Narrows a value to a bounded list of declared runtime dependencies. */
2113
+ var isDependencies = (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isDependency));
2114
+ /**
2115
+ * Narrows a value to one {@link ManifestRegionSet}.
2116
+ *
2117
+ * @remarks
2118
+ * The whole closed record a manifest-writing method accepts, so a caller
2119
+ * naming a region the writer does not carry is refused before any byte moves.
2120
+ * Each region is bounded by the same collection law its own list guard applies.
2121
+ *
2122
+ * @example
2123
+ * ```ts
2124
+ * import { isManifestRegionSet } from '@orkestrel/scaffold/server'
2024
2125
  *
2025
- * @param path - The resolved directory path to capture.
2026
- * @returns The anchor, or `undefined` when the path is not a physical directory.
2126
+ * isManifestRegionSet({ pins: { runtime: [], development: [] }, scripts: [] }) // true
2127
+ * isManifestRegionSet({ pins: { runtime: [], development: [] } }) // false
2128
+ * ```
2129
+ */
2130
+ var isManifestRegionSet = (0, _orkestrel_contract.recordOf)({
2131
+ pins: (0, _orkestrel_contract.recordOf)({
2132
+ runtime: isDependencies,
2133
+ development: isDependencies
2134
+ }),
2135
+ scripts: (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isManifestScript))
2136
+ });
2137
+ /** Narrows a value to a bounded list of fetched guide mirrors. */
2138
+ var isMirrors = (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isMirror));
2139
+ /** Narrows a value to a bounded list of fleet catalog rows. */
2140
+ var isCatalogEntries = (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isCatalogEntry));
2141
+ /**
2142
+ * Narrows a value to one {@link ManifestEntry}.
2027
2143
  *
2028
2144
  * @remarks
2029
- * Device and inode rather than the path, because the path is the thing that can
2030
- * be swapped underneath a write. An anchor captured before a mutation and
2031
- * checked again after it proves the directory written into sits where the
2032
- * inspected one sat, not that it is the one that was inspected.
2145
+ * Both paths are measured by the core path law, because a vendored host's
2146
+ * storage name and the destination it maps to are each a path inside a
2147
+ * workspace. That is what stops a hand-edited manifest from mapping a vendored
2148
+ * file to a destination outside the target.
2033
2149
  *
2034
2150
  * @example
2035
2151
  * ```ts
2036
- * import { readAnchor } from '@orkestrel/scaffold/server'
2152
+ * import { isManifestEntry } from '@orkestrel/scaffold/server'
2037
2153
  *
2038
- * readAnchor('/tmp/project') // { path: '/tmp/project', device: 1, inode: 2 }
2154
+ * isManifestEntry({
2155
+ * storage: 'AGENTS.md',
2156
+ * destination: 'AGENTS.md',
2157
+ * executable: false,
2158
+ * digest: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
2159
+ * }) // true
2039
2160
  * ```
2040
2161
  */
2041
- function readAnchor(path) {
2042
- const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(path));
2043
- if (!status.success || !status.value.isDirectory() || status.value.isSymbolicLink()) return;
2044
- return {
2045
- path,
2046
- device: status.value.dev,
2047
- inode: status.value.ino
2048
- };
2049
- }
2162
+ var isManifestEntry = (0, _orkestrel_contract.recordOf)({
2163
+ storage: _src_core.isPath,
2164
+ destination: _src_core.isPath,
2165
+ executable: _orkestrel_contract.isBoolean,
2166
+ digest: isDigest
2167
+ });
2050
2168
  /**
2051
- * Tests whether a captured directory is still the same directory.
2169
+ * Narrows a value to one {@link HostManifest}.
2052
2170
  *
2053
- * @param anchor - The identity captured earlier.
2054
- * @returns True if the path still holds a physical directory of that exact device and
2055
- * inode; false otherwise.
2171
+ * @remarks
2172
+ * The manifest is read from a directory a caller named, so it is the least
2173
+ * trusted value the server face handles and is guarded whole: every entry, every
2174
+ * declared root, sorted Surface collisions with distinct sorted owners, and
2175
+ * the syntax of the digest that authenticates that membership.
2176
+ */
2177
+ var isHostManifest = (0, _orkestrel_contract.recordOf)({
2178
+ entries: (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(isManifestEntry)),
2179
+ roots: (0, _orkestrel_contract.andOf)(_src_core.isCollection, (0, _orkestrel_contract.arrayOf)(_src_core.isPath)),
2180
+ surface: (0, _orkestrel_contract.whereOf)((0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.recordOf)({
2181
+ name: (0, _orkestrel_contract.stringOf)({ min: 1 }),
2182
+ owners: (0, _orkestrel_contract.whereOf)((0, _orkestrel_contract.arrayOf)((0, _orkestrel_contract.stringOf)({ min: 1 })), (owners) => (0, _src_core.isCollection)(owners) && owners.length > 1 && owners.every((owner, index) => index === 0 || (owners[index - 1] ?? owner) < owner))
2183
+ })), (surface) => (0, _src_core.isCollection)(surface) && surface.every((collision, index) => index === 0 || (surface[index - 1]?.name ?? collision.name) < collision.name)),
2184
+ digest: isDigest
2185
+ });
2186
+ /**
2187
+ * Narrows a value to one {@link Host}.
2056
2188
  *
2057
2189
  * @remarks
2058
- * This binds location rather than history. `true` means the path still resolves
2059
- * to the same physical directory on the same device, so the next write lands
2060
- * where the last one did. A path holding nothing, a file, or a symlink
2061
- * answers `false`; a directory swapped in by `rename` also answers `false`
2062
- * because the replacement carries its own inode. A directory deleted and made
2063
- * again under the same name can receive the old inode back and answers `true`,
2064
- * which nothing here detects.
2190
+ * A whole vendored host handed in as a value is as untrusted as one read from a
2191
+ * directory a caller named, so both halves are guarded: the manifest by the same
2192
+ * membership law a read root is held to, and the bytes by the core snapshot law,
2193
+ * which bounds the fill and reads every key as a path and every value as exact
2194
+ * lowercase hexadecimal. The manifest's digest must match its declared entries,
2195
+ * roots, and Surface collisions. Whether the fill agrees with the manifest is the
2196
+ * reader's question rather than this one's, because a guard has only `false` to
2197
+ * say and a mismatch has a path to name.
2065
2198
  *
2066
2199
  * @example
2067
2200
  * ```ts
2068
- * import { matchesAnchor, readAnchor } from '@orkestrel/scaffold/server'
2201
+ * import { computeManifestDigest, isHost } from '@orkestrel/scaffold/server'
2069
2202
  *
2070
- * const anchor = readAnchor('/tmp/project')
2071
- * anchor !== undefined && matchesAnchor(anchor) // true while it is untouched
2203
+ * const digest = computeManifestDigest([], [], [])
2204
+ *
2205
+ * isHost({ manifest: { entries: [], roots: [], surface: [], digest }, bytes: {} }) // true
2206
+ * isHost({ manifest: { entries: [], roots: [], surface: [], digest } }) // false
2072
2207
  * ```
2073
2208
  */
2074
- function matchesAnchor(anchor) {
2075
- const current = readAnchor(anchor.path);
2076
- return current !== void 0 && current.device === anchor.device && current.inode === anchor.inode;
2077
- }
2209
+ var isHost = (0, _orkestrel_contract.whereOf)((0, _orkestrel_contract.recordOf)({
2210
+ manifest: isHostManifest,
2211
+ bytes: _src_core.isSnapshot
2212
+ }), ({ manifest }) => manifest.digest === computeManifestDigest(manifest.entries, manifest.roots, manifest.surface));
2078
2213
  /**
2079
- * Captures what one destination holds before a write.
2080
- *
2081
- * @param path - The resolved destination path to capture.
2082
- * @returns The expectation, or `undefined` when the destination is a link or a
2083
- * shape this package will not write over.
2214
+ * Narrows a value to a {@link Worktree}.
2084
2215
  *
2085
2216
  * @remarks
2086
- * Absence is a captured state rather than a failure, because most writes expect
2087
- * exactly that. Each shape carries only the facts it supplies: a directory
2088
- * carries its identity, a file carries its identity, size, and bytes, and an
2089
- * absent destination carries nothing at all. A file past the artifact ceiling
2090
- * carries no digest and is bound by its identity, size, and modification time
2091
- * alone, which is the strongest honest claim about bytes nobody read.
2217
+ * Both path lists are target-relative, so both are measured by the core path
2218
+ * law: a reported path that is not one this package could have planned is not a
2219
+ * path it will delete. The inventory guard bounds the lists, because a checkout
2220
+ * is legitimately far larger than any collection a caller hands a method.
2092
2221
  *
2093
2222
  * @example
2094
2223
  * ```ts
2095
- * import { readExpectation } from '@orkestrel/scaffold/server'
2224
+ * import { isWorktree } from '@orkestrel/scaffold/server'
2096
2225
  *
2097
- * readExpectation('/tmp/project/absent.md') // { path: …, shape: 'absent' }
2226
+ * isWorktree({ tracked: ['AGENTS.md'], dirty: [] }) // true
2227
+ * isWorktree({ tracked: ['../secrets'], dirty: [] }) // false
2098
2228
  * ```
2099
2229
  */
2100
- function readExpectation(path) {
2101
- const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(path));
2102
- if (!status.success) return matchesMissingPath(status.error) ? {
2103
- path,
2104
- shape: "absent"
2105
- } : void 0;
2106
- const stats = status.value;
2107
- if (stats.isSymbolicLink()) return void 0;
2108
- if (stats.isDirectory()) return {
2109
- path,
2110
- shape: "directory",
2111
- device: stats.dev,
2112
- inode: stats.ino,
2113
- modified: stats.mtimeMs
2114
- };
2115
- if (!stats.isFile() || stats.nlink !== 1) return void 0;
2116
- const digest = computeFileDigest(path);
2117
- return {
2118
- path,
2119
- shape: "file",
2120
- device: stats.dev,
2121
- inode: stats.ino,
2122
- modified: stats.mtimeMs,
2123
- size: stats.size,
2124
- ...digest === void 0 ? {} : { digest }
2125
- };
2126
- }
2230
+ var isWorktree = (0, _orkestrel_contract.recordOf)({
2231
+ tracked: (0, _orkestrel_contract.andOf)(isInventory, (0, _orkestrel_contract.arrayOf)(_src_core.isPath)),
2232
+ dirty: (0, _orkestrel_contract.andOf)(isInventory, (0, _orkestrel_contract.arrayOf)(_src_core.isPath))
2233
+ });
2127
2234
  /**
2128
- * Tests whether a destination still holds what was captured of it.
2235
+ * Narrows a value to the materializer's initial listener record.
2129
2236
  *
2130
- * @param expectation - The state captured earlier.
2131
- * @returns True if re-reading the destination produces that same state; false otherwise.
2237
+ * @remarks
2238
+ * Every event is optional and every declared value is a function. A key outside
2239
+ * the materializer's event map is refused, so a listener wired to a misspelled
2240
+ * event fails at construction instead of never firing.
2241
+ */
2242
+ var isMaterializerHooks = (0, _orkestrel_contract.recordOf)({
2243
+ write: _orkestrel_contract.isFunction,
2244
+ remove: _orkestrel_contract.isFunction,
2245
+ finish: _orkestrel_contract.isFunction,
2246
+ error: _orkestrel_contract.isFunction,
2247
+ destroy: _orkestrel_contract.isFunction
2248
+ }, true);
2249
+ /**
2250
+ * Narrows a value to {@link MaterializerOptions}.
2132
2251
  *
2133
2252
  * @remarks
2134
- * Compared field for field against a fresh {@link readExpectation}, so an
2135
- * expectation recorded without a digest matches only a destination that still
2136
- * has no digest to give. That is what keeps the comparison honest in both
2137
- * directions: nothing is treated as satisfied because it was never measured.
2253
+ * `host` admits both representations of one vendored root: a directory path and
2254
+ * a whole {@link Host} value. They share a key because they are one setting
2255
+ * stated two ways rather than two settings, so nothing downstream has to
2256
+ * reconcile a pair that could disagree.
2138
2257
  *
2139
2258
  * @example
2140
2259
  * ```ts
2141
- * import { matchesExpectation, readExpectation } from '@orkestrel/scaffold/server'
2260
+ * import { isMaterializerOptions } from '@orkestrel/scaffold/server'
2142
2261
  *
2143
- * const expectation = readExpectation('/tmp/project/AGENTS.md')
2144
- * expectation !== undefined && matchesExpectation(expectation) // true while untouched
2262
+ * isMaterializerOptions({}) // true
2263
+ * isMaterializerOptions({ host: 'dist/host*' }) // false
2145
2264
  * ```
2146
2265
  */
2147
- function matchesExpectation(expectation) {
2148
- const current = readExpectation(expectation.path);
2149
- if (current === void 0) return false;
2150
- return current.shape === expectation.shape && current.device === expectation.device && current.inode === expectation.inode && current.modified === expectation.modified && current.size === expectation.size && current.digest === expectation.digest;
2151
- }
2266
+ var isMaterializerOptions = (0, _orkestrel_contract.recordOf)({
2267
+ host: (0, _orkestrel_contract.unionOf)(isFilesystemPath, isHost),
2268
+ on: isMaterializerHooks,
2269
+ error: _orkestrel_contract.isFunction
2270
+ }, true);
2152
2271
  /**
2153
- * Tests whether a destination still matches the narrower state a caller observed.
2272
+ * Narrows a value to the upstream reader's initial listener record.
2154
2273
  *
2155
- * @param precondition - The caller-observed state the write is held to.
2156
- * @returns True if the destination is absent as stated, or holds a physical file whose
2157
- * bytes digest to the stated value; false otherwise.
2274
+ * @remarks
2275
+ * Closed to the reader's own events for the same reason the materializer's
2276
+ * record is closed to its own.
2277
+ */
2278
+ var isUpstreamHooks = (0, _orkestrel_contract.recordOf)({
2279
+ release: _orkestrel_contract.isFunction,
2280
+ mirror: _orkestrel_contract.isFunction,
2281
+ file: _orkestrel_contract.isFunction,
2282
+ error: _orkestrel_contract.isFunction,
2283
+ destroy: _orkestrel_contract.isFunction
2284
+ }, true);
2285
+ /**
2286
+ * Narrows a value to {@link UpstreamOptions}.
2158
2287
  *
2159
2288
  * @remarks
2160
- * Narrower than {@link matchesExpectation} on purpose. A caller observed bytes,
2161
- * not inodes and timestamps, so binding a write to a device identity it never
2162
- * saw would refuse writes that are perfectly safe — a file rewritten to
2163
- * identical bytes by an editor is still the file the caller read. A precondition
2164
- * that states no digest claims presence only.
2289
+ * Each grouped endpoint is closed to its own leaves, so a setting written under
2290
+ * the wrong entity is refused rather than ignored. Every numeric leaf is a whole
2291
+ * number inside a ceiling: an unbounded concurrency, retry count, response
2292
+ * limit, or call budget is a way to exhaust the caller, so the ceiling is stated
2293
+ * here rather than left to the reader. The byte ceilings are the core
2294
+ * artifact and total-artifact limits, because a fetched guide is an artifact and
2295
+ * a whole call retains no more than a whole plan.
2165
2296
  *
2166
2297
  * @example
2167
2298
  * ```ts
2168
- * import { matchesPrecondition } from '@orkestrel/scaffold/server'
2299
+ * import { isUpstreamOptions } from '@orkestrel/scaffold/server'
2169
2300
  *
2170
- * matchesPrecondition({ path: '/tmp/project/new.md', shape: 'absent' }) // true while absent
2301
+ * isUpstreamOptions({ repository: { branch: 'main' }, concurrency: 4 }) // true
2302
+ * isUpstreamOptions({ concurrency: 0 }) // false
2171
2303
  * ```
2172
2304
  */
2173
- function matchesPrecondition(precondition) {
2174
- const status = (0, _orkestrel_contract.attempt)(() => (0, node_fs.lstatSync)(precondition.path));
2175
- if (!status.success) return precondition.shape === "absent" && matchesMissingPath(status.error);
2176
- if (precondition.shape !== "file" || !isPhysicalFile(precondition.path)) return false;
2177
- if (precondition.digest === void 0) return true;
2178
- return computeFileDigest(precondition.path) === precondition.digest;
2179
- }
2305
+ var isUpstreamOptions = (0, _orkestrel_contract.recordOf)({
2306
+ repository: (0, _orkestrel_contract.recordOf)({
2307
+ base: isEndpoint,
2308
+ branch: isBranch,
2309
+ timeout: isTimeout
2310
+ }, true),
2311
+ registry: (0, _orkestrel_contract.recordOf)({
2312
+ base: isEndpoint,
2313
+ timeout: isTimeout
2314
+ }, true),
2315
+ concurrency: (0, _orkestrel_contract.andOf)(_orkestrel_contract.isInteger, (0, _orkestrel_contract.boundsOf)(1, 64)),
2316
+ retries: (0, _orkestrel_contract.andOf)(_orkestrel_contract.isInteger, (0, _orkestrel_contract.boundsOf)(0, 5)),
2317
+ limit: (0, _orkestrel_contract.andOf)(_orkestrel_contract.isInteger, (0, _orkestrel_contract.boundsOf)(1, _src_core.MAX_ARTIFACT_BYTES)),
2318
+ budget: (0, _orkestrel_contract.andOf)(_orkestrel_contract.isInteger, (0, _orkestrel_contract.boundsOf)(1, _src_core.MAX_TOTAL_ARTIFACT_BYTES)),
2319
+ on: isUpstreamHooks,
2320
+ error: _orkestrel_contract.isFunction
2321
+ }, true);
2180
2322
  //#endregion
2181
2323
  //#region src/server/WriteTransaction.ts
2182
2324
  /**
@@ -2805,7 +2947,8 @@ var Materializer = class {
2805
2947
  *
2806
2948
  * @param plan - The compiled plan to compare.
2807
2949
  * @param target - The directory to inspect.
2808
- * @returns Findings for hydrated planned paths and selected foreign candidates.
2950
+ * @returns Findings for hydrated planned paths and selected foreign candidates,
2951
+ * plus non-blocking questions for present foreign mirrors differing from hosted guides.
2809
2952
  * @throws {@link ScaffoldError} coded `INVALID` when an argument is not the
2810
2953
  * exact shape, `TARGET` when the host or target cannot be read within its
2811
2954
  * bounds, and `DESTROYED` after teardown.
@@ -2914,9 +3057,9 @@ var Materializer = class {
2914
3057
  * the write cannot be staged or committed, and `DESTROYED` after teardown.
2915
3058
  *
2916
3059
  * @remarks
2917
- * A verdict carrying no bytes carries a cause instead, so it is skipped rather
2918
- * than written: one unreachable package never costs the caller the rest of the
2919
- * fetch, and it never empties a mirror it could not replace.
3060
+ * A failed or absent upstream guide uses the verified hosted guide only when
3061
+ * the observed target copy was absent. A present copy stays untouched. A guide
3062
+ * unavailable from the host is skipped, retaining the upstream verdict.
2920
3063
  */
2921
3064
  mirror(mirrors, target) {
2922
3065
  this.#assertAlive();
@@ -2925,23 +3068,33 @@ var Materializer = class {
2925
3068
  const writes = [];
2926
3069
  const skipped = [];
2927
3070
  const preconditions = [];
3071
+ let remaining = _src_core.MAX_TOTAL_ARTIFACT_BYTES;
2928
3072
  for (const fetched of accepted) {
2929
- if (fetched.lookup !== "found" || fetched.observed === (0, _src_core.contentToHex)(fetched.content)) {
3073
+ const hex = fetched.lookup === "found" ? (0, _src_core.contentToHex)(fetched.content) : fetched.observed === void 0 ? this.#reference(fetched.path, remaining) : void 0;
3074
+ if (hex === void 0 || fetched.observed === hex) {
2930
3075
  skipped.push(fetched.path);
2931
3076
  continue;
2932
3077
  }
3078
+ remaining -= hex.length / 2;
3079
+ if (remaining < 0) throw this.#error("TARGET", "The guide mirrors exceed the total artifact byte limit.");
2933
3080
  const current = readFileHex(directory, fetched.path);
2934
3081
  if (current !== fetched.observed) throw this.#error("TARGET", `The mirror at ${fetched.path} moved since it was fetched.`, {
2935
3082
  target: directory,
2936
3083
  path: fetched.path
2937
3084
  });
2938
3085
  preconditions.push(this.#bind(directory, fetched.path, current === void 0));
2939
- writes.push({
3086
+ writes.push(fetched.lookup === "found" ? {
2940
3087
  path: fetched.path,
2941
3088
  group: (0, _src_core.inferGroup)(fetched.path),
2942
3089
  ownership: "content",
2943
3090
  origin: "computed",
2944
3091
  content: fetched.content
3092
+ } : {
3093
+ path: fetched.path,
3094
+ group: "guides",
3095
+ ownership: "content",
3096
+ origin: "host",
3097
+ hex
2945
3098
  });
2946
3099
  }
2947
3100
  return this.#apply(directory, writes, [], skipped, preconditions);
@@ -3089,7 +3242,7 @@ var Materializer = class {
3089
3242
  }
3090
3243
  #verify(host) {
3091
3244
  const { entries, roots } = host.manifest;
3092
- if (host.manifest.digest !== computeManifestDigest(entries, roots)) throw this.#error("TARGET", "The vendored host manifest does not cover the membership beside it.");
3245
+ if (host.manifest.digest !== computeManifestDigest(entries, roots, host.manifest.surface)) throw this.#error("TARGET", "The vendored host manifest does not cover the membership beside it.");
3093
3246
  if (this.#entries.size !== entries.length) throw this.#error("TARGET", "The vendored host manifest maps two files to one destination.");
3094
3247
  if (new Set(entries.map((entry) => entry.storage)).size !== entries.length) throw this.#error("TARGET", "The vendored host manifest maps two destinations to one file.");
3095
3248
  const held = Object.keys(host.bytes);
@@ -3152,11 +3305,39 @@ var Materializer = class {
3152
3305
  for (const name of listFiles(directory)) paths.add(`${root}/${name}`);
3153
3306
  }
3154
3307
  for (const path of listCanonPaths(target, plan.groups)) paths.add(path);
3308
+ const questions = [];
3309
+ if (plan.groups.includes("guides")) {
3310
+ const directory = resolveContainedPath(target, "guides");
3311
+ let remaining = _src_core.MAX_TOTAL_ARTIFACT_BYTES;
3312
+ for (const file of directory === void 0 ? [] : listFiles(directory)) {
3313
+ const path = `guides/${file}`;
3314
+ if (file.includes("/") || file === "README.md" || !file.endsWith(".md") || path === (0, _src_core.nameToGuide)(plan.blueprint.name)) continue;
3315
+ const hosted = this.#reference(path, remaining);
3316
+ if (hosted === void 0) continue;
3317
+ remaining -= hosted.length / 2;
3318
+ const present = readFileHex(target, path);
3319
+ if (present !== void 0 && present !== hosted) questions.push({
3320
+ field: "guides",
3321
+ message: `The mirror at ${path} differs from the hosted guide. Run catalog to refresh it.`,
3322
+ blocking: false
3323
+ });
3324
+ }
3325
+ }
3155
3326
  return {
3156
3327
  findings: (0, _src_core.planToFindings)(hydrated, readSnapshot(target, [...paths])),
3157
- questions: []
3328
+ questions
3158
3329
  };
3159
3330
  }
3331
+ #reference(path, budget) {
3332
+ const entry = this.#entries.get(path);
3333
+ if (entry !== void 0) {
3334
+ const hex = this.#read(entry, budget);
3335
+ if (hexToDigest(hex) !== entry.digest) throw this.#error("TARGET", `The hosted guide at ${path} misses its declared digest.`, { path });
3336
+ return hex;
3337
+ }
3338
+ if (this.#manifest !== void 0 || this.#root === void 0) return void 0;
3339
+ return readFileHex(this.#root, path, Math.max(0, Math.min(_src_core.MAX_ARTIFACT_BYTES, budget)));
3340
+ }
3160
3341
  #roots(plan) {
3161
3342
  const roots = /* @__PURE__ */ new Set();
3162
3343
  for (const artifact of plan.artifacts) {
@@ -3971,7 +4152,7 @@ var Upstream = class {
3971
4152
  lookup: "failed",
3972
4153
  note: `the vendored inventory at ${url} is not a readable manifest`
3973
4154
  };
3974
- if (manifest.digest !== computeManifestDigest(manifest.entries, manifest.roots)) return {
4155
+ if (manifest.digest !== computeManifestDigest(manifest.entries, manifest.roots, manifest.surface)) return {
3975
4156
  ...empty,
3976
4157
  lookup: "failed",
3977
4158
  note: `the vendored inventory at ${url} does not match its own membership digest`
@@ -4375,6 +4556,8 @@ exports.readHostFloor = readHostFloor;
4375
4556
  exports.readHostManifest = readHostManifest;
4376
4557
  exports.readManifestEntry = readManifestEntry;
4377
4558
  exports.readSnapshot = readSnapshot;
4559
+ exports.readSurfaceBaseline = readSurfaceBaseline;
4560
+ exports.readSurfaceCollisions = readSurfaceCollisions;
4378
4561
  exports.resolveContainedPath = resolveContainedPath;
4379
4562
  exports.resolveRealPath = resolveRealPath;
4380
4563
  exports.stageBytes = stageBytes;