wyrd-scribe 0.1.0

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.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * B2 — THE OPT-IN FRONTMATTER STAMP. Line-based, and it REFUSES rather than merging.
3
+ *
4
+ * ⚠⚠ NO YAML PARSER AND NO AST MERGE, AND THAT IS A SECURITY DECISION RATHER THAN A DEPENDENCY ONE.
5
+ * Parsing a stranger's frontmatter means running a YAML parser over untrusted text on the write
6
+ * path — YAML's own history of anchor-expansion and tag-resolution defects is why that is not a
7
+ * free operation — and then RE-EMITTING their document from an AST, which rewrites quoting,
8
+ * ordering, comments and anchors in a file the user did not ask us to reformat. D3's justification
9
+ * for writing into someone else's notes is that the act is benign and disclosed; silently
10
+ * reserialising their frontmatter is neither.
11
+ *
12
+ * So this module does the smallest thing that can be correct: it finds the block's boundary LINES
13
+ * and inserts ONE line. Anything it cannot do that way it REFUSES.
14
+ *
15
+ * ⚠⚠ THE PROJECTION CARRIES NO `page` KEY AT ALL, AND THE FULL REASON IS WORTH THE PARAGRAPH,
16
+ * because the shape it replaces looked correct and was measurably wrong.
17
+ *
18
+ * It used to omit `page.content` and keep `page.identity`, on the argument that only the DIGEST is
19
+ * self-referential: the frontmatter becomes part of the page bytes, so a projection containing the
20
+ * page's own digest would have to be computed from bytes that do not exist until it is inserted.
21
+ * That argument is sound and it does not go far enough — the IDENTITY is unrecordable here for a
22
+ * different reason, and the two together leave nothing of `page` behind.
23
+ *
24
+ * The projection is built from the DRAFT record, whose page path is the CALLER'S SPELLING, because
25
+ * the fence has not resolved the target yet and will not until the create. The ledger line is
26
+ * rebuilt afterwards from the fence's canonical `created.rel`. So a write through an in-grant
27
+ * junction — `Alias/answer.md` resolving to `Mage/answer.md` — put `Alias/answer.md` in the page
28
+ * and `Mage/answer.md` in the ledger: TWO PROVENANCE RECORDS FOR ONE WRITE, DISAGREEING ABOUT WHICH
29
+ * PAGE THIS IS. A reader with both cannot tell which is the page's identity, and the one embedded
30
+ * in the bytes is the one that travels with the file.
31
+ *
32
+ * The fix is not to defer the projection until the canonical path is known — that would mean
33
+ * composing the page bytes after creating the page, which inverts the ordering `stamp.ts`'s header
34
+ * spends its length defending. It is that A PAGE DOES NOT NEED TO NAME ITSELF INSIDE ITSELF. The
35
+ * projection's job is to carry the SOURCES — their identities, hashes and spans — plus the schema
36
+ * and vault fields that make those readable. Page identity has exactly one home, the ledger, which
37
+ * is where the canonical path is known and where a consumer already looks for it.
38
+ *
39
+ * ⚠ ONE KEY, `wyrd_lineage`, HOLDING SINGLE-LINE JSON. JSON is a subset of YAML 1.2, so a flow-style
40
+ * value on one line parses under any conformant reader without this module emitting block scalars,
41
+ * indentation or line folding — three things a hand-rolled emitter gets wrong and a diff makes
42
+ * unreadable. It also keeps the insertion to exactly one line, which is what makes the "find the
43
+ * closing fence and insert above it" rule sound.
44
+ */
45
+ import type { LineageRecord } from './lineage.js';
46
+ import type { ScribeRefusal } from './refusal.js';
47
+ export declare const FRONTMATTER_KEY = "wyrd_lineage";
48
+ /**
49
+ * How far in we look for the closing `---`.
50
+ *
51
+ * ⚠ A BOUND, NOT A GUESS AT REAL DOCUMENTS. Without one, content that merely OPENS with `---` and
52
+ * never closes makes this scan the whole file to decide it has no frontmatter — an unbounded read
53
+ * of caller-supplied bytes on the write path, for a question about the document's first few lines.
54
+ * A block longer than this refuses; it does not get treated as absent, because treating it as
55
+ * absent would PREPEND a second block and produce a document with two.
56
+ */
57
+ export declare const MAX_FRONTMATTER_LINES = 200;
58
+ /**
59
+ * The record as it appears in the page: the SOURCES, and the fields that make them readable.
60
+ *
61
+ * ⚠ NO `page` MEMBER, AND ITS ABSENCE IS THE TYPE ENFORCING THE RULE ABOVE — a later session that
62
+ * decides the page should name itself has to widen this interface deliberately rather than by
63
+ * adding a line to `project`.
64
+ */
65
+ export interface FrontmatterProjection {
66
+ readonly schema: string;
67
+ readonly event: string;
68
+ readonly event_id: string;
69
+ readonly recorded_at: string;
70
+ readonly writer: LineageRecord['writer'];
71
+ readonly vault: LineageRecord['vault'];
72
+ readonly sources: LineageRecord['sources'];
73
+ }
74
+ /**
75
+ * ⚠ `event_id` IS CARRIED AND THE PAGE PATH IS NOT, WHICH IS THE ASYMMETRY WORTH NAMING. The id is
76
+ * the one field that JOINS this projection to its ledger line — without it a reader holding a
77
+ * stamped page and a ledger of many lines has only the timestamp to match on, and `ST29` exists
78
+ * because a timestamp does not distinguish two writes. It is server-minted per write, so it says
79
+ * nothing about the path and cannot disagree with the fence about anything.
80
+ */
81
+ export declare function project(record: LineageRecord): FrontmatterProjection;
82
+ /**
83
+ * Insert the stamp, or refuse.
84
+ *
85
+ * `content` is the caller's page text; the return carries the bytes to write.
86
+ */
87
+ export declare function stamp(content: string, projection: FrontmatterProjection): string | ScribeRefusal;
@@ -0,0 +1,302 @@
1
+ /**
2
+ * B2 — THE OPT-IN FRONTMATTER STAMP. Line-based, and it REFUSES rather than merging.
3
+ *
4
+ * ⚠⚠ NO YAML PARSER AND NO AST MERGE, AND THAT IS A SECURITY DECISION RATHER THAN A DEPENDENCY ONE.
5
+ * Parsing a stranger's frontmatter means running a YAML parser over untrusted text on the write
6
+ * path — YAML's own history of anchor-expansion and tag-resolution defects is why that is not a
7
+ * free operation — and then RE-EMITTING their document from an AST, which rewrites quoting,
8
+ * ordering, comments and anchors in a file the user did not ask us to reformat. D3's justification
9
+ * for writing into someone else's notes is that the act is benign and disclosed; silently
10
+ * reserialising their frontmatter is neither.
11
+ *
12
+ * So this module does the smallest thing that can be correct: it finds the block's boundary LINES
13
+ * and inserts ONE line. Anything it cannot do that way it REFUSES.
14
+ *
15
+ * ⚠⚠ THE PROJECTION CARRIES NO `page` KEY AT ALL, AND THE FULL REASON IS WORTH THE PARAGRAPH,
16
+ * because the shape it replaces looked correct and was measurably wrong.
17
+ *
18
+ * It used to omit `page.content` and keep `page.identity`, on the argument that only the DIGEST is
19
+ * self-referential: the frontmatter becomes part of the page bytes, so a projection containing the
20
+ * page's own digest would have to be computed from bytes that do not exist until it is inserted.
21
+ * That argument is sound and it does not go far enough — the IDENTITY is unrecordable here for a
22
+ * different reason, and the two together leave nothing of `page` behind.
23
+ *
24
+ * The projection is built from the DRAFT record, whose page path is the CALLER'S SPELLING, because
25
+ * the fence has not resolved the target yet and will not until the create. The ledger line is
26
+ * rebuilt afterwards from the fence's canonical `created.rel`. So a write through an in-grant
27
+ * junction — `Alias/answer.md` resolving to `Mage/answer.md` — put `Alias/answer.md` in the page
28
+ * and `Mage/answer.md` in the ledger: TWO PROVENANCE RECORDS FOR ONE WRITE, DISAGREEING ABOUT WHICH
29
+ * PAGE THIS IS. A reader with both cannot tell which is the page's identity, and the one embedded
30
+ * in the bytes is the one that travels with the file.
31
+ *
32
+ * The fix is not to defer the projection until the canonical path is known — that would mean
33
+ * composing the page bytes after creating the page, which inverts the ordering `stamp.ts`'s header
34
+ * spends its length defending. It is that A PAGE DOES NOT NEED TO NAME ITSELF INSIDE ITSELF. The
35
+ * projection's job is to carry the SOURCES — their identities, hashes and spans — plus the schema
36
+ * and vault fields that make those readable. Page identity has exactly one home, the ledger, which
37
+ * is where the canonical path is known and where a consumer already looks for it.
38
+ *
39
+ * ⚠ ONE KEY, `wyrd_lineage`, HOLDING SINGLE-LINE JSON. JSON is a subset of YAML 1.2, so a flow-style
40
+ * value on one line parses under any conformant reader without this module emitting block scalars,
41
+ * indentation or line folding — three things a hand-rolled emitter gets wrong and a diff makes
42
+ * unreadable. It also keeps the insertion to exactly one line, which is what makes the "find the
43
+ * closing fence and insert above it" rule sound.
44
+ */
45
+ import { scribeRefuse } from './refusal.js';
46
+ export const FRONTMATTER_KEY = 'wyrd_lineage';
47
+ /**
48
+ * How far in we look for the closing `---`.
49
+ *
50
+ * ⚠ A BOUND, NOT A GUESS AT REAL DOCUMENTS. Without one, content that merely OPENS with `---` and
51
+ * never closes makes this scan the whole file to decide it has no frontmatter — an unbounded read
52
+ * of caller-supplied bytes on the write path, for a question about the document's first few lines.
53
+ * A block longer than this refuses; it does not get treated as absent, because treating it as
54
+ * absent would PREPEND a second block and produce a document with two.
55
+ */
56
+ export const MAX_FRONTMATTER_LINES = 200;
57
+ /**
58
+ * ⚠ `event_id` IS CARRIED AND THE PAGE PATH IS NOT, WHICH IS THE ASYMMETRY WORTH NAMING. The id is
59
+ * the one field that JOINS this projection to its ledger line — without it a reader holding a
60
+ * stamped page and a ledger of many lines has only the timestamp to match on, and `ST29` exists
61
+ * because a timestamp does not distinguish two writes. It is server-minted per write, so it says
62
+ * nothing about the path and cannot disagree with the fence about anything.
63
+ */
64
+ export function project(record) {
65
+ return {
66
+ schema: record.schema,
67
+ event: record.event,
68
+ event_id: record.event_id,
69
+ recorded_at: record.recorded_at,
70
+ writer: record.writer,
71
+ vault: record.vault,
72
+ sources: record.sources
73
+ };
74
+ }
75
+ /**
76
+ * ⚠⚠ THE LINE SCAN IS OVER `\n` AND TOLERATES `\r`, because a document authored on Windows has
77
+ * CRLF endings and a scan that only knows `\n` sees the closing fence as `"---\r"`, fails to match
78
+ * it, and refuses `FRONTMATTER_INVALID` on a perfectly ordinary file. Trimming only the trailing
79
+ * `\r` — never other whitespace — keeps the match exact in every other respect: ` ---` is NOT a
80
+ * fence in YAML and must not be treated as one.
81
+ */
82
+ function isFence(line) {
83
+ return (line.endsWith('\r') ? line.slice(0, -1) : line) === '---';
84
+ }
85
+ /**
86
+ * The key a mapping line declares, or `null` if the line declares none AT THE TOP LEVEL.
87
+ *
88
+ * ⚠⚠ THE INDENTATION TEST IS THE ONLY THING SEPARATING A TOP-LEVEL KEY FROM A NESTED ONE, AND IT
89
+ * HAD TO BE MADE SO. The first version sliced the key WITHOUT trimming, so an indented
90
+ * ` wyrd_lineage:` yielded the string `" wyrd_lineage"`, which never equalled the key anyway —
91
+ * two guards where one was doing the work, and the mutation matrix measured exactly that: removing
92
+ * the indentation test left the suite green (`M14`, a real survivor). The name is trimmed first now,
93
+ * so the test is load-bearing and the mutant dies. A guard that is masked by an incidental
94
+ * mismatch is a guard nothing is checking.
95
+ */
96
+ function keyOf(line) {
97
+ const body = line.endsWith('\r') ? line.slice(0, -1) : line;
98
+ // ⚠ Only a TOP-LEVEL key counts for the conflict check: an indented `wyrd_lineage:` is a member
99
+ // of someone else's mapping and is not ours to collide with. A leading space disqualifies it.
100
+ if (/^\s/.test(body))
101
+ return null;
102
+ const colon = body.indexOf(':');
103
+ if (colon <= 0)
104
+ return null;
105
+ return unquote(body.slice(0, colon).trim());
106
+ }
107
+ /**
108
+ * ⚠⚠ `"wyrd_lineage"`, `'wyrd_lineage'` AND `wyrd_lineage` ARE ONE KEY IN YAML, AND UNTIL THIS
109
+ * EXISTED THE CONFLICT CHECK SAW THREE. MEASURED 2026-09-03, not reasoned: stamping a page whose
110
+ * frontmatter carried `"wyrd_lineage": {...}` did not refuse — it WROTE, leaving a document with
111
+ * TWO top-level `wyrd_lineage` keys. That is invalid YAML, and the ordinary parser response is to
112
+ * take one and silently drop the other, so the user's existing provenance record is destroyed
113
+ * without a refusal, an error, or a visible mark. `FRONTMATTER_CONFLICT` exists for exactly that
114
+ * case and was being routed around by a quote character.
115
+ *
116
+ * ⚠ IT STRIPS A MATCHED PAIR AND NOTHING MORE, AND THE NARROWNESS IS THE DESIGN. This module's
117
+ * founding rule is that it does not run a YAML parser over a stranger's frontmatter on the write
118
+ * path, so full scalar semantics — escape sequences, multi-line keys, flow keys — are deliberately
119
+ * NOT handled here. What is handled is the spelling a stamp or a person actually produces.
120
+ *
121
+ * ⚠ THE FAILURE DIRECTION IS THE SAFE ONE EITHER WAY. An exotic spelling this does not recognise
122
+ * falls back to today's behaviour, which is the bug and no worse than it. There is no spelling on
123
+ * which this makes the module refuse a document it should have written: an unmatched or absent
124
+ * quote returns the name unchanged.
125
+ */
126
+ function unquote(name) {
127
+ if (name.length < 2)
128
+ return name;
129
+ const first = name[0];
130
+ if ((first === '"' || first === "'") && name.endsWith(first)) {
131
+ return name.slice(1, -1);
132
+ }
133
+ return name;
134
+ }
135
+ /**
136
+ * ⚠⚠ A FIRST-LINE `---` IS NOT ENOUGH TO CALL SOMETHING FRONTMATTER, and the version that believed
137
+ * it was mishandled ordinary documents in two directions.
138
+ *
139
+ * A Markdown document may legitimately OPEN with a horizontal rule. Under the old rule that first
140
+ * line was an opening fence, so:
141
+ *
142
+ * · a document opening with a rule and then prose was refused `FRONTMATTER_INVALID` — a
143
+ * perfectly ordinary file the user could never write through this server, with a message
144
+ * saying their frontmatter had no closing fence when they had written no frontmatter at all;
145
+ * · a document with a SECOND rule within the 200-line ceiling was worse, because it succeeded:
146
+ * the key was inserted between two horizontal rules, in the middle of the user's prose, as if
147
+ * the span between them had been a mapping.
148
+ *
149
+ * So the block between the fences must LOOK like YAML mapping lines. Every non-blank line is either
150
+ * `key:`-shaped at column 0, a `#` comment, or an indented continuation of the line above. Prose
151
+ * fails all three, and a document whose first line is a rule is then correctly treated as having no
152
+ * frontmatter — it gets a block PREPENDED, which is the outcome the user wanted.
153
+ *
154
+ * ⚠ THIS IS A RECOGNISER, NOT A YAML PARSER, AND THE DISTINCTION IS THE MODULE'S WHOLE DESIGN. It
155
+ * decides one question — does this look like a mapping block? — from line shapes alone, and it
156
+ * still never parses, never builds an AST and never re-emits. The header's argument against running
157
+ * a YAML parser over untrusted text on the write path is untouched.
158
+ *
159
+ * ⚠ IT ERRS TOWARD "NOT FRONTMATTER". A block this rejects is PREPENDED to rather than inserted
160
+ * into, so the failure mode of a false negative is a document with a stamp block above a rule —
161
+ * visible, correct YAML, and nothing of the user's rewritten. A false POSITIVE is the defect above:
162
+ * a key spliced into prose.
163
+ */
164
+ function looksLikeMapping(lines, from, to) {
165
+ for (let index = from; index < to; index += 1) {
166
+ const raw = lines[index];
167
+ const body = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
168
+ if (body.trim().length === 0)
169
+ continue;
170
+ // An indented line continues whatever came before it — a nested mapping, a sequence member,
171
+ // a folded scalar. Its own shape is not this recogniser's business.
172
+ if (/^\s/.test(body))
173
+ continue;
174
+ if (body.startsWith('#'))
175
+ continue;
176
+ // ⚠ A KEY AT COLUMN 0, WITH THE COLON REQUIRED. `keyOf` already encodes exactly this shape
177
+ // for the conflict check, so it is reused rather than re-expressed — two spellings of "what
178
+ // is a top-level key" would be two things to keep in step.
179
+ if (keyOf(body) !== null)
180
+ continue;
181
+ return false;
182
+ }
183
+ return true;
184
+ }
185
+ /**
186
+ * The document's line ending, decided from the OPENING FENCE rather than from a scan.
187
+ *
188
+ * ⚠ A CRLF DOCUMENT MUST GET A CRLF KEY LINE. Inserting a lone LF into a file whose every other
189
+ * line ends CRLF produces a document with mixed endings — which git will report as a whole-file
190
+ * change on the next normalisation, and which some Windows editors render as one run-on line. The
191
+ * fence we are inserting above is the nearest evidence of what this document does, and it is the
192
+ * line our insertion sits against.
193
+ */
194
+ function endingOf(line) {
195
+ return line.endsWith('\r') ? '\r' : '';
196
+ }
197
+ /**
198
+ * ⚠⚠ THE PREPENDED BLOCK'S ENDING, AND EVERY PREPEND EXIT GOES THROUGH HERE. Three call sites
199
+ * used to build `` `---\n${keyLine}\n---\n${content}` `` inline, which put THREE lone LFs into a
200
+ * CRLF document — measured 2026-09-03 on all three, against the compiled build. That is the
201
+ * mixed-ending file `endingOf` exists to prevent, arrived at through the one path that had no
202
+ * fence to read: a prepend happens precisely when the document has no frontmatter block.
203
+ *
204
+ * ⚠ THE BLOCK HAS THREE SEPARATORS AND ALL THREE MATTER — after the opening fence, after the key
205
+ * line, and BETWEEN THE CLOSING FENCE AND THE USER'S FIRST LINE. A fix that changed only the ones
206
+ * inside the block would still leave a lone LF at the seam, which is the byte that actually abuts
207
+ * the user's text. That is why this returns the whole string rather than an ending to interpolate.
208
+ *
209
+ * ⚠ THE RULE IS THE FIRST PHYSICAL LINE'S ENDING, NOT A DOCUMENT-WIDE SURVEY, and the narrowness
210
+ * is the design. Our block touches the document at exactly one seam — above its first line — so
211
+ * that line is the only nearby evidence, and it is the same shape of rule the splice path already
212
+ * follows (take the ending from the line you are inserting against). A majority or last-ending
213
+ * heuristic would invent a global policy for a document that is already inconsistent, and would
214
+ * disagree with the splice path on that document — one module with two rules.
215
+ *
216
+ * ⚠ AN ALREADY-MIXED DOCUMENT STAYS MIXED, DELIBERATELY. We did not cause it, and this module's
217
+ * justification for writing into someone else's notes is that the act is minimal and disclosed;
218
+ * normalising their line endings is neither. Not one byte of `content` is rewritten here.
219
+ *
220
+ * ⚠ NO BREAK AT ALL — an empty document, or a single unterminated line — HAS NO STYLE TO PRESERVE,
221
+ * so it takes LF. Bare-CR (classic Mac) documents are out of scope and stay that way: `split('\n')`
222
+ * does not see them as multi-line anywhere in this module.
223
+ */
224
+ function prepended(keyLine, content) {
225
+ const firstBreak = content.indexOf('\n');
226
+ const eol = firstBreak > 0 && content[firstBreak - 1] === '\r' ? '\r\n' : '\n';
227
+ return `---${eol}${keyLine}${eol}---${eol}${content}`;
228
+ }
229
+ /**
230
+ * Insert the stamp, or refuse.
231
+ *
232
+ * `content` is the caller's page text; the return carries the bytes to write.
233
+ */
234
+ export function stamp(content, projection) {
235
+ const value = JSON.stringify(projection);
236
+ const keyLine = `${FRONTMATTER_KEY}: ${value}`;
237
+ // ⚠ THE OPENING FENCE MUST BE THE FIRST LINE, with nothing before it — not even a blank line.
238
+ // A `---` further down is a horizontal rule or a document separator in the body, and prepending
239
+ // a block above it is correct; treating it as an opening fence would insert our key into the
240
+ // middle of the user's prose.
241
+ const lines = content.split('\n');
242
+ const opens = lines.length > 0 && isFence(lines[0]);
243
+ if (!opens) {
244
+ return prepended(keyLine, content);
245
+ }
246
+ const ceiling = Math.min(lines.length, MAX_FRONTMATTER_LINES);
247
+ for (let index = 1; index < ceiling; index += 1) {
248
+ const line = lines[index];
249
+ if (isFence(line)) {
250
+ /**
251
+ * ⚠⚠ THE BLOCK IS RECOGNISED BEFORE IT IS WRITTEN INTO, AND A BLOCK THAT IS NOT A
252
+ * MAPPING IS NOT FRONTMATTER AT ALL. A pair of fences around PROSE is two horizontal
253
+ * rules, and splicing a key between them puts YAML into the middle of the user's
254
+ * document — the defect this check exists for.
255
+ *
256
+ * ⚠ SO IT PREPENDS RATHER THAN REFUSING, and the choice is the whole point. Refusing
257
+ * would leave an ordinary Markdown file — a rule, some prose, another rule —
258
+ * permanently unwritable through this server, which is the OTHER half of the same
259
+ * defect rather than an improvement on it. Treating the document as having no
260
+ * frontmatter is both true and useful: it gets a block on top and not one byte of the
261
+ * user's own text moves.
262
+ *
263
+ * ⚠ IT IS THE SAME ANSWER THE NO-CLOSING-FENCE PATH BELOW GIVES FOR THE SAME SHAPE,
264
+ * deliberately. One question — is this a mapping block? — with one answer, rather than
265
+ * two outcomes hanging on whether a second rule happened to fall inside the ceiling.
266
+ */
267
+ if (!looksLikeMapping(lines, 1, index)) {
268
+ return prepended(keyLine, content);
269
+ }
270
+ // ⚠ THE KEY LINE MATCHES THE DOCUMENT'S OWN ENDING, taken from the closing fence it is
271
+ // inserted above. A lone LF in a CRLF document is a mixed-ending file.
272
+ const spliced = [...lines.slice(0, index), keyLine + endingOf(line), ...lines.slice(index)];
273
+ return spliced.join('\n');
274
+ }
275
+ if (keyOf(line) === FRONTMATTER_KEY) {
276
+ // ⚠ THE CONFLICT REFUSES; IT DOES NOT OVERWRITE. The existing value may be a previous
277
+ // stamp or may be the user's own key — this module cannot tell, and replacing either
278
+ // one destroys information the caller never offered up. Refusing is the only outcome
279
+ // that is right in both readings.
280
+ return scribeRefuse('FRONTMATTER_CONFLICT', `the page already carries a top-level ${FRONTMATTER_KEY} key`);
281
+ }
282
+ }
283
+ /**
284
+ * No closing fence within the ceiling.
285
+ *
286
+ * ⚠⚠ AND THE MAPPING TEST DECIDES WHICH FAILURE THIS IS, which is the second half of the
287
+ * horizontal-rule fix. If what follows the first line reads as a mapping, this really is an
288
+ * unclosed frontmatter block and it REFUSES — prepending a second block would hand back a
289
+ * document with two, which is why the ceiling refuses rather than falling through.
290
+ *
291
+ * If it does NOT read as a mapping, the first line was a horizontal rule and this document has
292
+ * no frontmatter at all. It gets a block prepended, exactly as a document opening with prose
293
+ * would. The old code refused here, which made an ordinary Markdown file — a rule, then prose —
294
+ * permanently unwritable through this server, with a message about a closing fence the user had
295
+ * no reason to think they owed.
296
+ */
297
+ if (!looksLikeMapping(lines, 1, ceiling)) {
298
+ return prepended(keyLine, content);
299
+ }
300
+ return scribeRefuse('FRONTMATTER_INVALID', `the page opens a frontmatter block with no closing --- within ${MAX_FRONTMATTER_LINES} lines`);
301
+ }
302
+ //# sourceMappingURL=frontmatter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frontmatter.js","sourceRoot":"","sources":["../src/frontmatter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
3
+ import { createFsGate } from 'wyrd-fence';
4
+ import { main } from './main.js';
5
+ await main({
6
+ argv: process.argv.slice(2),
7
+ env: process.env,
8
+ makeFsGate: createFsGate,
9
+ makeTransport: () => new StdioServerTransport(),
10
+ stderr: line => process.stderr.write(`${line}\n`),
11
+ setExitCode: code => {
12
+ process.exitCode = code;
13
+ }
14
+ });
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,oCAAoC,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE1C,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,MAAM,IAAI,CAAC;IACP,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;IAC3B,GAAG,EAAE,OAAO,CAAC,GAAG;IAChB,UAAU,EAAE,YAAY;IACxB,aAAa,EAAE,GAAG,EAAE,CAAC,IAAI,oBAAoB,EAAE;IAC/C,MAAM,EAAE,IAAI,CAAC,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,IAAI,CAAC;IACjD,WAAW,EAAE,IAAI,CAAC,EAAE;QAChB,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAC5B,CAAC;CACJ,CAAC,CAAC"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * THE LEDGER SEAM — a TYPE, the production appender over the fence, and a refusing one for the arms.
3
+ *
4
+ * ⚠⚠ THE APPEND IS THE FENCE'S, NEVER THIS FILE'S. B4 rules the append is one `O_APPEND` write of
5
+ * one fully-serialised line, and `FsGate.appendLineInGrant` is that write — landed 2026-09-02 with
6
+ * its own containment argument, its own name screens and its own identity re-proof. Everything
7
+ * `gateAppender` below does is name the path and hand the bytes over. Reaching around the gate with
8
+ * `fs.appendFileSync` would put a second, unfenced write path in the very package whose D8 argument
9
+ * is that there is exactly one — and it is worth saying plainly that the temptation is real, since
10
+ * an append to a known relative path looks like the most harmless call in the file.
11
+ *
12
+ * ⚠ THIS FILE USED TO SAY THE PRIMITIVE DID NOT EXIST, AND THE ARM IT NAMED AS OWED IS NOW PAID.
13
+ * `ST22`–`ST25` append real lines to a real `.wyrd/lineage.jsonl` over a real grant: one line
14
+ * landing, three accumulating in order, a parent deleted underneath the write refusing after the
15
+ * page was created, and eight concurrent writers producing eight intact lines. The `ST-OWED`
16
+ * inventory row is gone rather than annotated, because an inventory that describes a debt already
17
+ * settled is worse than no row at all.
18
+ *
19
+ * ⚠ INJECTED, NOT IMPORTED, at the composition root. `writePage` takes the appender as a parameter
20
+ * so the arms can supply a spy or a refuser without the production path acquiring a mode flag. A
21
+ * module that knows whether it is under test is a module whose test proves less than it looks like.
22
+ */
23
+ import type { FenceRefusal, FsGate } from 'wyrd-fence';
24
+ /** What an append reports on success. `bytes` is the line INCLUDING its terminating LF. */
25
+ export interface Appended {
26
+ readonly ok: true;
27
+ readonly bytes: number;
28
+ }
29
+ /**
30
+ * ⚠ THE PARAMETER IS A FULLY-SERIALISED BUFFER, AND THE TYPE IS THE RULE. B4's atomicity argument
31
+ * holds for ONE write of ONE complete line; an implementation handed a record and left to serialise
32
+ * it could emit in pieces. Handing over bytes makes "never split a line" a property of the
33
+ * signature rather than of a comment inside whoever implements it.
34
+ */
35
+ export interface LedgerAppender {
36
+ appendLine(line: Buffer): Promise<Appended | FenceRefusal>;
37
+ }
38
+ /**
39
+ * THE PRODUCTION APPENDER. One line, through the gate, to the vault's own lineage log.
40
+ *
41
+ * ⚠⚠ THE FENCE'S RESULT IS PASSED THROUGH UNCHANGED, NOT REPACKAGED, and that is a decision rather
42
+ * than laziness. The fence's `Appended` carries `rel` alongside `ok` and `bytes`, so it already
43
+ * satisfies this module's narrower `Appended`; its `WriteRefusal` is a `FenceRefusal` carrying
44
+ * `retained`, which is the field that tells a caller whether a failed append may have left a
45
+ * partial line behind. Narrowing either one to this file's shape would DISCARD exactly the
46
+ * diagnostics the fence spent its slice producing — and `stamp.ts` hands the refusal straight to
47
+ * the caller inside `PAGE_WRITTEN_LEDGER_FAILED`, where the retention answer is the whole point.
48
+ *
49
+ * ⚠ THE PATH IS A CONSTANT AND THE CALLER CANNOT CHOOSE IT. A lineage log the caller can redirect
50
+ * is a provenance record that can be written somewhere nobody reads — so the only path this
51
+ * appender will ever name is `config.ts`'s `LINEAGE_PATH`, and the constant is shared with the
52
+ * config loader so the ledger and the config can never disagree about which directory is the
53
+ * vault's.
54
+ *
55
+ * ⚠ NO RETRY, ANYWHERE ABOVE THE FENCE EITHER. The fence refuses to retry under `O_APPEND` because
56
+ * a second attempt re-selects the end of file and splices this record through whatever another
57
+ * appender landed in between; a retry loop added HERE would manufacture precisely that, one level
58
+ * up, where the fence's comment explaining why cannot be seen.
59
+ */
60
+ export declare function gateAppender(gate: FsGate): LedgerAppender;
61
+ /**
62
+ * The appender the arms use to exercise the failure branch.
63
+ *
64
+ * ⚠ IT REFUSES RATHER THAN NO-OPS, deliberately. A no-op appender would make every arm above it
65
+ * green while the ledger silently recorded nothing — the exact "green on a path that does nothing"
66
+ * shape the suite runner exists to make impossible. It is no longer what the package ships with:
67
+ * `createScribe` now defaults to `gateAppender`, and the argument for that change is in `index.ts`.
68
+ */
69
+ export declare function refusingAppender(detail?: string): LedgerAppender;
package/dist/ledger.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * THE LEDGER SEAM — a TYPE, the production appender over the fence, and a refusing one for the arms.
3
+ *
4
+ * ⚠⚠ THE APPEND IS THE FENCE'S, NEVER THIS FILE'S. B4 rules the append is one `O_APPEND` write of
5
+ * one fully-serialised line, and `FsGate.appendLineInGrant` is that write — landed 2026-09-02 with
6
+ * its own containment argument, its own name screens and its own identity re-proof. Everything
7
+ * `gateAppender` below does is name the path and hand the bytes over. Reaching around the gate with
8
+ * `fs.appendFileSync` would put a second, unfenced write path in the very package whose D8 argument
9
+ * is that there is exactly one — and it is worth saying plainly that the temptation is real, since
10
+ * an append to a known relative path looks like the most harmless call in the file.
11
+ *
12
+ * ⚠ THIS FILE USED TO SAY THE PRIMITIVE DID NOT EXIST, AND THE ARM IT NAMED AS OWED IS NOW PAID.
13
+ * `ST22`–`ST25` append real lines to a real `.wyrd/lineage.jsonl` over a real grant: one line
14
+ * landing, three accumulating in order, a parent deleted underneath the write refusing after the
15
+ * page was created, and eight concurrent writers producing eight intact lines. The `ST-OWED`
16
+ * inventory row is gone rather than annotated, because an inventory that describes a debt already
17
+ * settled is worse than no row at all.
18
+ *
19
+ * ⚠ INJECTED, NOT IMPORTED, at the composition root. `writePage` takes the appender as a parameter
20
+ * so the arms can supply a spy or a refuser without the production path acquiring a mode flag. A
21
+ * module that knows whether it is under test is a module whose test proves less than it looks like.
22
+ */
23
+ import { LINEAGE_PATH } from './config.js';
24
+ /**
25
+ * THE PRODUCTION APPENDER. One line, through the gate, to the vault's own lineage log.
26
+ *
27
+ * ⚠⚠ THE FENCE'S RESULT IS PASSED THROUGH UNCHANGED, NOT REPACKAGED, and that is a decision rather
28
+ * than laziness. The fence's `Appended` carries `rel` alongside `ok` and `bytes`, so it already
29
+ * satisfies this module's narrower `Appended`; its `WriteRefusal` is a `FenceRefusal` carrying
30
+ * `retained`, which is the field that tells a caller whether a failed append may have left a
31
+ * partial line behind. Narrowing either one to this file's shape would DISCARD exactly the
32
+ * diagnostics the fence spent its slice producing — and `stamp.ts` hands the refusal straight to
33
+ * the caller inside `PAGE_WRITTEN_LEDGER_FAILED`, where the retention answer is the whole point.
34
+ *
35
+ * ⚠ THE PATH IS A CONSTANT AND THE CALLER CANNOT CHOOSE IT. A lineage log the caller can redirect
36
+ * is a provenance record that can be written somewhere nobody reads — so the only path this
37
+ * appender will ever name is `config.ts`'s `LINEAGE_PATH`, and the constant is shared with the
38
+ * config loader so the ledger and the config can never disagree about which directory is the
39
+ * vault's.
40
+ *
41
+ * ⚠ NO RETRY, ANYWHERE ABOVE THE FENCE EITHER. The fence refuses to retry under `O_APPEND` because
42
+ * a second attempt re-selects the end of file and splices this record through whatever another
43
+ * appender landed in between; a retry loop added HERE would manufacture precisely that, one level
44
+ * up, where the fence's comment explaining why cannot be seen.
45
+ */
46
+ export function gateAppender(gate) {
47
+ return Object.freeze({
48
+ appendLine: (line) => gate.appendLineInGrant(LINEAGE_PATH, line)
49
+ });
50
+ }
51
+ /**
52
+ * The appender the arms use to exercise the failure branch.
53
+ *
54
+ * ⚠ IT REFUSES RATHER THAN NO-OPS, deliberately. A no-op appender would make every arm above it
55
+ * green while the ledger silently recorded nothing — the exact "green on a path that does nothing"
56
+ * shape the suite runner exists to make impossible. It is no longer what the package ships with:
57
+ * `createScribe` now defaults to `gateAppender`, and the argument for that change is in `index.ts`.
58
+ */
59
+ export function refusingAppender(detail = 'the ledger append was refused') {
60
+ return Object.freeze({
61
+ appendLine: async () => Object.freeze({ ok: false, reason: 'IO_ERROR', detail, resolvedPath: '' })
62
+ });
63
+ }
64
+ //# sourceMappingURL=ledger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ledger.js","sourceRoot":"","sources":["../src/ledger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAIH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAkB3C;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY;IACrC,OAAO,MAAM,CAAC,MAAM,CAAC;QACjB,UAAU,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,IAAI,CAAC,iBAAiB,CAAC,YAAY,EAAE,IAAI,CAAC;KAC3E,CAAC,CAAC;AACP,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAM,GAAG,+BAA+B;IACrE,OAAO,MAAM,CAAC,MAAM,CAAC;QACjB,UAAU,EAAE,KAAK,IAA2B,EAAE,CAC1C,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,KAAc,EAAE,MAAM,EAAE,UAAmB,EAAE,MAAM,EAAE,YAAY,EAAE,EAAE,EAAE,CAAC;KACnG,CAAC,CAAC;AACP,CAAC"}