@dtmd/temper 0.0.6 → 0.0.8

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 (50) hide show
  1. package/README.md +84 -53
  2. package/bin/temper.js +52 -0
  3. package/dist/src/assembly.d.ts +15 -1
  4. package/dist/src/assembly.js +2 -1
  5. package/dist/src/builtins.d.ts +765 -48
  6. package/dist/src/builtins.js +767 -68
  7. package/dist/src/claude-code.d.ts +2 -2
  8. package/dist/src/claude-code.js +1 -1
  9. package/dist/src/contract.d.ts +180 -29
  10. package/dist/src/contract.js +128 -15
  11. package/dist/src/declarations.d.ts +92 -6
  12. package/dist/src/declarations.js +448 -102
  13. package/dist/src/dial.d.ts +75 -0
  14. package/dist/src/dial.js +82 -0
  15. package/dist/src/emit.d.ts +82 -1
  16. package/dist/src/emit.js +402 -56
  17. package/dist/src/generated/AssemblyFactRow.d.ts +2 -2
  18. package/dist/src/generated/BoundRow.d.ts +2 -2
  19. package/dist/src/generated/ClauseRow.d.ts +73 -3
  20. package/dist/src/generated/CollectionAddressRow.d.ts +21 -0
  21. package/dist/src/generated/CollectionAddressRow.js +2 -0
  22. package/dist/src/generated/Declarations.d.ts +17 -0
  23. package/dist/src/generated/EmbeddedMember.d.ts +3 -3
  24. package/dist/src/generated/FeatureValue.d.ts +2 -2
  25. package/dist/src/generated/Features.d.ts +52 -3
  26. package/dist/src/generated/KindFactRow.d.ts +38 -7
  27. package/dist/src/generated/MentionRow.d.ts +5 -3
  28. package/dist/src/generated/NestedMemberRow.d.ts +32 -0
  29. package/dist/src/generated/PayloadMember.d.ts +6 -0
  30. package/dist/src/generated/RegistrationRow.d.ts +35 -0
  31. package/dist/src/generated/RegistrationRow.js +2 -0
  32. package/dist/src/generated/RequirementRow.d.ts +4 -2
  33. package/dist/src/generated/SatisfiesRow.d.ts +2 -1
  34. package/dist/src/generated/SettingsRow.d.ts +25 -0
  35. package/dist/src/generated/SettingsRow.js +2 -0
  36. package/dist/src/generated/Shape.d.ts +15 -0
  37. package/dist/src/generated/Shape.js +2 -0
  38. package/dist/src/generated/TemplateRow.d.ts +24 -0
  39. package/dist/src/generated/TemplateRow.js +2 -0
  40. package/dist/src/generated/ValueType.d.ts +11 -2
  41. package/dist/src/generated/Verifier.d.ts +20 -0
  42. package/dist/src/generated/Verifier.js +2 -0
  43. package/dist/src/generated/index.d.ts +6 -0
  44. package/dist/src/index.d.ts +8 -8
  45. package/dist/src/index.js +3 -3
  46. package/dist/src/kind.d.ts +160 -29
  47. package/dist/src/kind.js +47 -16
  48. package/dist/src/prose.d.ts +81 -25
  49. package/dist/src/prose.js +91 -21
  50. package/package.json +10 -2
package/dist/src/emit.js CHANGED
@@ -10,9 +10,16 @@
10
10
  */
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { readFileSync } from "node:fs";
13
- import { renderText, resolveLeaf } from "./prose.js";
13
+ import { checkMentions, isTextSpan, renderText, resolveLeaf } from "./prose.js";
14
14
  import { permissionUnion } from "./needs.js";
15
- import { compareStrings, compileDeclarations, declaredAddresses, declaredRequirements, encodeSeam, } from "./declarations.js";
15
+ import { compareStrings, compileDeclarations, declaredAddresses, declaredAtLocusKinds, declaredRequirements, encodeSeam, placementKey, registrationRows, settingsRows, tapHookRows, } from "./declarations.js";
16
+ /** The {@link MentionScope} a set of {@link ResolveOptions} names — its two sets, each defaulting to empty. */
17
+ function scopeOf(options) {
18
+ return {
19
+ mentionable: options.mentionable ?? new Set(),
20
+ deferrableKinds: options.deferrableKinds ?? new Set(),
21
+ };
22
+ }
16
23
  /**
17
24
  * TOML-quote a leaf's authored text into a basic-string literal — the escapes
18
25
  * `toml_edit`'s parser reads back (backslash, quote, and the C0 control set),
@@ -49,9 +56,167 @@ function tomlString(text) {
49
56
  }
50
57
  return out + '"';
51
58
  }
59
+ /** Join path parts with `/`, dropping the empties and the `.` root a root-locus kind carries. */
60
+ function joinSlash(...parts) {
61
+ return parts.filter((part) => part !== "" && part !== ".").join("/");
62
+ }
63
+ /**
64
+ * `name` spliced through `pattern`'s single `*` — the one name-through-a-glob map, shared
65
+ * by a flat `at` glob and a host template's path pattern. A `*`-free pattern is a fixed
66
+ * path, left verbatim. `starredSegment` admits a single-`*` segment glob whose one `*` stars
67
+ * a whole leading directory segment (a starred-segment kind's locus), landing `<name>/<file>`;
68
+ * every other caller passes `false`, where a `/` beside the `*` is a stray directory the
69
+ * splice cannot place.
70
+ *
71
+ * # Throws
72
+ * If `pattern` carries a `*` yet is neither single-star nor single-segment (and not the
73
+ * admitted leading-segment case): the splice would leave a stray literal `*` or directory
74
+ * segment behind.
75
+ */
76
+ function spliceName(kindName, pattern, name, starredSegment) {
77
+ const stars = pattern.split("*").length - 1;
78
+ const leadingSegment = starredSegment && stars === 1 && pattern.startsWith("*/");
79
+ if (stars > 0 && !leadingSegment && (stars > 1 || pattern.includes("/"))) {
80
+ throw new Error(`kind \`${kindName}\`: glob \`${pattern}\` is neither a single-segment single-\`*\` ` +
81
+ `pattern nor an any-depth \`**\` glob — a member name splices through neither.`);
82
+ }
83
+ return pattern.replace("*", name);
84
+ }
85
+ /**
86
+ * The unit `host`'s file children compose their paths under — a directory unit's own
87
+ * directory, since a template's path pattern is relative to the parent's unit.
88
+ *
89
+ * # Throws
90
+ * If the host owns no directory unit: a lone file has no interior for a child to sit in.
91
+ */
92
+ function hostUnit(host, context) {
93
+ if (host.facts.locus.kind === "at" && host.facts.unitShape === "directory") {
94
+ return joinSlash(host.facts.locus.root, host.name);
95
+ }
96
+ throw new Error(`${context}: its host \`${host.kind}:${host.name}\` owns no directory unit — a template's ` +
97
+ `path pattern is relative to the host's unit, and a lone file has no interior for a ` +
98
+ `child to sit in (specs/model/representation.md, "locus").`);
99
+ }
100
+ /**
101
+ * A nested file child's harness-relative locus: its host member's unit joined with the
102
+ * host template's path pattern, its own name spliced through the pattern. The pattern is
103
+ * the host kind's declared fact and the child kind governs no glob, so one home owns the
104
+ * path and no child contends with its host's own locus.
105
+ *
106
+ * # Throws
107
+ * If the child names no host, or its host's kind templates no file layer for the child's
108
+ * kind — there is no pattern to compose against.
109
+ */
110
+ function nestedFilePath(member) {
111
+ const context = `member \`${member.name}\` of kind \`${member.kind}\``;
112
+ const host = member.host;
113
+ if (host === undefined) {
114
+ throw new Error(`${context}: a nested file child names the host its path composes under.`);
115
+ }
116
+ const template = (host.facts.templates ?? []).find((layer) => layer.kind.key === member.kind && layer.path !== undefined);
117
+ if (template?.path === undefined) {
118
+ throw new Error(`${context}: its host \`${host.kind}:${host.name}\` templates no file layer for kind ` +
119
+ `\`${member.kind}\` — the path pattern is the host kind's declared fact, and there is ` +
120
+ `none to compose against (specs/model/representation.md, "locus").`);
121
+ }
122
+ return joinSlash(hostUnit(host, context), spliceName(member.kind, template.path, member.name, false));
123
+ }
124
+ /**
125
+ * The harness-relative locus `member` projects onto: a directory unit lands its entry
126
+ * file under `<root>/<name>/`; a lone file splices the name through the glob's single
127
+ * `*` (an any-depth glob — a memory kind's `**\/CLAUDE.md` — lands the root `<name>.md`,
128
+ * and a `*`-free glob is a fixed path left verbatim); a nested file child composes its
129
+ * path under its host's unit ({@link nestedFilePath}). The engine derives the same locus
130
+ * from the same facts (`src/drift.rs`'s `member_projection_path`); the two must agree,
131
+ * since a hook's rendered link is written from this side and reaped from that one.
132
+ *
133
+ * # Throws
134
+ * If the kind is embedded (no standalone projection), or the member's glob or host
135
+ * template pattern maps its name to no one path ({@link spliceName},
136
+ * {@link nestedFilePath}).
137
+ */
138
+ function projectionPath(member) {
139
+ const facts = member.facts;
140
+ if (facts.locus.kind === "embedded") {
141
+ throw new Error(`kind \`${facts.name}\` is embedded — its members live inside a host body and ` +
142
+ `carry no standalone projection (specs/model/representation.md, "locus").`);
143
+ }
144
+ if (facts.locus.kind === "nested-file")
145
+ return nestedFilePath(member);
146
+ const { root, glob } = facts.locus;
147
+ if (facts.unitShape === "directory") {
148
+ const slash = glob.indexOf("/");
149
+ return joinSlash(root, member.name, slash < 0 ? glob : glob.slice(slash + 1));
150
+ }
151
+ if (glob.includes("**"))
152
+ return joinSlash(root, `${member.name}.md`);
153
+ const starredSegment = facts.unitShape === "starred-segment";
154
+ return joinSlash(root, spliceName(facts.name, glob, member.name, starredSegment));
155
+ }
156
+ /**
157
+ * `to`'s path as read from the document at `from` — the shared leading segments drop
158
+ * and each of `from`'s remaining directory segments becomes a `..`, so a rendered link
159
+ * resolves from wherever the host member's own projection lands.
160
+ */
161
+ function relativeProjection(from, to) {
162
+ const fromDirs = from.split("/").slice(0, -1);
163
+ const toParts = to.split("/");
164
+ let shared = 0;
165
+ while (shared < fromDirs.length && shared < toParts.length - 1 && fromDirs[shared] === toParts[shared]) {
166
+ shared += 1;
167
+ }
168
+ return [...fromDirs.slice(shared).map(() => ".."), ...toParts.slice(shared)].join("/");
169
+ }
170
+ /**
171
+ * The closed, engine-derived facts about one embedded value's edge-field targets,
172
+ * keyed by edge field: the declaring kind names which leaves are addresses, and each
173
+ * address resolves against the program's composed members — the same table a mention
174
+ * resolves against. The facts are read off the resolved target, so a format that
175
+ * selects them renders a reference true by construction; the four are the whole set.
176
+ *
177
+ * An unfilled leaf is no edge, so it contributes no entry: requiredness is the kind's
178
+ * own field schema, which fails in the author's program at compose time.
179
+ *
180
+ * # Throws
181
+ * If a filled leaf names no composed member, or names one that owns no projection to
182
+ * point at. An edge target cannot defer to the gate the way a bare mention may: the
183
+ * reference is written now, and there is nothing true to write.
184
+ */
185
+ function edgeTargetFacts(host, value, leaves, options) {
186
+ const targets = {};
187
+ const context = `member \`${host.name}\`: embedded value \`${value.key}\` of kind \`${value.kind}\``;
188
+ for (const edge of value.edgeFields ?? []) {
189
+ const address = leaves[edge.field];
190
+ if (address === undefined || address === "")
191
+ continue;
192
+ // A one-element `to` set resolves a bare address within its one kind; a
193
+ // multi-element set reads the kind-qualified `kind:name` the author wrote
194
+ // (`EdgeField.to`). An already-qualified address carries its own colon, so
195
+ // only a bare leaf is lifted to `${edge.to[0]}:${address}` for the lookup.
196
+ const lookup = edge.to.length === 1 && !address.includes(":") ? `${edge.to[0]}:${address}` : address;
197
+ const target = options.members?.get(lookup);
198
+ if (target === undefined) {
199
+ throw new Error(`${context}: edge field \`${edge.field}\` names \`${address}\`, which resolves to no ` +
200
+ `composed member — an edge target's facts are derived, never fabricated ` +
201
+ `(specs/model/pipeline.md, "Emit", the "Refusing" bullet).`);
202
+ }
203
+ if (!isProjected(target)) {
204
+ throw new Error(`${context}: edge field \`${edge.field}\` names \`${address}\`, which owns no ` +
205
+ `projection to reference (specs/model/representation.md, "locus").`);
206
+ }
207
+ targets[edge.field] = {
208
+ name: target.name,
209
+ address: lookup,
210
+ kind: target.kind,
211
+ path: relativeProjection(projectionPath(host), projectionPath(target)),
212
+ };
213
+ }
214
+ return targets;
215
+ }
52
216
  /**
53
217
  * Resolve one embedded member's value's leaves — top-level and each
54
- * collection entry's — to their final stored strings: a `Text`-authored leaf
218
+ * collection entry's — to their final stored strings, and derive its edge fields'
219
+ * target facts off the resolved leaves: a `Text`-authored leaf
55
220
  * resolves the way `resolveBody` resolves a member-level `Text` body (mention
56
221
  * resolution-checked against `mentionable`, loud on a dangling address); a
57
222
  * bare-string leaf is unchanged. The one resolution point shared by the
@@ -59,23 +224,30 @@ function tomlString(text) {
59
224
  * embedded-kind leaf mention never depends on whether the kind declares
60
225
  * `render` (`pipeline.md`, "Emit", the "Refusing" bullet).
61
226
  */
62
- function resolveMemberLeaves(value, mentionable) {
227
+ function resolveMemberLeaves(host, value, options) {
228
+ const scope = scopeOf(options);
63
229
  const context = (childPath) => `member.${value.kind} ${value.key}: leaf \`${childPath}\``;
64
230
  const leaves = {};
65
231
  for (const [key, leaf] of Object.entries(value.leaves)) {
66
- leaves[key] = resolveLeaf(leaf, mentionable, context(key));
232
+ leaves[key] = resolveLeaf(leaf, scope, context(key));
67
233
  }
68
234
  const collections = {};
69
235
  for (const [collection, entries] of Object.entries(value.collections)) {
70
236
  collections[collection] = entries.map((entry) => {
71
237
  const entryLeaves = {};
72
238
  for (const [leaf, text] of Object.entries(entry.leaves)) {
73
- entryLeaves[leaf] = resolveLeaf(text, mentionable, context(`${collection}.${entry.key}.${leaf}`));
239
+ entryLeaves[leaf] = resolveLeaf(text, scope, context(`${collection}.${entry.key}.${leaf}`));
74
240
  }
75
241
  return { key: entry.key, leaves: entryLeaves };
76
242
  });
77
243
  }
78
- return { kind: value.kind, key: value.key, leaves, collections };
244
+ return {
245
+ kind: value.kind,
246
+ key: value.key,
247
+ leaves,
248
+ collections,
249
+ targets: edgeTargetFacts(host, value, leaves, options),
250
+ };
79
251
  }
80
252
  /**
81
253
  * Render one resolved embedded member's interior TOML: its top-level leaves,
@@ -101,24 +273,163 @@ function renderMemberToml(value) {
101
273
  return lines.join("\n");
102
274
  }
103
275
  /**
104
- * Render one embedded member's value to its `member.<kind> <key>` fenced block:
105
- * the originating kind's own `render` hook, when declared, in place of the
106
- * default `[collection.entry]` TOML view — the fence wrapper itself never
107
- * changes, so a `render`-less kind's projection is byte-unchanged. Leaves
108
- * resolve once (`resolveMemberLeaves`) before either path sees them, so a
276
+ * Render one embedded member's value to its projected block. A `render`-less
277
+ * kind projects the default `[collection.entry]` TOML view wrapped in a
278
+ * `member.<kind> <key>` fence, byte-unchanged. A kind that declares a `render`
279
+ * hook projects the hook's output directly, with no fence: an embedded format
280
+ * is writer-only and unconstrained when its host is composed (`representation.md`,
281
+ * "kind") — the engine never reads the block back (nested-member facts ride the
282
+ * lock, `pipeline.md`, "The lock"), so the fence is cosmetic and a hook that
283
+ * already renders readable markdown should not be re-buried in a code fence.
284
+ * Leaves resolve once (`resolveMemberLeaves`) before either path sees them, so a
109
285
  * hook receives plain strings, never a raw `Text` leaf.
110
286
  */
111
- function renderMemberFence(value, options) {
112
- const mentionable = options.mentionable ?? new Set();
113
- const resolved = resolveMemberLeaves(value, mentionable);
114
- const body = value.render !== undefined ? value.render(resolved) : renderMemberToml(resolved);
115
- return `\`\`\`member.${value.kind} ${value.key}\n${body}\n\`\`\``;
287
+ function renderMemberBlock(host, value, options) {
288
+ const resolved = resolveMemberLeaves(host, value, options);
289
+ if (value.render !== undefined)
290
+ return value.render(resolved);
291
+ return `\`\`\`member.${value.kind} ${value.key}\n${renderMemberToml(resolved)}\n\`\`\``;
292
+ }
293
+ /**
294
+ * A recording view of a resolved value: every read of an edge field's key — off the
295
+ * derived `targets` facts or off the `leaves` that authored its address — is collected
296
+ * into `placed`. Those two are the whole surface an edge's data can reach a format
297
+ * through, so a format that touches neither placed nothing.
298
+ *
299
+ * Placement is observed as *selection*, which bounds the check in one direction only: a
300
+ * format that reads an edge and discards it reads as placed. That keeps the predicate
301
+ * free of false positives, which is what earns it the gate — a format that never names
302
+ * the edge, the case the check exists for, is caught exactly.
303
+ */
304
+ function recordingView(resolved, edgeFields, placed) {
305
+ const watch = (record) => new Proxy(record, {
306
+ get(target, property, receiver) {
307
+ if (typeof property === "string" && edgeFields.has(property))
308
+ placed.add(property);
309
+ return Reflect.get(target, property, receiver);
310
+ },
311
+ });
312
+ return { ...resolved, leaves: watch(resolved.leaves), targets: watch(resolved.targets) };
313
+ }
314
+ /**
315
+ * The declared edge fields one embedded value's format placed, sorted — the fact a
316
+ * `format-places-edges` clause decides over, since the engine never sees a format and
317
+ * never reads a rendering back. A `render`-less kind takes the default TOML view, which
318
+ * writes every leaf, so every edge the value fills is placed by construction; a kind that
319
+ * declares one runs the hook against a {@link recordingView} and reports what it
320
+ * selected.
321
+ *
322
+ * The obligation ranges over the edges this value *fills*, never its kind's whole
323
+ * declared set: an unfilled field is no edge, so a format cannot omit it. `undefined`
324
+ * when the value fills none — there is nothing to place, so the row records nothing
325
+ * rather than an empty column on every ordinary value.
326
+ *
327
+ * This renders the value a second time, the way `nestedMemberRow` reads its leaves a
328
+ * second time: a hook is pure (emit double-verifies its own bytes), so the observing
329
+ * render and the projecting one cannot disagree.
330
+ */
331
+ function placedEdges(host, value, options) {
332
+ if ((value.edgeFields ?? []).length === 0)
333
+ return undefined;
334
+ const resolved = resolveMemberLeaves(host, value, options);
335
+ // `targets` carries exactly the filled edge fields — an unfilled one derives no facts.
336
+ const edgeFields = new Set(Object.keys(resolved.targets));
337
+ if (edgeFields.size === 0)
338
+ return undefined;
339
+ if (value.render === undefined)
340
+ return [...edgeFields].sort(compareStrings);
341
+ const placed = new Set();
342
+ value.render(recordingView(resolved, edgeFields, placed));
343
+ return [...placed].sort(compareStrings);
344
+ }
345
+ /**
346
+ * Every composed embedded value's placed edge fields, keyed by the value's
347
+ * {@link placementKey} — what `emit` hands {@link compileDeclarations} so each
348
+ * `nested_member` row carries its own format's placement record. Iterates exactly the
349
+ * values `nestedMemberRows` does, so every edge-bearing row it builds has an observation.
350
+ */
351
+ export function edgePlacements(harness, options) {
352
+ const placements = new Map();
353
+ for (const member of harness.members) {
354
+ if (member.prose?.kind !== "blocks")
355
+ continue;
356
+ for (const value of member.prose.values) {
357
+ if (isTextSpan(value))
358
+ continue;
359
+ const placed = placedEdges(member, value, options);
360
+ if (placed !== undefined) {
361
+ placements.set(placementKey(`${member.kind}:${member.name}`, value.kind, value.key), placed);
362
+ }
363
+ }
364
+ }
365
+ return placements;
366
+ }
367
+ /**
368
+ * The line count of a rendered block, matching the engine's `str::lines()`: a single
369
+ * trailing newline is absorbed (a block and the same block plus one `\n` span the same),
370
+ * and an empty block spans none. Kept in step with `src/extract.rs`'s file-side count so a
371
+ * budget reads one member the same whether it is a file or an embedded projection.
372
+ */
373
+ function renderedLineCount(block) {
374
+ if (block.length === 0)
375
+ return 0;
376
+ const body = block.endsWith("\n") ? block.slice(0, -1) : block;
377
+ return body.split("\n").length;
378
+ }
379
+ /**
380
+ * Every composed embedded value's rendered extent — the line and character count of the
381
+ * block `emit` projected for it — keyed by its {@link placementKey}, what `emit` hands
382
+ * {@link compileDeclarations} so each `nested_member` row carries the span an `extent`
383
+ * clause budgets. Iterates exactly the values {@link edgePlacements} does, rendering each
384
+ * through the same {@link renderMemberBlock} the body projection uses (a hook is pure, so
385
+ * the measured render and the projected one cannot disagree), never a second renderer.
386
+ *
387
+ * A value the SDK composes is always rendered here, so it always captures a span; a value
388
+ * no format rendered — an embedded member read off a layout host's source — is lowered by
389
+ * the engine, not this pass, and reaches its row with no span (the `placed_edges`
390
+ * distinction between an observed empty and an unobserved absence).
391
+ */
392
+ export function renderedExtents(harness, options) {
393
+ const extents = new Map();
394
+ for (const member of harness.members) {
395
+ if (member.prose?.kind !== "blocks")
396
+ continue;
397
+ for (const value of member.prose.values) {
398
+ if (isTextSpan(value))
399
+ continue;
400
+ const block = renderMemberBlock(member, value, options);
401
+ extents.set(placementKey(`${member.kind}:${member.name}`, value.kind, value.key), {
402
+ lines: renderedLineCount(block),
403
+ // Unicode scalar values, matching Rust's `chars().count()` — iterating a string
404
+ // yields code points, so a surrogate pair counts once, the way it does file-side.
405
+ chars: [...block].length,
406
+ });
407
+ }
408
+ }
409
+ return extents;
410
+ }
411
+ /**
412
+ * Render a member-level `Text` body to its final bytes: its mentions are
413
+ * resolution-checked against `scope` ({@link checkMentions}: loud on a dangling
414
+ * address, a discovery-locus one deferred; `context` naming the host) and the
415
+ * display rule applied, each include slot left standing for the engine to splice.
416
+ * Shared by a `text` body and a composed body's prose spans, so a narrative span
417
+ * resolves the identical way a member-level `text` body does.
418
+ *
419
+ * # Throws
420
+ * If a mention names no declared value and has no discovery locus.
421
+ */
422
+ function renderTextBody(prose, scope, context) {
423
+ checkMentions(prose.mentions, scope, context);
424
+ return renderText(prose);
116
425
  }
117
426
  /**
118
427
  * Resolve a member's prose to its final body bytes: a `file()` asset is read in
119
428
  * byte-for-byte; a `text` body's mentions are resolution-checked (loud on a
120
- * dangling address) and rendered by the one display rule; a `blocks()` body
121
- * renders each embedded member as a `member.<kind> <key>` TOML fence. The words
429
+ * dangling address) and rendered by the one display rule; a `blocks()` composed
430
+ * body renders each child in authored order — a prose span as its resolved words
431
+ * (`renderTextBody`), an embedded member as a `member.<kind> <key>` TOML fence
432
+ * (or, for a kind with a `render` hook, the hook's fence-free markdown). The words
122
433
  * are never reworded.
123
434
  *
124
435
  * # Throws
@@ -138,30 +449,31 @@ function resolveBody(member, options) {
138
449
  `(looked at \`${assetPath}\`).`, { cause });
139
450
  }
140
451
  }
452
+ const scope = scopeOf(options);
141
453
  if (prose.kind === "blocks") {
142
- return prose.values.map((value) => renderMemberFence(value, options)).join("\n\n") + "\n";
143
- }
144
- const mentionable = options.mentionable ?? new Set();
145
- for (const mention of prose.mentions) {
146
- if (!mentionable.has(mention.target.address)) {
147
- throw new Error(`member \`${member.name}\`: mention of \`${mention.target.address}\` resolves to no ` +
148
- `declared value — a mention cannot dangle (specs/model/contract.md).`);
149
- }
454
+ const context = `member \`${member.name}\``;
455
+ return (prose.values
456
+ .map((value) => isTextSpan(value) ? renderTextBody(value, scope, context) : renderMemberBlock(member, value, options))
457
+ .join("\n\n") + "\n");
150
458
  }
151
- return renderText(prose);
459
+ return renderTextBody(prose, scope, `member \`${member.name}\``);
152
460
  }
153
461
  /**
154
- * The two declare-side refusals emit runs before it produces a byte:
155
- * a `satisfies` claim naming
156
- * no declared requirement (a dangling join), and a `required` requirement no
157
- * member fills (an unfilled required requirement).
462
+ * The one declare-side refusal emit runs before it produces a byte: a
463
+ * `satisfies` claim naming no declared requirement (a dangling join).
464
+ *
465
+ * Fill enforcement — every `required` requirement has ≥1 satisfier — is the
466
+ * engine's, not the SDK's: it lands over the composed members' `satisfies`
467
+ * *plus* the fill rows emit derives from a layout document's `satisfies` edge
468
+ * slot, which the SDK never reads. A pre-flight over composed `satisfies`
469
+ * alone would spuriously refuse a requirement a layout host fills, so the SDK
470
+ * implements no semantics here and defers to the engine's requirement clause.
158
471
  *
159
472
  * # Throws
160
- * On a dangling `satisfies` join or an unfilled `required` requirement.
473
+ * On a dangling `satisfies` join.
161
474
  */
162
475
  function refuseBrokenSource(harness) {
163
476
  const requirements = declaredRequirements(harness);
164
- const filled = new Set();
165
477
  for (const member of harness.members) {
166
478
  for (const name of member.satisfies) {
167
479
  if (!requirements.has(name)) {
@@ -169,31 +481,24 @@ function refuseBrokenSource(harness) {
169
481
  `harness-level or member-published requirement declares — a dangling join ` +
170
482
  `(specs/model/pipeline.md, "Emit", the "Refusing" bullet).`);
171
483
  }
172
- filled.add(name);
173
- }
174
- }
175
- const requiredSources = [];
176
- for (const [name, requirement] of Object.entries(harness.require)) {
177
- if (requirement.required)
178
- requiredSources.push([name, "the assembly"]);
179
- }
180
- for (const member of harness.members) {
181
- for (const [name, requirement] of Object.entries(member.requires)) {
182
- if (requirement.required)
183
- requiredSources.push([name, `member \`${member.name}\``]);
184
- }
185
- }
186
- for (const [name, source] of requiredSources) {
187
- if (!filled.has(name)) {
188
- throw new Error(`required requirement \`${name}\` (declared by ${source}) is filled by no member's ` +
189
- `\`satisfies\` — an unfilled required requirement ` +
190
- `(specs/model/pipeline.md, "Emit", the "Refusing" bullet).`);
191
484
  }
192
485
  }
193
486
  }
194
- /** A member is projected iff its kind lives at a path locus (an embedded member is not). */
487
+ /**
488
+ * A fields-only registration member (a hook, an MCP server) surfaces embedded in a
489
+ * host manifest, so it owns no standalone artifact — its facts erase into a
490
+ * {@link RegistrationFact} for the manifest write face, never a projected member.
491
+ */
492
+ function isRegistration(member) {
493
+ return member.facts.shape === "fields";
494
+ }
495
+ /**
496
+ * A member is projected iff it owns a file — at its kind's governed glob or composed
497
+ * under its host's unit — and is not a fields-only registration member. An embedded
498
+ * member and a registration member each carry no standalone projection.
499
+ */
195
500
  function isProjected(member) {
196
- return member.facts.locus.kind === "at";
501
+ return member.facts.locus.kind !== "embedded" && !isRegistration(member);
197
502
  }
198
503
  /**
199
504
  * The resolved absolute path of a `file()` prose asset, or `undefined` for
@@ -211,6 +516,42 @@ function fileSourcePath(member) {
211
516
  return undefined;
212
517
  return fileURLToPath(new URL(prose.path, prose.moduleUrl));
213
518
  }
519
+ /**
520
+ * The harness's registration write facts as the public {@link RegistrationFact} view —
521
+ * the seam's own `registration` rows mapped to the nested `collectionAddress` shape the
522
+ * `EmitResult` sibling exposes, so the two cannot disagree on what a manifest carries.
523
+ * The fields-only registration members ({@link registrationRows}) and the memberless tap
524
+ * hooks a telemetry verifier synthesizes ({@link tapHookRows}) fold in together, the same
525
+ * union `compileDeclarations` writes into `declarations.registrations`.
526
+ *
527
+ * # Throws
528
+ * If a fields-only member declares no collection address — it surfaces in no manifest.
529
+ */
530
+ function registrationFacts(harness) {
531
+ return [...registrationRows(harness), ...tapHookRows(harness)].map((row) => ({
532
+ kind: row.kind,
533
+ key: row.key,
534
+ collectionAddress: { manifest: row.manifest, keyPath: row.key_path },
535
+ fields: row.fields,
536
+ }));
537
+ }
538
+ /**
539
+ * The harness's residual settings keys as the public {@link SettingsResidue} view — the
540
+ * seam's own `settings` rows ({@link settingsRows}) surfaced under the `EmitResult` sibling,
541
+ * so the two cannot disagree on what a manifest's residue carries. Key-sorted, the same
542
+ * byte-stable order the seam family takes.
543
+ */
544
+ function settingsResidue(harness) {
545
+ return settingsRows(harness).map((row) => ({ manifest: row.manifest, key: row.key, value: row.value }));
546
+ }
547
+ /**
548
+ * The harness's composed members by `kind:name` address — the table an embedded value's
549
+ * edge field resolves its target against. Keyed the identical way {@link declaredAddresses}
550
+ * spells a member address, so an edge field and a mention name a member the same way.
551
+ */
552
+ function memberTable(harness) {
553
+ return new Map(harness.members.map((member) => [`${member.kind}:${member.name}`, member]));
554
+ }
214
555
  /** The harness's projected members as payload members, deterministically kind-then-name ordered. */
215
556
  function orderedMembers(harness, options) {
216
557
  return [...harness.members]
@@ -219,6 +560,7 @@ function orderedMembers(harness, options) {
219
560
  .map((member) => ({
220
561
  kind: member.kind,
221
562
  name: member.name,
563
+ host: member.host && `${member.host.kind}:${member.host.name}`,
222
564
  // The generated row carries a mutable field list; the member's is read-only,
223
565
  // so copy each pair into a fresh tuple — the same values, a shape the row accepts.
224
566
  fields: member.fields.map(([name, value]) => [name, value]),
@@ -237,15 +579,19 @@ export function emit(harness) {
237
579
  refuseBrokenSource(harness);
238
580
  const resolve = {
239
581
  mentionable: declaredAddresses(harness),
582
+ deferrableKinds: declaredAtLocusKinds(harness),
583
+ members: memberTable(harness),
240
584
  };
241
585
  const compile = () => {
242
586
  const members = orderedMembers(harness, resolve);
243
- const declarations = compileDeclarations(harness);
587
+ const declarations = compileDeclarations(harness, edgePlacements(harness, resolve), renderedExtents(harness, resolve));
244
588
  return {
245
589
  declarations,
246
590
  members,
247
591
  seam: encodeSeam({ declarations, members }),
248
592
  permissions: permissionUnion(harness.members.flatMap((member) => [...member.needs])),
593
+ registrations: registrationFacts(harness),
594
+ settings: settingsResidue(harness),
249
595
  };
250
596
  };
251
597
  const first = compile();
@@ -23,7 +23,7 @@ export type AssemblyFactRow = {
23
23
  */
24
24
  field?: string;
25
25
  /**
26
- * An `edge` fact's target kind.
26
+ * An `edge` fact's target kinds — the non-empty set the field may resolve into.
27
27
  */
28
- to?: string;
28
+ to?: Array<string>;
29
29
  };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * A node-scope clause row's scalar bound — `min_len`'s `min`, `max_len`/`max_lines`'s
2
+ * A node-scope clause row's scalar bound — `min_len`'s `min`, `max_len`/`extent`'s
3
3
  * `max`, each endpoint optional so the row carries only what the predicate declared.
4
4
  */
5
5
  export type BoundRow = {
@@ -8,7 +8,7 @@ export type BoundRow = {
8
8
  */
9
9
  min?: number;
10
10
  /**
11
- * The inclusive upper bound, when the predicate declares one (`max_len`/`max_lines`).
11
+ * The inclusive upper bound, when the predicate declares one (`max_len`/`extent`).
12
12
  */
13
13
  max?: number;
14
14
  };