@dtmd/temper 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/src/assembly.d.ts +45 -0
- package/dist/src/assembly.js +23 -0
- package/dist/src/builtins.d.ts +57 -0
- package/dist/src/builtins.js +50 -0
- package/dist/src/contract.d.ts +84 -0
- package/dist/src/contract.js +36 -0
- package/dist/src/declarations.d.ts +62 -0
- package/dist/src/declarations.js +133 -0
- package/dist/src/emit.d.ts +70 -0
- package/dist/src/emit.js +187 -0
- package/dist/src/genres.d.ts +65 -0
- package/dist/src/genres.js +57 -0
- package/dist/src/index.d.ts +32 -0
- package/dist/src/index.js +21 -0
- package/dist/src/kind.d.ts +138 -0
- package/dist/src/kind.js +63 -0
- package/dist/src/lock.d.ts +43 -0
- package/dist/src/lock.js +127 -0
- package/dist/src/needs.d.ts +31 -0
- package/dist/src/needs.js +28 -0
- package/dist/src/project.d.ts +79 -0
- package/dist/src/project.js +162 -0
- package/dist/src/prose.d.ts +78 -0
- package/dist/src/prose.js +0 -0
- package/dist/src/toml.d.ts +26 -0
- package/dist/src/toml.js +194 -0
- package/package.json +40 -0
package/dist/src/lock.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lock — tool-written provenance, emit fingerprints, and the program's
|
|
3
|
+
* declaration rows (`specs/architecture/20-surface.md`, "The lock and drift").
|
|
4
|
+
* Two row families: a per-member `[[<kind>]]` rollup (`name`, `source_path`,
|
|
5
|
+
* `source_hash`, `emit_hash`) and the `[declaration]` table's four sub-families
|
|
6
|
+
* (`[[declaration.kind]]`, `[[declaration.clause]]`, `[[declaration.requirement]]`,
|
|
7
|
+
* `[[declaration.assembly]]`). Both are byte-identical to the Rust lock
|
|
8
|
+
* (`src/import.rs` `write_rollup`, `src/drift.rs` `Declarations::write_into`) — the
|
|
9
|
+
* byte-parity lockstep two writers keep until single-writer lands.
|
|
10
|
+
*
|
|
11
|
+
* Fingerprints are SHA-256 hex over raw UTF-8 (`hash::sha256_hex`), so an
|
|
12
|
+
* SDK-emitted lock and a Rust-emitted lock agree for the same harness.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { joinSections, keyValue, encodeString } from "./toml.js";
|
|
16
|
+
/** Lowercase hex SHA-256 of `text`'s UTF-8 bytes — the Rust `sha256_hex` port. */
|
|
17
|
+
export function sha256Hex(text) {
|
|
18
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
19
|
+
}
|
|
20
|
+
/** The member name a projection encodes — the path's identity segment. */
|
|
21
|
+
function projectionName(projection) {
|
|
22
|
+
const segments = projection.path.split("/");
|
|
23
|
+
const last = segments[segments.length - 1];
|
|
24
|
+
if (last === "SKILL.md")
|
|
25
|
+
return segments[segments.length - 2];
|
|
26
|
+
return last.replace(/\.md$/, "");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The rollup row a projection stamps: both fingerprints are `sha256(projection
|
|
30
|
+
* bytes)`, the fresh-emit baseline (`source_hash == emit_hash`) a Rust import then
|
|
31
|
+
* emit lands on for a byte-identical projection.
|
|
32
|
+
*/
|
|
33
|
+
export function lockRow(kind, projection) {
|
|
34
|
+
const hash = sha256Hex(projection.bytes);
|
|
35
|
+
return {
|
|
36
|
+
kind,
|
|
37
|
+
name: projectionName(projection),
|
|
38
|
+
sourcePath: projection.path,
|
|
39
|
+
sourceHash: hash,
|
|
40
|
+
emitHash: hash,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** The rollup sections — one `[[<kind>]]` table per member, kinds then rows name-sorted. */
|
|
44
|
+
function rollupSections(rows) {
|
|
45
|
+
const byKind = new Map();
|
|
46
|
+
for (const row of rows) {
|
|
47
|
+
const bucket = byKind.get(row.kind);
|
|
48
|
+
if (bucket)
|
|
49
|
+
bucket.push(row);
|
|
50
|
+
else
|
|
51
|
+
byKind.set(row.kind, [row]);
|
|
52
|
+
}
|
|
53
|
+
const sections = [];
|
|
54
|
+
for (const kind of [...byKind.keys()].sort()) {
|
|
55
|
+
const kindRows = byKind
|
|
56
|
+
.get(kind)
|
|
57
|
+
.slice()
|
|
58
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
59
|
+
for (const row of kindRows) {
|
|
60
|
+
sections.push(`[[${kind}]]\n` +
|
|
61
|
+
keyValue("name", encodeString(row.name)) +
|
|
62
|
+
keyValue("source_path", encodeString(row.sourcePath)) +
|
|
63
|
+
keyValue("source_hash", encodeString(row.sourceHash)) +
|
|
64
|
+
keyValue("emit_hash", encodeString(row.emitHash)));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return sections;
|
|
68
|
+
}
|
|
69
|
+
/** A `key = "value"\n` line for a present string column, or "" to omit an absent one. */
|
|
70
|
+
function optionalColumn(key, value) {
|
|
71
|
+
return value === undefined ? "" : keyValue(key, encodeString(value));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The `[declaration]` table's sections, in the fixed family order kind · clause ·
|
|
75
|
+
* requirement · assembly, each row a `[[declaration.<family>]]` table with its
|
|
76
|
+
* columns in the Rust `to_table` order. An empty family writes nothing — an empty
|
|
77
|
+
* array vanishes on the toml round-trip.
|
|
78
|
+
*/
|
|
79
|
+
function declarationSections(declarations) {
|
|
80
|
+
const sections = [];
|
|
81
|
+
for (const row of declarations.kinds) {
|
|
82
|
+
sections.push("[[declaration.kind]]\n" +
|
|
83
|
+
keyValue("name", encodeString(row.name)) +
|
|
84
|
+
optionalColumn("provider", row.provider) +
|
|
85
|
+
keyValue("governs_root", encodeString(row.governs_root)) +
|
|
86
|
+
keyValue("governs_glob", encodeString(row.governs_glob)) +
|
|
87
|
+
optionalColumn("format", row.format) +
|
|
88
|
+
optionalColumn("unit_shape", row.unit_shape) +
|
|
89
|
+
optionalColumn("activation", row.activation));
|
|
90
|
+
}
|
|
91
|
+
for (const row of declarations.clauses) {
|
|
92
|
+
sections.push("[[declaration.clause]]\n" +
|
|
93
|
+
keyValue("kind", encodeString(row.kind)) +
|
|
94
|
+
keyValue("predicate", encodeString(row.predicate)) +
|
|
95
|
+
optionalColumn("field", row.field) +
|
|
96
|
+
keyValue("severity", encodeString(row.severity)));
|
|
97
|
+
}
|
|
98
|
+
for (const row of declarations.requirements) {
|
|
99
|
+
sections.push("[[declaration.requirement]]\n" +
|
|
100
|
+
keyValue("name", encodeString(row.name)) +
|
|
101
|
+
optionalColumn("kind", row.kind) +
|
|
102
|
+
optionalColumn("package", row.package) +
|
|
103
|
+
keyValue("required", row.required ? "true" : "false") +
|
|
104
|
+
optionalColumn("verified_by", row.verified_by));
|
|
105
|
+
}
|
|
106
|
+
for (const row of declarations.assembly) {
|
|
107
|
+
sections.push("[[declaration.assembly]]\n" +
|
|
108
|
+
keyValue("fact", encodeString(row.fact)) +
|
|
109
|
+
optionalColumn("value", row.value) +
|
|
110
|
+
optionalColumn("from", row.from) +
|
|
111
|
+
optionalColumn("field", row.field) +
|
|
112
|
+
optionalColumn("to", row.to));
|
|
113
|
+
}
|
|
114
|
+
return sections;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Serialize the lock — the rollup rows then the declaration families, joined the
|
|
118
|
+
* `toml_edit` way (exactly one blank line before every table header but the
|
|
119
|
+
* document's first). An all-empty declaration set contributes no sections, so a
|
|
120
|
+
* memberless lock is empty and a rollup-only lock carries no `[declaration]` rows.
|
|
121
|
+
*/
|
|
122
|
+
export function stampLock(rows, declarations) {
|
|
123
|
+
const sections = [...rollupSections(rows)];
|
|
124
|
+
if (declarations !== undefined)
|
|
125
|
+
sections.push(...declarationSections(declarations));
|
|
126
|
+
return joinSections(sections);
|
|
127
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Needs — the capabilities a member's behavior uses, declared as typed values
|
|
3
|
+
* (`specs/architecture/20-surface.md`, "The member"; "Emit — total"). Emit derives
|
|
4
|
+
* the settings permission list from their union, so a permission is never authored
|
|
5
|
+
* twice: `permissions.allow` is the union of the members' declared `needs`, and a
|
|
6
|
+
* permission with no member is visible as exactly that (the derived-list Decision).
|
|
7
|
+
*/
|
|
8
|
+
/** A declared capability — its `permission` is the entry it derives in the union. */
|
|
9
|
+
export interface Capability {
|
|
10
|
+
/**
|
|
11
|
+
* The permission-list entry this capability derives. The union of every
|
|
12
|
+
* member's needs is the settings `permissions.allow` — the fold hooks and MCP
|
|
13
|
+
* members ride into once those kinds land (`20-surface.md`, "Emit — total").
|
|
14
|
+
*/
|
|
15
|
+
readonly permission: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A shell-command capability. Its derived permission is the Claude Code allow
|
|
19
|
+
* entry `Bash(<command>)` (code.claude.com/docs/en/settings, retrieved
|
|
20
|
+
* 2026-07-04) — the port scene's `bash("git diff")` (`20-surface.md`).
|
|
21
|
+
*/
|
|
22
|
+
export declare function bash(command: string): Capability;
|
|
23
|
+
/** Any capability whose permission entry the author states verbatim. */
|
|
24
|
+
export declare function capability(permission: string): Capability;
|
|
25
|
+
/**
|
|
26
|
+
* The derived permission list — the union of every capability's entry, deduped
|
|
27
|
+
* and sorted so the derived artifact is byte-stable across runs (law 5). The
|
|
28
|
+
* permission is derived here, never authored (`20-surface.md`, the derived-list
|
|
29
|
+
* Decision).
|
|
30
|
+
*/
|
|
31
|
+
export declare function permissionUnion(needs: readonly Capability[]): string[];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Needs — the capabilities a member's behavior uses, declared as typed values
|
|
3
|
+
* (`specs/architecture/20-surface.md`, "The member"; "Emit — total"). Emit derives
|
|
4
|
+
* the settings permission list from their union, so a permission is never authored
|
|
5
|
+
* twice: `permissions.allow` is the union of the members' declared `needs`, and a
|
|
6
|
+
* permission with no member is visible as exactly that (the derived-list Decision).
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* A shell-command capability. Its derived permission is the Claude Code allow
|
|
10
|
+
* entry `Bash(<command>)` (code.claude.com/docs/en/settings, retrieved
|
|
11
|
+
* 2026-07-04) — the port scene's `bash("git diff")` (`20-surface.md`).
|
|
12
|
+
*/
|
|
13
|
+
export function bash(command) {
|
|
14
|
+
return { permission: `Bash(${command})` };
|
|
15
|
+
}
|
|
16
|
+
/** Any capability whose permission entry the author states verbatim. */
|
|
17
|
+
export function capability(permission) {
|
|
18
|
+
return { permission };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The derived permission list — the union of every capability's entry, deduped
|
|
22
|
+
* and sorted so the derived artifact is byte-stable across runs (law 5). The
|
|
23
|
+
* permission is derived here, never authored (`20-surface.md`, the derived-list
|
|
24
|
+
* Decision).
|
|
25
|
+
*/
|
|
26
|
+
export function permissionUnion(needs) {
|
|
27
|
+
return [...new Set(needs.map((need) => need.permission))].sort();
|
|
28
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projection — emit compiles each member to its harness format under `.claude/**`,
|
|
3
|
+
* whole-file and byte-faithful (`specs/architecture/20-surface.md`, "Emit —
|
|
4
|
+
* total"). A member with frontmatter fields projects to a fresh `---`-delimited
|
|
5
|
+
* block over its resolved body; a frontmatterless kind (memory) projects to its
|
|
6
|
+
* body alone. The words are the author's, untouched — emit never stamps metadata
|
|
7
|
+
* into the projection (law 5); the managed-by note and the schema modeline ride
|
|
8
|
+
* `install`, and a re-emit round-trips them through the whole-file write.
|
|
9
|
+
*
|
|
10
|
+
* The locus and layout come from the kind's five facts (`15-kinds.md`), never a
|
|
11
|
+
* hardcoded kind name: a directory unit lands its entry file under a per-member
|
|
12
|
+
* directory, a lone file lands at the stem, a frontmatterless any-depth memory
|
|
13
|
+
* lands the root `<name>.md`.
|
|
14
|
+
*/
|
|
15
|
+
import type { KindFacts } from "./kind.js";
|
|
16
|
+
/** One projected harness file: where it lands and the byte-faithful content. */
|
|
17
|
+
export interface Projection {
|
|
18
|
+
/** The slash path relative to the harness root (`.claude/**`, or a root memory). */
|
|
19
|
+
readonly path: string;
|
|
20
|
+
/** The whole-file projection bytes — frontmatter (if any) over the body. */
|
|
21
|
+
readonly bytes: string;
|
|
22
|
+
}
|
|
23
|
+
/** The resolved member emit hands the projector — facts, name, ordered fields, resolved body. */
|
|
24
|
+
export interface ProjectionInput {
|
|
25
|
+
readonly facts: KindFacts;
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly fields: ReadonlyArray<readonly [string, unknown]>;
|
|
28
|
+
readonly body: string;
|
|
29
|
+
}
|
|
30
|
+
/** Emit-time inputs beyond the member — where install's placements are read from. */
|
|
31
|
+
export interface ProjectOptions {
|
|
32
|
+
/**
|
|
33
|
+
* The harness root the **committed** projection is read from to carry install's
|
|
34
|
+
* frontmatter placements (the schema modeline + managed-by note) through the
|
|
35
|
+
* whole-file re-emit — the two-projectors seam. Absent — or an absent committed
|
|
36
|
+
* file — carries no placements: emit writes the projection fresh.
|
|
37
|
+
*/
|
|
38
|
+
readonly projectionDir?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The install-placed frontmatter comment lines present in `source`, in on-disk
|
|
42
|
+
* order — the schema modeline and the managed-by note. `emit` round-trips these
|
|
43
|
+
* through its whole-file re-emit so its content-faithful projection (law 5)
|
|
44
|
+
* carries install's metadata instead of dropping it (`20-surface.md`).
|
|
45
|
+
*/
|
|
46
|
+
export declare function placementLines(source: string): string[];
|
|
47
|
+
/**
|
|
48
|
+
* The harness locus a member of `facts` named `name` projects onto, derived from
|
|
49
|
+
* the kind's locus and unit shape (`15-kinds.md`): a directory unit lands its
|
|
50
|
+
* entry file under `<root>/<name>/`; a lone file replaces the glob's `*` with the
|
|
51
|
+
* name (an any-depth memory lands the root `<name>.md`).
|
|
52
|
+
*
|
|
53
|
+
* # Throws
|
|
54
|
+
* If the kind is a genre — a block-locus member has no standalone projection.
|
|
55
|
+
*/
|
|
56
|
+
export declare function projectionPath(facts: KindFacts, name: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* One frontmatter field as `key: <value>\n`, or `null` to omit a null/undefined
|
|
59
|
+
* value. The value is compact JSON — valid YAML flow, round-tripping to the same
|
|
60
|
+
* JSON on the next parse — matching the Rust `render_field` (`serde_json::to_string`).
|
|
61
|
+
*/
|
|
62
|
+
export declare function renderField(key: string, value: unknown): string | null;
|
|
63
|
+
/**
|
|
64
|
+
* The whole-file projection bytes for one member: no surviving field ⇒ the body
|
|
65
|
+
* alone (no frontmatter block, so no place a modeline/note could sit); one or
|
|
66
|
+
* more ⇒ a fresh `---` frontmatter (install's preserved `placements` first, then
|
|
67
|
+
* every field in order) over the byte-faithful body.
|
|
68
|
+
*/
|
|
69
|
+
export declare function projectBytes(fields: ReadonlyArray<readonly [string, unknown]>, body: string, placements?: readonly string[]): string;
|
|
70
|
+
/**
|
|
71
|
+
* Project one resolved member onto its harness file — its locus and the whole-file
|
|
72
|
+
* bytes. With `options.projectionDir` set, install's placement lines ride through
|
|
73
|
+
* the re-emit (the two-projectors seam).
|
|
74
|
+
*
|
|
75
|
+
* # Throws
|
|
76
|
+
* If the member's kind is a genre ([`projectionPath`]), or the committed projection
|
|
77
|
+
* cannot be read for a reason other than absence.
|
|
78
|
+
*/
|
|
79
|
+
export declare function projectMember(member: ProjectionInput, options?: ProjectOptions): Projection;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projection — emit compiles each member to its harness format under `.claude/**`,
|
|
3
|
+
* whole-file and byte-faithful (`specs/architecture/20-surface.md`, "Emit —
|
|
4
|
+
* total"). A member with frontmatter fields projects to a fresh `---`-delimited
|
|
5
|
+
* block over its resolved body; a frontmatterless kind (memory) projects to its
|
|
6
|
+
* body alone. The words are the author's, untouched — emit never stamps metadata
|
|
7
|
+
* into the projection (law 5); the managed-by note and the schema modeline ride
|
|
8
|
+
* `install`, and a re-emit round-trips them through the whole-file write.
|
|
9
|
+
*
|
|
10
|
+
* The locus and layout come from the kind's five facts (`15-kinds.md`), never a
|
|
11
|
+
* hardcoded kind name: a directory unit lands its entry file under a per-member
|
|
12
|
+
* directory, a lone file lands at the stem, a frontmatterless any-depth memory
|
|
13
|
+
* lands the root `<name>.md`.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
/** The schema modeline marker install places and emit preserves (`src/install.rs`). */
|
|
18
|
+
const MODELINE_MARKER = "# yaml-language-server:";
|
|
19
|
+
/** The managed-by note's stable marker (`src/install.rs`). */
|
|
20
|
+
const NOTE_MARKER = "# temper: managed projection";
|
|
21
|
+
/** Whether `line` is one of install's managed metadata comments. */
|
|
22
|
+
function isPlacementComment(line) {
|
|
23
|
+
const trimmed = line.replace(/^\s+/, "");
|
|
24
|
+
return trimmed.startsWith(MODELINE_MARKER) || trimmed.startsWith(NOTE_MARKER);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A string's lines the Rust `str::lines` way: split on `\n`, a trailing newline
|
|
28
|
+
* opens no line, a trailing `\r` is stripped from each.
|
|
29
|
+
*/
|
|
30
|
+
function lines(textValue) {
|
|
31
|
+
if (textValue === "")
|
|
32
|
+
return [];
|
|
33
|
+
const parts = textValue.split("\n");
|
|
34
|
+
if (parts[parts.length - 1] === "")
|
|
35
|
+
parts.pop();
|
|
36
|
+
return parts.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The frontmatter interior of `rest` — everything after the opening `---\n` up to
|
|
40
|
+
* the closing `---` line — or `null` when there is no closing delimiter (an
|
|
41
|
+
* opening `---` that is really prose). A port of the Rust `install::frontmatter_inner`.
|
|
42
|
+
*/
|
|
43
|
+
function frontmatterInner(rest) {
|
|
44
|
+
let offset = 0;
|
|
45
|
+
let cursor = 0;
|
|
46
|
+
while (cursor < rest.length) {
|
|
47
|
+
const newline = rest.indexOf("\n", cursor);
|
|
48
|
+
const end = newline === -1 ? rest.length : newline + 1;
|
|
49
|
+
const piece = rest.slice(cursor, end);
|
|
50
|
+
const content = piece.endsWith("\n") ? piece.slice(0, -1) : piece;
|
|
51
|
+
if (content.replace(/\s+$/, "") === "---")
|
|
52
|
+
return rest.slice(0, offset);
|
|
53
|
+
offset += piece.length;
|
|
54
|
+
cursor = end;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The install-placed frontmatter comment lines present in `source`, in on-disk
|
|
60
|
+
* order — the schema modeline and the managed-by note. `emit` round-trips these
|
|
61
|
+
* through its whole-file re-emit so its content-faithful projection (law 5)
|
|
62
|
+
* carries install's metadata instead of dropping it (`20-surface.md`).
|
|
63
|
+
*/
|
|
64
|
+
export function placementLines(source) {
|
|
65
|
+
if (!source.startsWith("---\n"))
|
|
66
|
+
return [];
|
|
67
|
+
const inner = frontmatterInner(source.slice("---\n".length));
|
|
68
|
+
if (inner === null)
|
|
69
|
+
return [];
|
|
70
|
+
return lines(inner).filter(isPlacementComment);
|
|
71
|
+
}
|
|
72
|
+
/** Join non-empty, non-`.` path segments with `/` — a `.` root drops out (root memory). */
|
|
73
|
+
function joinSlash(...parts) {
|
|
74
|
+
return parts.filter((part) => part !== "" && part !== ".").join("/");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The harness locus a member of `facts` named `name` projects onto, derived from
|
|
78
|
+
* the kind's locus and unit shape (`15-kinds.md`): a directory unit lands its
|
|
79
|
+
* entry file under `<root>/<name>/`; a lone file replaces the glob's `*` with the
|
|
80
|
+
* name (an any-depth memory lands the root `<name>.md`).
|
|
81
|
+
*
|
|
82
|
+
* # Throws
|
|
83
|
+
* If the kind is a genre — a block-locus member has no standalone projection.
|
|
84
|
+
*/
|
|
85
|
+
export function projectionPath(facts, name) {
|
|
86
|
+
if (facts.locus.kind !== "at") {
|
|
87
|
+
throw new Error(`kind \`${facts.name}\` is a genre — its members live inside host documents ` +
|
|
88
|
+
`and have no standalone projection (specs/architecture/15-kinds.md).`);
|
|
89
|
+
}
|
|
90
|
+
const { root, glob } = facts.locus;
|
|
91
|
+
if (facts.unitShape === "directory") {
|
|
92
|
+
// `*/SKILL.md` → the entry file after the first slash, under a per-member dir.
|
|
93
|
+
const entry = glob.slice(glob.indexOf("/") + 1);
|
|
94
|
+
return joinSlash(root, name, entry);
|
|
95
|
+
}
|
|
96
|
+
// A lone file: any-depth glob (`**/CLAUDE.md`) is the root `<name>.md`; a simple
|
|
97
|
+
// glob (`*.md`) replaces its single star with the name.
|
|
98
|
+
const filename = glob.includes("**") ? `${name}.md` : glob.replace("*", name);
|
|
99
|
+
return joinSlash(root, filename);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* One frontmatter field as `key: <value>\n`, or `null` to omit a null/undefined
|
|
103
|
+
* value. The value is compact JSON — valid YAML flow, round-tripping to the same
|
|
104
|
+
* JSON on the next parse — matching the Rust `render_field` (`serde_json::to_string`).
|
|
105
|
+
*/
|
|
106
|
+
export function renderField(key, value) {
|
|
107
|
+
if (value === null || value === undefined)
|
|
108
|
+
return null;
|
|
109
|
+
return `${key}: ${JSON.stringify(value)}\n`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The whole-file projection bytes for one member: no surviving field ⇒ the body
|
|
113
|
+
* alone (no frontmatter block, so no place a modeline/note could sit); one or
|
|
114
|
+
* more ⇒ a fresh `---` frontmatter (install's preserved `placements` first, then
|
|
115
|
+
* every field in order) over the byte-faithful body.
|
|
116
|
+
*/
|
|
117
|
+
export function projectBytes(fields, body, placements = []) {
|
|
118
|
+
const rendered = fields
|
|
119
|
+
.map(([key, value]) => renderField(key, value))
|
|
120
|
+
.filter((line) => line !== null);
|
|
121
|
+
if (rendered.length === 0)
|
|
122
|
+
return body;
|
|
123
|
+
const frontmatter = placements.map((line) => `${line}\n`).join("") + rendered.join("");
|
|
124
|
+
return `---\n${frontmatter}---\n${body}`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Read the install-placed frontmatter lines from the committed projection at
|
|
128
|
+
* `projectionDir/path`, or `[]` when none is read (no `projectionDir`, or the file
|
|
129
|
+
* is absent — emit writes it fresh). Reads are of committed bytes, never a clock,
|
|
130
|
+
* so the double-emit purity check still holds.
|
|
131
|
+
*
|
|
132
|
+
* # Throws
|
|
133
|
+
* On a read failure that is not "file absent".
|
|
134
|
+
*/
|
|
135
|
+
function committedPlacements(projectionDir, path) {
|
|
136
|
+
if (projectionDir === undefined)
|
|
137
|
+
return [];
|
|
138
|
+
try {
|
|
139
|
+
return placementLines(readFileSync(join(projectionDir, path), "utf8"));
|
|
140
|
+
}
|
|
141
|
+
catch (cause) {
|
|
142
|
+
if (cause.code === "ENOENT")
|
|
143
|
+
return [];
|
|
144
|
+
throw new Error(`failed to read committed projection \`${path}\` under \`${projectionDir}\`.`, {
|
|
145
|
+
cause,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Project one resolved member onto its harness file — its locus and the whole-file
|
|
151
|
+
* bytes. With `options.projectionDir` set, install's placement lines ride through
|
|
152
|
+
* the re-emit (the two-projectors seam).
|
|
153
|
+
*
|
|
154
|
+
* # Throws
|
|
155
|
+
* If the member's kind is a genre ([`projectionPath`]), or the committed projection
|
|
156
|
+
* cannot be read for a reason other than absence.
|
|
157
|
+
*/
|
|
158
|
+
export function projectMember(member, options = {}) {
|
|
159
|
+
const path = projectionPath(member.facts, member.name);
|
|
160
|
+
const placements = committedPlacements(options.projectionDir, path);
|
|
161
|
+
return { path, bytes: projectBytes(member.fields, member.body, placements) };
|
|
162
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prose — three constructors, one field type (`specs/architecture/20-surface.md`,
|
|
3
|
+
* "Prose — three constructors, one field type"). A member's words are data the
|
|
4
|
+
* member declares: `file()` for a document that keeps its medium, `` text`…` ``
|
|
5
|
+
* for short inline prose, `blocks()` for fully composed genre values. Whatever
|
|
6
|
+
* the constructor, the words land byte-identical to their authored text (law 5).
|
|
7
|
+
* Interpolations in `` text`…` `` are **mentions** — declared one-way edges,
|
|
8
|
+
* authored per word, resolution-checked at emit, never mined (law 8).
|
|
9
|
+
*/
|
|
10
|
+
import type { GenreValue } from "./genres.js";
|
|
11
|
+
/** A declared value a mention may name — the target of the one-way citation edge. */
|
|
12
|
+
export interface Mentionable {
|
|
13
|
+
/** The mention's rendered form and graph edge target (`kind:name` or a leaf address). */
|
|
14
|
+
readonly address: string;
|
|
15
|
+
/** The display text the one corpus-wide rule renders in place. */
|
|
16
|
+
readonly display: string;
|
|
17
|
+
}
|
|
18
|
+
/** One authored interpolation: position in the template plus its target. */
|
|
19
|
+
export interface Mention {
|
|
20
|
+
readonly index: number;
|
|
21
|
+
readonly target: Mentionable;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `file(path)` — the document keeps its medium: markdown in a markdown file,
|
|
25
|
+
* full tooling, forever legal (posture 1, `15-kinds.md`). Resolved and read in
|
|
26
|
+
* byte-for-byte at emit.
|
|
27
|
+
*/
|
|
28
|
+
export interface File {
|
|
29
|
+
readonly kind: "file";
|
|
30
|
+
/** Module-relative path to the authored document. */
|
|
31
|
+
readonly path: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `` text`…` `` — short prose inline, dedented, byte-deterministic; the
|
|
35
|
+
* three-line rule that would be silly as a sidecar file. Mentions ride beside
|
|
36
|
+
* the text, never inside it (law 5).
|
|
37
|
+
*/
|
|
38
|
+
export interface Text {
|
|
39
|
+
readonly kind: "text";
|
|
40
|
+
/** The dedented authored text with one {@link MENTION_SLOT} per mention, in order. */
|
|
41
|
+
readonly template: string;
|
|
42
|
+
readonly mentions: readonly Mention[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* `blocks(…)` — fully composed genre values (posture 3): typed collections whose
|
|
46
|
+
* emitted document is pure render, byte-identical to the same values authored as
|
|
47
|
+
* fences (posture 2). The shared fence format is `(genre-fence-format)`, deferred
|
|
48
|
+
* until its first consumer lands (`15-kinds.md`), so a `blocks()` body composes
|
|
49
|
+
* now and renders when the pilot does — emit refuses to project one until then,
|
|
50
|
+
* never guessing a format.
|
|
51
|
+
*/
|
|
52
|
+
export interface Blocks {
|
|
53
|
+
readonly kind: "blocks";
|
|
54
|
+
readonly values: readonly GenreValue[];
|
|
55
|
+
}
|
|
56
|
+
/** A member's prose — one of the three constructors, one field type. */
|
|
57
|
+
export type Prose = File | Text | Blocks;
|
|
58
|
+
/** Declare a document whose medium is preserved — read in whole at emit (posture 1). */
|
|
59
|
+
export declare function file(path: string): File;
|
|
60
|
+
/**
|
|
61
|
+
* The inline dedenting prose constructor. Interpolate only {@link Mentionable}
|
|
62
|
+
* values — each interpolation is a mention, opt-in per word; plain prose with
|
|
63
|
+
* zero mentions is fully legal forever (the opt-in Decision, `20-surface.md`).
|
|
64
|
+
*
|
|
65
|
+
* # Throws
|
|
66
|
+
* If an authored chunk contains {@link MENTION_SLOT} — the marker must be the
|
|
67
|
+
* tool's alone, so a stray NUL is a loud authoring error, not a silent mis-split.
|
|
68
|
+
*/
|
|
69
|
+
export declare function text(strings: TemplateStringsArray, ...targets: Mentionable[]): Text;
|
|
70
|
+
/** Compose fully-typed genre values into a member's body (posture 3). */
|
|
71
|
+
export declare function blocks(...values: GenreValue[]): Blocks;
|
|
72
|
+
/**
|
|
73
|
+
* Render an inline body to its final text — the display rule applied: each
|
|
74
|
+
* mention slot becomes its target's display form, the surrounding words
|
|
75
|
+
* untouched (law 5). The chunk count is `mentions.length + 1` by {@link text}'s
|
|
76
|
+
* construction, so the walk consumes every slot.
|
|
77
|
+
*/
|
|
78
|
+
export declare function renderText(prose: Text): string;
|
|
Binary file
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOML value/key encoding and table layout — a faithful port of `toml_write`
|
|
3
|
+
* 0.1.2 (`src/string.rs`) and `toml_edit` 0.22.27's `visit_table`
|
|
4
|
+
* (`specs/architecture/20-surface.md`, "Content-faithful, deterministically
|
|
5
|
+
* emitted (law 5)"). Shared by the manifest emitter (`emit.ts`) and the lock
|
|
6
|
+
* stamper (`lock.ts`) so every `key = value` line and every table header the SDK
|
|
7
|
+
* writes is byte-identical to the Rust `toml_edit` output — the manifest, the
|
|
8
|
+
* projection frontmatter, and the lock all agree to the byte.
|
|
9
|
+
*/
|
|
10
|
+
/** A TOML string *value* — the exact bytes `toml_edit`'s `value(String)` emits. */
|
|
11
|
+
export declare function encodeString(s: string): string;
|
|
12
|
+
/** A TOML *key* — bare where it can be, else `toml_edit`'s `TomlKeyBuilder::as_default`. */
|
|
13
|
+
export declare function encodeKey(s: string): string;
|
|
14
|
+
/** One `key = value\n` line, the key/value decor `toml_edit` renders (`key = value`). */
|
|
15
|
+
export declare function keyValue(key: string, valueRepr: string): string;
|
|
16
|
+
/** A TOML string array — `["a", "b"]`, no leading space, `, ` between elements. */
|
|
17
|
+
export declare function stringArray(values: readonly string[]): string;
|
|
18
|
+
/** Sorted keys — the stable order `toml_edit` gets for free from its `BTreeMap`s. */
|
|
19
|
+
export declare function sortedKeys(record: Readonly<Record<string, unknown>>): string[];
|
|
20
|
+
/**
|
|
21
|
+
* Join an ordered list of table sections the `toml_edit` way — exactly one blank
|
|
22
|
+
* line before every table header but the document's first
|
|
23
|
+
* (`DEFAULT_TABLE_DECOR = ("\n", "")`, the first table `("", …)`). Each section is
|
|
24
|
+
* a header line plus its `key = value\n` lines, already newline-terminated.
|
|
25
|
+
*/
|
|
26
|
+
export declare function joinSections(sections: readonly string[]): string;
|