@miadi/inquiry-weave 0.8.3 → 0.9.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.
package/dist/mint.js ADDED
@@ -0,0 +1,743 @@
1
+ /**
2
+ * The episode door — mint, land, relate, author lineage, redeem a receipt —
3
+ * for a caller that never touches the filesystem: the HTTP route and the MCP
4
+ * server both call these and nothing else.
5
+ *
6
+ * The vessel manifest written here is passages' mkepisode shape
7
+ * (packages/passages/js/lib/mkepisode.js buildManifestYaml), lifted into this
8
+ * package so the app, which already depends on @miadi/inquiry-weave, has a
9
+ * typed writer without a process boundary. Byte parity is tested against
10
+ * passages' own function. scaffoldEpisode() keeps writing its thinner
11
+ * `status: scaffold` record for the callers it already has.
12
+ *
13
+ * Ref: jgwill/Miadi#621, jgwill/Miadi#584, jgwill/Miadi#501.
14
+ */
15
+ import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
16
+ import { existsSync } from "node:fs";
17
+ import { join, relative } from "node:path";
18
+ import { parse } from "yaml";
19
+ import { relate } from "./relate.js";
20
+ import { syncArtefact } from "./sync.js";
21
+ import { resolveArtefact } from "./artefact.js";
22
+ import { registerEpisode } from "./register.js";
23
+ import { appendLineageEdge, } from "./lineage.js";
24
+ import { projectLineageEdge } from "./lineage-edge.js";
25
+ import { DEFAULT_MW_URL, resolveEnv } from "./env.js";
26
+ import { hasEpisodeManifest, localDateStamp, missingManifestRemedy, resolveEpisode, } from "./episode.js";
27
+ import { episodeNodeId, registerEpisodeNode } from "./episode-node.js";
28
+ import { readEpisodeWeave } from "./weave.js";
29
+ import { chronicleGitRoot, integrateOrigin, landPaths, originTreeNames, preflightChronicle, withChronicleLock, } from "./git-landing.js";
30
+ import { closingIsProven, MW_RECEIPT_BASENAME, proveEpisodeClosing, refusePoisonedWheel, WHEEL_PROBE_TIMEOUT_MS, } from "./closing.js";
31
+ export class EpisodeServiceError extends Error {
32
+ code;
33
+ detail;
34
+ constructor(code, message, detail) {
35
+ super(message);
36
+ this.code = code;
37
+ this.detail = detail;
38
+ this.name = "EpisodeServiceError";
39
+ }
40
+ }
41
+ // ── Manifest (mkepisode shape) ──────────────────────────────────────────────
42
+ export function normalizeSlug(title) {
43
+ return title
44
+ .trim()
45
+ .toLowerCase()
46
+ .replace(/[^a-z0-9]+/g, "-")
47
+ .replace(/^-+|-+$/g, "");
48
+ }
49
+ const PLAIN_SCALAR = /^[A-Za-z][A-Za-z0-9 ._-]*$/;
50
+ const YAML_RESERVED = /^(true|false|null|~|yes|no|on|off)$/i;
51
+ export function yamlScalar(value) {
52
+ const text = String(value);
53
+ if (PLAIN_SCALAR.test(text) && !YAML_RESERVED.test(text))
54
+ return text;
55
+ return JSON.stringify(text);
56
+ }
57
+ /** Byte-identical to passages' mkepisode buildManifestYaml(). */
58
+ export function buildVesselManifestYaml(vessel) {
59
+ const lines = [
60
+ `episode: ${vessel.number}`,
61
+ `title: ${yamlScalar(vessel.title)}`,
62
+ `slug: ${yamlScalar(vessel.slug)}`,
63
+ `date: '${vessel.date}'`,
64
+ "series: miadi-chronicle",
65
+ `status: ${yamlScalar(vessel.status ?? "vessel")}`,
66
+ "type: chronicle-episode",
67
+ `goal: ${yamlScalar(vessel.goal)}`,
68
+ "references:",
69
+ ...vessel.references.map((reference) => ` - ${yamlScalar(reference)}`),
70
+ ];
71
+ return `${lines.join("\n")}\n`;
72
+ }
73
+ const ISSUE_REF = /^[\w.-]+\/[\w.-]+#\d+$/;
74
+ export function findSourceIssue(references) {
75
+ return references.find((reference) => ISSUE_REF.test(reference));
76
+ }
77
+ // ── Context ─────────────────────────────────────────────────────────────────
78
+ /** `MIADI_CHRONICLE_MW_URL` is the wheel variable of record (chronicle commit 1900c1b, 2026-09-04). */
79
+ export function chronicleWheelUrl(env = process.env) {
80
+ const url = env.MIADI_CHRONICLE_MW_URL?.trim() || env.MW_API_URL?.trim() || DEFAULT_MW_URL;
81
+ return url.replace(/\/+$/, "");
82
+ }
83
+ export function resolveDoorContext(options = {}, env = process.env) {
84
+ const weave = resolveEnv({ chronicleRoot: options.chronicleRoot, inquiryRoot: options.inquiryRoot }, env);
85
+ return {
86
+ chronicleRoot: weave.chronicleRoot,
87
+ inquiryRoot: weave.inquiryRoot,
88
+ wheelUrl: options.wheelUrl ?? chronicleWheelUrl(env),
89
+ gitRoot: options.gitRoot,
90
+ now: options.now,
91
+ fetchImpl: options.fetchImpl,
92
+ };
93
+ }
94
+ async function gitRootOf(context) {
95
+ if (context.gitRoot)
96
+ return context.gitRoot;
97
+ context.gitRoot = await chronicleGitRoot(context.chronicleRoot);
98
+ return context.gitRoot;
99
+ }
100
+ function nowOf(context) {
101
+ return context.now ? context.now() : new Date();
102
+ }
103
+ async function wheelEpisodeNames(context) {
104
+ const fetchImpl = context.fetchImpl ?? fetch;
105
+ const url = `${context.wheelUrl}/api/nodes?kind=chronicle_episode&parent_id=${encodeURIComponent("chronicle:miadi-chronicle")}&limit=5000`;
106
+ const controller = new AbortController();
107
+ const timer = setTimeout(() => controller.abort(), WHEEL_PROBE_TIMEOUT_MS);
108
+ try {
109
+ const response = await fetchImpl(url, { signal: controller.signal });
110
+ if (!response.ok)
111
+ return { ok: false, names: [], observed: `GET ${url}: HTTP ${response.status}` };
112
+ const body = (await response.json());
113
+ const names = (body.nodes ?? [])
114
+ .map((node) => (typeof node.id === "string" ? node.id.replace(/^chronicle:/, "") : ""))
115
+ .filter(Boolean);
116
+ const truncated = body.truncated === true ? " (TRUNCATED — the wheel did not return every card)" : "";
117
+ return { ok: true, names, observed: `${names.length} wheel cards${truncated}` };
118
+ }
119
+ catch (error) {
120
+ return { ok: false, names: [], observed: `wheel unreachable at ${url}: ${error instanceof Error ? error.message : String(error)}` };
121
+ }
122
+ finally {
123
+ clearTimeout(timer);
124
+ }
125
+ }
126
+ async function knownNames(context, gitRoot) {
127
+ let disk = [];
128
+ try {
129
+ disk = await readdir(context.chronicleRoot);
130
+ }
131
+ catch {
132
+ disk = [];
133
+ }
134
+ const subdir = relative(gitRoot, context.chronicleRoot);
135
+ const origin = await originTreeNames(gitRoot, subdir);
136
+ const wheel = await wheelEpisodeNames(context);
137
+ return {
138
+ disk,
139
+ origin: origin.names,
140
+ wheel: wheel.names,
141
+ originAnswered: origin.ok,
142
+ wheelAnswered: wheel.ok,
143
+ originObserved: origin.observed,
144
+ wheelObserved: wheel.observed,
145
+ };
146
+ }
147
+ function numberPattern(number) {
148
+ return new RegExp(`-episode-0*${number}-`);
149
+ }
150
+ const EPISODE_NUMBER = /episode-(\d+)/;
151
+ function maxNumber(names) {
152
+ let max = 0;
153
+ for (const name of names) {
154
+ const match = EPISODE_NUMBER.exec(name);
155
+ if (match)
156
+ max = Math.max(max, Number(match[1]));
157
+ }
158
+ return max;
159
+ }
160
+ /**
161
+ * Is `number` free everywhere the chronicle is known? Fetches origin first
162
+ * (jgwill/Miadi#501) and lists the wheel (jgwill/Miadi#584). Read-only.
163
+ */
164
+ export async function checkEpisodeNumber(context, number, options = {}) {
165
+ const gitRoot = await gitRootOf(context);
166
+ if (!options.skipFetch)
167
+ await integrateOrigin(gitRoot, { dryRun: true });
168
+ const known = await knownNames(context, gitRoot);
169
+ const pattern = numberPattern(number);
170
+ const existing = [];
171
+ for (const name of known.disk)
172
+ if (pattern.test(name))
173
+ existing.push({ name, seen: "disk" });
174
+ for (const name of known.origin)
175
+ if (pattern.test(name) && !existing.some((e) => e.name === name))
176
+ existing.push({ name, seen: "origin" });
177
+ for (const name of known.wheel)
178
+ if (pattern.test(name) && !existing.some((e) => e.name === name))
179
+ existing.push({ name, seen: "wheel" });
180
+ const union = new Set([...known.disk, ...known.origin]);
181
+ const blind = [
182
+ known.originAnswered ? "" : `origin unreadable (${known.originObserved})`,
183
+ known.wheelAnswered ? "" : `wheel unreadable (${known.wheelObserved})`,
184
+ ].filter(Boolean);
185
+ const observed = existing.length
186
+ ? `episode ${number} already resolves: ${existing.map((e) => `${e.name} [${e.seen}]`).join(", ")}`
187
+ : `episode ${number} is free over ${union.size} names (disk ∪ origin/main) and ${known.wheel.length} wheel cards` +
188
+ (blind.length ? `; ${blind.join("; ")}` : "");
189
+ return {
190
+ number,
191
+ taken: existing.length > 0,
192
+ existing,
193
+ observed,
194
+ originAnswered: known.originAnswered,
195
+ wheelAnswered: known.wheelAnswered,
196
+ };
197
+ }
198
+ /**
199
+ * max(disk ∪ origin/main ∪ wheel) + 1. Never fills a hole. Refuses when the
200
+ * wheel does not answer: an allocation blind to the wheel is the #584 defect.
201
+ */
202
+ export async function previewAllocation(context, options = {}) {
203
+ const gitRoot = await gitRootOf(context);
204
+ if (!options.skipFetch)
205
+ await integrateOrigin(gitRoot, { dryRun: true });
206
+ const known = await knownNames(context, gitRoot);
207
+ if (!known.wheelAnswered) {
208
+ throw new EpisodeServiceError("number_unverifiable", `cannot allocate a number while the wheel does not answer (${known.wheelObserved}); pass an explicit number or retry`);
209
+ }
210
+ const max = maxNumber([...known.disk, ...known.origin, ...known.wheel]);
211
+ return {
212
+ next: max + 1,
213
+ max,
214
+ observed: `max ${max} over ${known.disk.length} disk names, ${known.origin.length} origin/main names, ${known.wheel.length} wheel cards` +
215
+ (known.originAnswered ? "" : `; origin unreadable (${known.originObserved})`),
216
+ originAnswered: known.originAnswered,
217
+ wheelAnswered: known.wheelAnswered,
218
+ };
219
+ }
220
+ async function registerAndReceipt(context, episode, card, options) {
221
+ const node_id = episodeNodeId(episode.name);
222
+ if (options.dryRun) {
223
+ return { state: "would-register", node_id, url: context.wheelUrl, receiptWritten: false };
224
+ }
225
+ let outcome;
226
+ try {
227
+ outcome = await registerEpisodeNode(context.wheelUrl, {
228
+ episodeName: episode.name,
229
+ name: `Episode ${episode.number} — ${card.title}`,
230
+ description: card.goal,
231
+ sourceIssue: card.sourceIssue,
232
+ }, { fetchImpl: context.fetchImpl });
233
+ }
234
+ catch (error) {
235
+ outcome = { state: "pending", node_id, error: error instanceof Error ? error.message : String(error) };
236
+ }
237
+ const receipt = {
238
+ state: outcome.state,
239
+ node_id: outcome.node_id,
240
+ timestamp: nowOf(context).toISOString(),
241
+ url: context.wheelUrl,
242
+ };
243
+ if (outcome.error)
244
+ receipt.error = outcome.error;
245
+ let receiptWritten = false;
246
+ try {
247
+ await writeFile(join(episode.dir, MW_RECEIPT_BASENAME), `${JSON.stringify(receipt, null, 2)}\n`, "utf8");
248
+ receiptWritten = true;
249
+ }
250
+ catch {
251
+ receiptWritten = false;
252
+ }
253
+ return { state: outcome.state, node_id: outcome.node_id, url: context.wheelUrl, error: outcome.error, receiptWritten };
254
+ }
255
+ /**
256
+ * The files the door itself knows to be the vessel's, relative to the git root:
257
+ * manifest, receipt, the weave record, and each synced artefact copy. Never the
258
+ * whole directory — a chapter someone is drafting there is not the door's.
259
+ */
260
+ export async function vesselPaths(gitRoot, episode) {
261
+ const rel = relative(gitRoot, episode.dir);
262
+ const paths = [`${rel}/episode.yaml`, `${rel}/${MW_RECEIPT_BASENAME}`, `${rel}/inquiry/weave.yaml`];
263
+ try {
264
+ const weave = await readEpisodeWeave(episode.dir);
265
+ for (const entry of weave?.inquiries ?? [])
266
+ paths.push(`${rel}/inquiry/${entry.artefact}`);
267
+ }
268
+ catch {
269
+ // an unreadable weave file is reported by status, not by landing
270
+ }
271
+ return paths.filter((path) => existsSync(join(gitRoot, path)));
272
+ }
273
+ async function readManifestSummary(episode) {
274
+ const path = join(episode.dir, "episode.yaml");
275
+ if (!existsSync(path))
276
+ return { present: false };
277
+ try {
278
+ const parsed = parse(await readFile(path, "utf8"));
279
+ return {
280
+ present: true,
281
+ status: typeof parsed?.status === "string" ? parsed.status : undefined,
282
+ title: typeof parsed?.title === "string" ? parsed.title : undefined,
283
+ goal: typeof parsed?.goal === "string" ? parsed.goal : undefined,
284
+ references: Array.isArray(parsed?.references) ? parsed.references.map(String) : undefined,
285
+ };
286
+ }
287
+ catch {
288
+ return { present: true };
289
+ }
290
+ }
291
+ async function closingFor(context, folder) {
292
+ return proveEpisodeClosing({ chronicleRoot: context.chronicleRoot, gitRoot: await gitRootOf(context), wheelUrl: context.wheelUrl, fetchImpl: context.fetchImpl }, folder);
293
+ }
294
+ async function requirePreflight(gitRoot) {
295
+ const preflight = await preflightChronicle(gitRoot);
296
+ if (!preflight.ok)
297
+ throw new EpisodeServiceError(preflight.code, preflight.message);
298
+ }
299
+ function commitBody(lead, references) {
300
+ const issue = findSourceIssue(references);
301
+ const lines = [lead, ""];
302
+ if (issue)
303
+ lines.push(`Ref: ${issue}`);
304
+ for (const reference of references)
305
+ if (reference !== issue)
306
+ lines.push(`Reference: ${reference}`);
307
+ lines.push("", "Minted-By: miadi-api");
308
+ return lines.join("\n");
309
+ }
310
+ // ── Resolve with the door's error taxonomy ──────────────────────────────────
311
+ export function parseEpisodeRef(raw) {
312
+ const ref = typeof raw === "string" ? raw.trim() : "";
313
+ if (!ref)
314
+ throw new EpisodeServiceError("invalid_ref", "episode reference is required (a number, epNNN, or a directory name)");
315
+ if (ref.includes("/") || ref.includes("\\") || ref === "." || ref === "..") {
316
+ throw new EpisodeServiceError("invalid_ref", `episode reference '${ref}' looks like a path — the door resolves numbers, epNNN, or directory names only`);
317
+ }
318
+ return ref;
319
+ }
320
+ export async function resolveDoorEpisode(context, ref) {
321
+ // EpisodeResolutionError / AmbiguousEpisodeError pass through untouched so
322
+ // the route maps them to 404 / 409 exactly as the attention door does.
323
+ return resolveEpisode(context.chronicleRoot, parseEpisodeRef(ref));
324
+ }
325
+ export async function episodeDoorStatus(context, ref) {
326
+ const identity = await resolveDoorEpisode(context, ref);
327
+ const report = await closingFor(context, identity.name);
328
+ return { ...report, identity, manifest: await readManifestSummary(identity), ok: closingIsProven(report) };
329
+ }
330
+ async function landIdentity(context, identity, input, defaults) {
331
+ const gitRoot = await gitRootOf(context);
332
+ await requirePreflight(gitRoot);
333
+ const files = await vesselPaths(gitRoot, identity);
334
+ const landing = await landPaths({
335
+ gitRoot,
336
+ paths: files,
337
+ subject: input.subject?.trim() || defaults.subject,
338
+ body: input.body?.trim() || defaults.body,
339
+ dryRun: input.dryRun,
340
+ push: input.push,
341
+ });
342
+ const report = await closingFor(context, identity.name);
343
+ return {
344
+ ok: closingIsProven(report),
345
+ dryRun: input.dryRun === true,
346
+ identity,
347
+ files,
348
+ landing,
349
+ closing: report.closing,
350
+ drift: report.drift,
351
+ owedActions: report.owedActions,
352
+ };
353
+ }
354
+ export async function landEpisode(context, ref, input = {}) {
355
+ const identity = await resolveDoorEpisode(context, ref);
356
+ const manifest = await readManifestSummary(identity);
357
+ const gitRoot = await gitRootOf(context);
358
+ return withChronicleLock(gitRoot, () => landIdentity(context, identity, input, {
359
+ subject: `ep${identity.number ?? "?"}: ${manifest.title ?? identity.slug ?? identity.name}`,
360
+ body: commitBody("Vessel landed through POST /api/chronicle/episodes/{ref}/land.", manifest.references ?? []),
361
+ }));
362
+ }
363
+ export function parseLineageField(raw) {
364
+ switch (raw) {
365
+ case "continues_from":
366
+ case "continues-from":
367
+ return "continues_from";
368
+ case "relates_to":
369
+ case "relates-to":
370
+ case "related-to":
371
+ return "relates_to";
372
+ default:
373
+ throw new EpisodeServiceError("invalid_field", `field must be continues_from or relates_to (got '${String(raw)}')`);
374
+ }
375
+ }
376
+ async function authorEdges(context, from, edges, options) {
377
+ if (!hasEpisodeManifest(from.dir)) {
378
+ throw new EpisodeServiceError("manifest_missing", missingManifestRemedy(from.dir));
379
+ }
380
+ const reports = [];
381
+ for (const edge of edges) {
382
+ const to = await resolveDoorEpisode(context, edge.to);
383
+ if (!hasEpisodeManifest(to.dir) && edge.reverse !== undefined) {
384
+ throw new EpisodeServiceError("manifest_missing", missingManifestRemedy(to.dir));
385
+ }
386
+ const pairs = [
387
+ { field: edge.field, a: from, b: to, relation: edge.relation },
388
+ ];
389
+ if (edge.reverse !== undefined)
390
+ pairs.push({ field: "relates_to", a: to, b: from, relation: edge.reverse });
391
+ for (const pair of pairs) {
392
+ const manifest = await appendLineageEdge({
393
+ episodeYamlPath: join(pair.a.dir, "episode.yaml"),
394
+ field: pair.field,
395
+ target: { episode: pair.b.number, path: pair.b.path },
396
+ relation: pair.relation,
397
+ dryRun: options.dryRun,
398
+ });
399
+ const wheel = await projectLineageEdge(context.wheelUrl, {
400
+ fromNodeId: episodeNodeId(pair.a.path),
401
+ toNodeId: episodeNodeId(pair.b.path),
402
+ relationshipType: pair.field,
403
+ description: pair.relation,
404
+ }, { dryRun: options.dryRun, fetchImpl: context.fetchImpl });
405
+ const { before: _before, after: _after, ...rest } = manifest;
406
+ reports.push({ field: pair.field, from: pair.a.name, to: pair.b.name, manifest: rest, wheel });
407
+ }
408
+ }
409
+ return reports;
410
+ }
411
+ export async function authorEpisodeLineage(context, ref, input) {
412
+ const relation = input.relation?.trim();
413
+ if (!relation)
414
+ throw new EpisodeServiceError("identity_required", "a lineage edge needs field, to, and a non-empty relation");
415
+ const field = parseLineageField(input.field);
416
+ parseEpisodeRef(input.to);
417
+ const identity = await resolveDoorEpisode(context, ref);
418
+ const gitRoot = await gitRootOf(context);
419
+ return withChronicleLock(gitRoot, async () => {
420
+ const edges = await authorEdges(context, identity, [{ ...input, field, relation }], { dryRun: input.dryRun });
421
+ let landing;
422
+ if (input.land !== false) {
423
+ const targets = edges.map((edge) => edge.to).filter((name, index, all) => all.indexOf(name) === index);
424
+ landing = await landIdentity(context, identity, { dryRun: input.dryRun }, {
425
+ subject: `ep${identity.number ?? "?"}: lineage ${input.field} -> ${input.to}`,
426
+ body: commitBody(`Lineage authored through POST /api/chronicle/episodes/{ref}/lineage.\n\nEdges: ${edges.map((e) => `${e.from} ${e.field} ${e.to}`).join("; ")}`, []),
427
+ });
428
+ // A reverse edge lives in the target's manifest; land that file too.
429
+ if (input.reverse !== undefined && !input.dryRun) {
430
+ for (const name of targets) {
431
+ const target = await resolveDoorEpisode(context, name);
432
+ await landIdentity(context, target, { dryRun: false }, {
433
+ subject: `ep${target.number ?? "?"}: lineage relates_to -> ${identity.name}`,
434
+ body: commitBody("Reverse lineage authored through POST /api/chronicle/episodes/{ref}/lineage.", []),
435
+ });
436
+ }
437
+ }
438
+ }
439
+ const report = await closingFor(context, identity.name);
440
+ return {
441
+ ok: closingIsProven(report) && edges.every((edge) => edge.wheel.state !== "pending"),
442
+ dryRun: input.dryRun === true,
443
+ identity,
444
+ edges,
445
+ landing,
446
+ closing: report.closing,
447
+ drift: report.drift,
448
+ owedActions: report.owedActions,
449
+ };
450
+ });
451
+ }
452
+ export function parseArtefactName(raw) {
453
+ const name = typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
454
+ if (!name)
455
+ throw new EpisodeServiceError("identity_required", "artefact is required (a directory name under the inquiry root)");
456
+ if (name.includes("/") || name.includes("\\") || name.startsWith(".")) {
457
+ throw new EpisodeServiceError("invalid_artefact", `artefact '${name}' must be a bare directory name under the inquiry root, not a path`);
458
+ }
459
+ return name;
460
+ }
461
+ async function weaveInquiry(context, identity, input) {
462
+ const name = parseArtefactName(input.artefact);
463
+ if (input.issue !== undefined && !ISSUE_REF.test(input.issue.trim())) {
464
+ throw new EpisodeServiceError("invalid_reference", `issue '${input.issue}' must be owner/repo#N`);
465
+ }
466
+ let artefact;
467
+ try {
468
+ artefact = resolveArtefact(context.inquiryRoot, name);
469
+ }
470
+ catch (error) {
471
+ throw new EpisodeServiceError("invalid_artefact", error instanceof Error ? error.message : String(error));
472
+ }
473
+ const related = await relate({
474
+ artefact,
475
+ episode: identity,
476
+ issue: input.issue?.trim(),
477
+ now: context.now,
478
+ dryRun: input.dryRun,
479
+ });
480
+ const synced = await syncArtefact(artefact.dir, identity.dir, {
481
+ dryRun: input.dryRun,
482
+ allowUnrelated: input.dryRun,
483
+ now: context.now,
484
+ });
485
+ let registered;
486
+ let registerError;
487
+ if (!input.dryRun) {
488
+ try {
489
+ registered = await registerEpisode({ chronicleRoot: context.chronicleRoot, inquiryRoot: context.inquiryRoot, mwUrl: context.wheelUrl, repo: resolveEnv().repo }, identity, { now: context.now });
490
+ }
491
+ catch (error) {
492
+ registerError = error instanceof Error ? error.message : String(error);
493
+ }
494
+ }
495
+ return {
496
+ related,
497
+ synced,
498
+ registered,
499
+ registerError,
500
+ artefactSide: input.dryRun
501
+ ? "not written (dry run)"
502
+ : `written under ${artefact.dir} (.weave.yaml, AGENTS.md) — another repository; not committed by this door`,
503
+ };
504
+ }
505
+ export async function weaveEpisodeInquiry(context, ref, input) {
506
+ const identity = await resolveDoorEpisode(context, ref);
507
+ const gitRoot = await gitRootOf(context);
508
+ return withChronicleLock(gitRoot, async () => {
509
+ const inquiry = await weaveInquiry(context, identity, input);
510
+ let landing;
511
+ if (input.land !== false) {
512
+ landing = await landIdentity(context, identity, { dryRun: input.dryRun }, {
513
+ subject: `ep${identity.number ?? "?"}: weave ${inquiry.related.artefact}`,
514
+ body: commitBody("Inquiry woven and synced through POST /api/chronicle/episodes/{ref}/inquiry.", inquiry.related.issue ? [inquiry.related.issue] : []),
515
+ });
516
+ }
517
+ const report = await closingFor(context, identity.name);
518
+ return {
519
+ ok: closingIsProven(report) && !inquiry.registerError,
520
+ dryRun: input.dryRun === true,
521
+ identity,
522
+ inquiry,
523
+ landing,
524
+ closing: report.closing,
525
+ drift: report.drift,
526
+ owedActions: report.owedActions,
527
+ };
528
+ });
529
+ }
530
+ export async function redeemEpisodeRegistration(context, ref, input = {}) {
531
+ refusePoisonedWheelOrThrow(context.wheelUrl);
532
+ const identity = await resolveDoorEpisode(context, ref);
533
+ if (!hasEpisodeManifest(identity.dir)) {
534
+ throw new EpisodeServiceError("manifest_missing", missingManifestRemedy(identity.dir));
535
+ }
536
+ const manifest = await readManifestSummary(identity);
537
+ const gitRoot = await gitRootOf(context);
538
+ return withChronicleLock(gitRoot, async () => {
539
+ const registration = await registerAndReceipt(context, identity, {
540
+ title: manifest.title ?? identity.slug ?? identity.name,
541
+ goal: manifest.goal ?? "",
542
+ sourceIssue: findSourceIssue(manifest.references ?? []),
543
+ }, { dryRun: input.dryRun });
544
+ let landing;
545
+ if (input.land !== false) {
546
+ landing = await landIdentity(context, identity, { dryRun: input.dryRun }, {
547
+ subject: `ep${identity.number ?? "?"}: registration receipt ${registration.state}`,
548
+ body: commitBody("Receipt redeemed through POST /api/chronicle/episodes/{ref}/register.", manifest.references ?? []),
549
+ });
550
+ }
551
+ const report = await closingFor(context, identity.name);
552
+ return {
553
+ ok: closingIsProven(report),
554
+ dryRun: input.dryRun === true,
555
+ identity,
556
+ registration,
557
+ landing,
558
+ closing: report.closing,
559
+ drift: report.drift,
560
+ owedActions: report.owedActions,
561
+ };
562
+ });
563
+ }
564
+ function refusePoisonedWheelOrThrow(wheelUrl) {
565
+ try {
566
+ refusePoisonedWheel(wheelUrl);
567
+ }
568
+ catch (error) {
569
+ throw new EpisodeServiceError("wheel_refused", error instanceof Error ? error.message : String(error));
570
+ }
571
+ }
572
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
573
+ function validateMintInput(input) {
574
+ const title = typeof input.title === "string" ? input.title.trim() : "";
575
+ const goal = typeof input.goal === "string" ? input.goal.trim() : "";
576
+ const references = Array.isArray(input.references)
577
+ ? input.references.map((reference) => (typeof reference === "string" ? reference.trim() : "")).filter(Boolean)
578
+ : [];
579
+ if (!title || !goal || references.length === 0) {
580
+ throw new EpisodeServiceError("identity_required", "title, goal, and at least one reference are required");
581
+ }
582
+ const slug = normalizeSlug(title);
583
+ if (!slug)
584
+ throw new EpisodeServiceError("identity_required", `title '${title}' yields an empty slug`);
585
+ const status = typeof input.status === "string" && input.status.trim() ? input.status.trim() : "vessel";
586
+ if (input.date !== undefined && (typeof input.date !== "string" || !DATE.test(input.date))) {
587
+ throw new EpisodeServiceError("invalid_date", "date must be YYYY-MM-DD");
588
+ }
589
+ let number;
590
+ if (input.number !== undefined && input.number !== null) {
591
+ if (typeof input.number !== "number" || !Number.isSafeInteger(input.number) || input.number < 1) {
592
+ throw new EpisodeServiceError("invalid_number", "number must be a positive integer");
593
+ }
594
+ number = input.number;
595
+ }
596
+ const lineage = [];
597
+ for (const edge of input.lineage ?? []) {
598
+ if (typeof edge?.relation !== "string" || !edge.relation.trim()) {
599
+ throw new EpisodeServiceError("identity_required", "each lineage edge needs field, to, and a non-empty relation");
600
+ }
601
+ lineage.push({
602
+ field: parseLineageField(edge.field),
603
+ to: parseEpisodeRef(edge.to),
604
+ relation: edge.relation.trim(),
605
+ reverse: typeof edge.reverse === "string" && edge.reverse.trim() ? edge.reverse.trim() : undefined,
606
+ });
607
+ }
608
+ if (input.inquiry)
609
+ parseArtefactName(input.inquiry.artefact);
610
+ return { title, slug, goal, references, status, date: input.date, number, lineage };
611
+ }
612
+ export async function mintEpisode(context, input) {
613
+ const vessel = validateMintInput(input);
614
+ const dryRun = input.dryRun === true;
615
+ const register = input.register !== false;
616
+ if (register)
617
+ refusePoisonedWheelOrThrow(context.wheelUrl);
618
+ const gitRoot = await gitRootOf(context);
619
+ return withChronicleLock(gitRoot, async () => {
620
+ await requirePreflight(gitRoot);
621
+ // Integrate first so the disk sees what origin has (jgwill/Miadi#501). In a
622
+ // dry run the fetch happens and the tree is left alone.
623
+ const integration = await integrateOrigin(gitRoot, { dryRun });
624
+ if (integration.state === "refused") {
625
+ throw new EpisodeServiceError("chronicle_integration_refused", integration.observed, integration);
626
+ }
627
+ let number;
628
+ let numberObserved;
629
+ let source;
630
+ if (vessel.number !== undefined) {
631
+ const check = await checkEpisodeNumber(context, vessel.number, { skipFetch: true });
632
+ if (check.taken) {
633
+ throw new EpisodeServiceError("number_taken", check.observed, { existing: check.existing });
634
+ }
635
+ number = vessel.number;
636
+ numberObserved = check.observed;
637
+ source = "explicit";
638
+ }
639
+ else {
640
+ const allocation = await previewAllocation(context, { skipFetch: true });
641
+ number = allocation.next;
642
+ numberObserved = `allocated ${number}: ${allocation.observed}`;
643
+ source = "allocated";
644
+ }
645
+ const date = vessel.date ?? localDateStamp(nowOf(context));
646
+ const name = `${date}-episode-${String(number).padStart(3, "0")}-${vessel.slug}`;
647
+ const dir = join(context.chronicleRoot, name);
648
+ const identity = { number, slug: vessel.slug, date, name, dir, path: name };
649
+ const manifest = buildVesselManifestYaml({ ...vessel, number, date });
650
+ const sourceIssue = findSourceIssue(vessel.references);
651
+ const rel = relative(gitRoot, dir);
652
+ if (dryRun) {
653
+ const lineage = vessel.lineage.length
654
+ ? await authorEdges(context, { ...identity }, vessel.lineage, { dryRun: true }).catch((error) => {
655
+ // The vessel does not exist yet in a dry run; the manifest check refuses.
656
+ if (error instanceof EpisodeServiceError && error.code === "manifest_missing")
657
+ return [];
658
+ throw error;
659
+ })
660
+ : [];
661
+ const report = await closingFor(context, name);
662
+ return {
663
+ ok: false,
664
+ dryRun: true,
665
+ identity,
666
+ number: { source, observed: numberObserved },
667
+ manifest,
668
+ files: [`${rel}/episode.yaml`, ...(register ? [`${rel}/${MW_RECEIPT_BASENAME}`] : [])],
669
+ lineage,
670
+ inquiry: null,
671
+ registration: {
672
+ state: register ? "would-register" : "skipped",
673
+ node_id: episodeNodeId(name),
674
+ url: context.wheelUrl,
675
+ receiptWritten: false,
676
+ },
677
+ landing: null,
678
+ integration,
679
+ closing: report.closing,
680
+ drift: report.drift,
681
+ owedActions: report.owedActions,
682
+ };
683
+ }
684
+ // mkdir without recursive: EEXIST is the atomicity primitive between the
685
+ // number check and the write (mkepisode.js uses `wx` for the same reason).
686
+ try {
687
+ await mkdir(dir);
688
+ }
689
+ catch (error) {
690
+ throw new EpisodeServiceError("number_taken", `episode directory appeared between the check and the write: ${dir} (${error instanceof Error ? error.message : String(error)})`);
691
+ }
692
+ const manifestPath = join(dir, "episode.yaml");
693
+ const temporary = join(dir, `.episode.yaml.mint-${process.pid}`);
694
+ await writeFile(temporary, manifest, { encoding: "utf8", flag: "wx" });
695
+ await rename(temporary, manifestPath);
696
+ const lineage = vessel.lineage.length ? await authorEdges(context, identity, vessel.lineage, { dryRun: false }) : [];
697
+ const inquiry = input.inquiry ? await weaveInquiry(context, identity, { ...input.inquiry, dryRun: false }) : null;
698
+ const registration = register
699
+ ? await registerAndReceipt(context, identity, { title: vessel.title, goal: vessel.goal, sourceIssue }, { dryRun: false })
700
+ : { state: "skipped", node_id: episodeNodeId(name), url: context.wheelUrl, receiptWritten: false };
701
+ const files = await vesselPaths(gitRoot, identity);
702
+ for (const edge of lineage) {
703
+ // A reverse edge changed the target's manifest; that file lands in the same commit.
704
+ if (edge.from !== identity.name) {
705
+ const targetPath = `${relative(gitRoot, join(context.chronicleRoot, edge.from))}/episode.yaml`;
706
+ if (!files.includes(targetPath))
707
+ files.push(targetPath);
708
+ }
709
+ }
710
+ let landing = null;
711
+ if (input.land !== false) {
712
+ landing = await landPaths({
713
+ gitRoot,
714
+ paths: files,
715
+ subject: `ep${number}: ${vessel.title}`,
716
+ body: commitBody("Vessel minted through POST /api/chronicle/episodes.", vessel.references),
717
+ dryRun: false,
718
+ });
719
+ }
720
+ const report = await closingFor(context, name);
721
+ return {
722
+ ok: closingIsProven(report),
723
+ dryRun: false,
724
+ identity,
725
+ number: { source, observed: numberObserved },
726
+ manifest,
727
+ files,
728
+ lineage,
729
+ inquiry,
730
+ registration,
731
+ landing,
732
+ integration,
733
+ closing: report.closing,
734
+ drift: report.drift,
735
+ owedActions: report.owedActions,
736
+ };
737
+ });
738
+ }
739
+ /** Map library errors to the door's codes; anything unknown passes through. */
740
+ export function isDoorError(error) {
741
+ return error instanceof EpisodeServiceError;
742
+ }
743
+ //# sourceMappingURL=mint.js.map