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