@miadi/inquiry-weave 0.16.6 → 0.17.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.
Files changed (47) hide show
  1. package/dist/artefact-dsl.d.ts +58 -0
  2. package/dist/artefact-dsl.d.ts.map +1 -0
  3. package/dist/artefact-dsl.js +129 -0
  4. package/dist/artefact-dsl.js.map +1 -0
  5. package/dist/index.d.ts +5 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +5 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server.js +162 -1
  10. package/dist/mcp-server.js.map +1 -1
  11. package/dist/mint.d.ts +199 -1
  12. package/dist/mint.d.ts.map +1 -1
  13. package/dist/mint.js +451 -0
  14. package/dist/mint.js.map +1 -1
  15. package/dist/notebook.d.ts +88 -0
  16. package/dist/notebook.d.ts.map +1 -0
  17. package/dist/notebook.js +258 -0
  18. package/dist/notebook.js.map +1 -0
  19. package/dist/prototype.d.ts +90 -0
  20. package/dist/prototype.d.ts.map +1 -0
  21. package/dist/prototype.js +286 -0
  22. package/dist/prototype.js.map +1 -0
  23. package/dist/references.d.ts +2 -0
  24. package/dist/references.d.ts.map +1 -1
  25. package/dist/references.js +2 -0
  26. package/dist/references.js.map +1 -1
  27. package/dist/rise-dsl.d.ts +44 -0
  28. package/dist/rise-dsl.d.ts.map +1 -0
  29. package/dist/rise-dsl.js +99 -0
  30. package/dist/rise-dsl.js.map +1 -0
  31. package/dist/rise-workspaces.d.ts +34 -0
  32. package/dist/rise-workspaces.d.ts.map +1 -0
  33. package/dist/rise-workspaces.js +73 -0
  34. package/dist/rise-workspaces.js.map +1 -0
  35. package/dist/rispec.d.ts +72 -0
  36. package/dist/rispec.d.ts.map +1 -0
  37. package/dist/rispec.js +227 -0
  38. package/dist/rispec.js.map +1 -0
  39. package/dist/text-references.d.ts +1 -1
  40. package/dist/text-references.d.ts.map +1 -1
  41. package/dist/text-references.js +18 -2
  42. package/dist/text-references.js.map +1 -1
  43. package/dist/tracing.d.ts +95 -0
  44. package/dist/tracing.d.ts.map +1 -0
  45. package/dist/tracing.js +302 -0
  46. package/dist/tracing.js.map +1 -0
  47. package/package.json +2 -2
package/dist/mint.js CHANGED
@@ -32,6 +32,14 @@ import { existingReviewRelation, fetchPublicReview, projectEpisodeReview, requir
32
32
  import { readEpisodeReviews, reviewNodeId, reviewReference } from "@miadi/episodic-memory-schema";
33
33
  import { DEFAULT_FOUNDATION_RELATION, episodeFoundationPath, foundationNodeId, readEpisodeFoundations, } from "@miadi/episodic-memory-schema";
34
34
  import { existingFoundationRelation, FoundationError, foundationLookupFromDir, foundationLookupFromFront, projectEpisodeFoundation, readEpisodePacket, requireFoundationSlug, upsertEpisodeFoundation, } from "./foundation.js";
35
+ import { NotebookError, existingNotebookRelation, projectEpisodeNotebook, readNotebookFolder, requireNotebookRef, upsertEpisodeNotebook, } from "./notebook.js";
36
+ import { PrototypeError, existingPrototypeRelation, optionalEditorUrl, assertDistinctPrototypeAddresses, projectEpisodePrototype, prototypeEditorUrl, requirePrototypeSlug, requirePrototypeUrl, upsertEpisodePrototype, } from "./prototype.js";
37
+ import { TracingError, existingTracingRelation, optionalUiUrl, projectEpisodeTracing, requireTracingRef, tracingProviderName, tracingUiUrl, upsertEpisodeTracing, } from "./tracing.js";
38
+ import { RispecError, existingRispecRelation, projectEpisodeRispec, requireRiseRef, upsertEpisodeRispec } from "./rispec.js";
39
+ import { DEFAULT_RISPEC_RELATION, readEpisodeRispecs, riseNodeId, riseReference } from "@miadi/episodic-memory-schema";
40
+ import { defaultTracingRelation, readEpisodeTracing, tracingKey, tracingNodeId, } from "@miadi/episodic-memory-schema";
41
+ import { DEFAULT_PROTOTYPE_RELATION, prototypeNodeId, readEpisodePrototypes, } from "@miadi/episodic-memory-schema";
42
+ import { DEFAULT_NOTEBOOK_RELATION, notebookNodeId, readEpisodeNotebooks, } from "@miadi/episodic-memory-schema";
35
43
  import { closingIsProven, MW_RECEIPT_BASENAME, proveEpisodeClosing, refusePoisonedWheel, WHEEL_PROBE_TIMEOUT_MS, } from "./closing.js";
36
44
  export class EpisodeServiceError extends Error {
37
45
  code;
@@ -794,6 +802,70 @@ function asFoundationDoorError(error) {
794
802
  }
795
803
  throw error;
796
804
  }
805
+ function asNotebookDoorError(error) {
806
+ if (error instanceof NotebookError) {
807
+ const code = error.code === "notebook_manifest_conflict" ? "invalid_field" : error.code;
808
+ throw new EpisodeServiceError(code, error.message);
809
+ }
810
+ throw error;
811
+ }
812
+ function asPrototypeDoorError(error) {
813
+ if (error instanceof PrototypeError) {
814
+ const code = error.code === "prototype_manifest_conflict" ? "invalid_field" : error.code;
815
+ throw new EpisodeServiceError(code, error.message);
816
+ }
817
+ throw error;
818
+ }
819
+ function asTracingDoorError(error) {
820
+ if (error instanceof TracingError) {
821
+ const code = error.code === "tracing_manifest_conflict" ? "invalid_field" : error.code;
822
+ throw new EpisodeServiceError(code, error.message);
823
+ }
824
+ throw error;
825
+ }
826
+ function asRispecDoorError(error) {
827
+ if (error instanceof RispecError) {
828
+ const code = error.code === "rispec_manifest_conflict" ? "invalid_field" : error.code;
829
+ throw new EpisodeServiceError(code, error.message);
830
+ }
831
+ throw error;
832
+ }
833
+ function parseRispecRelation(raw) {
834
+ if (raw === undefined || raw === null || raw === "")
835
+ return undefined;
836
+ const relation = typeof raw === "string" ? raw.trim() : "";
837
+ if (!/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(relation) || relation.length > 80) {
838
+ throw new EpisodeServiceError("invalid_field", `relation '${String(raw)}' must be a kebab-case phrase completing "Episode <relation> Spec", e.g. is-specified-in or revises`);
839
+ }
840
+ return relation;
841
+ }
842
+ function parseTracingRelation(raw) {
843
+ if (raw === undefined || raw === null || raw === "")
844
+ return undefined;
845
+ const relation = typeof raw === "string" ? raw.trim() : "";
846
+ if (!/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(relation) || relation.length > 80) {
847
+ throw new EpisodeServiceError("invalid_field", `relation '${String(raw)}' must be a kebab-case phrase completing "Episode <relation> Resource", e.g. is-traced-in`);
848
+ }
849
+ return relation;
850
+ }
851
+ function parsePrototypeRelation(raw) {
852
+ if (raw === undefined || raw === null || raw === "")
853
+ return undefined;
854
+ const relation = typeof raw === "string" ? raw.trim() : "";
855
+ if (!/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(relation) || relation.length > 80) {
856
+ throw new EpisodeServiceError("invalid_field", `relation '${String(raw)}' must be a kebab-case phrase completing "Episode <relation> Prototype", e.g. is-prototyped-in`);
857
+ }
858
+ return relation;
859
+ }
860
+ function parseNotebookRelation(raw) {
861
+ if (raw === undefined || raw === null || raw === "")
862
+ return undefined;
863
+ const relation = typeof raw === "string" ? raw.trim() : "";
864
+ if (!/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(relation) || relation.length > 80) {
865
+ throw new EpisodeServiceError("invalid_field", `relation '${String(raw)}' must be a kebab-case phrase completing "Episode <relation> Notebook", e.g. cites`);
866
+ }
867
+ return relation;
868
+ }
797
869
  function parseFoundationRelation(raw) {
798
870
  if (raw === undefined || raw === null || raw === "")
799
871
  return undefined;
@@ -904,6 +976,385 @@ export async function listEpisodeFoundations(context, ref) {
904
976
  }
905
977
  return { identity, foundations: readEpisodeFoundations(manifest) };
906
978
  }
979
+ /**
980
+ * Hold a notebook in an episode: the manifest entry (filling only what a
981
+ * hand-written entry lacks), the `notebook:<uuid>` node and the episode→notebook
982
+ * edge on the wheel, and the landing. The folder under `captures/` is read for
983
+ * its files and title when the episode keeps one; nothing is written there.
984
+ */
985
+ export async function attachEpisodeNotebook(context, ref, input) {
986
+ let notebook;
987
+ try {
988
+ notebook = requireNotebookRef(input.notebook);
989
+ }
990
+ catch (error) {
991
+ asNotebookDoorError(error);
992
+ }
993
+ const asked = parseNotebookRelation(input.relation);
994
+ const identity = await resolveDoorEpisode(context, ref);
995
+ if (!hasEpisodeManifest(identity.dir)) {
996
+ throw new EpisodeServiceError("manifest_missing", missingManifestRemedy(identity.dir));
997
+ }
998
+ const folder = await readNotebookFolder(identity.dir, notebook.id);
999
+ const gitRoot = await gitRootOf(context);
1000
+ return withChronicleLock(gitRoot, async () => {
1001
+ const manifestPath = join(identity.dir, "episode.yaml");
1002
+ const nodeEpisode = episodeNodeId(identity.path);
1003
+ const nodeId = notebookNodeId(notebook.id);
1004
+ let previous;
1005
+ try {
1006
+ previous = readEpisodeNotebooks(parse(await readFile(manifestPath, "utf8"))).find((entry) => entry.id === notebook.id);
1007
+ }
1008
+ catch {
1009
+ previous = undefined;
1010
+ }
1011
+ // One ordered pair is one edge on the wheel; a hand-made edge keeps its relation.
1012
+ const onWheel = await existingNotebookRelation(context.wheelUrl, nodeEpisode, nodeId, { fetchImpl: context.fetchImpl });
1013
+ const relation = onWheel ?? previous?.relation ?? asked ?? DEFAULT_NOTEBOOK_RELATION;
1014
+ const relationNote = asked && asked !== relation
1015
+ ? `asked for '${asked}', kept '${relation}': ${onWheel ? "the wheel already holds that edge" : "the episode already recorded that relation"}`
1016
+ : undefined;
1017
+ const sources = input.sources?.map((s) => s.trim()).filter(Boolean);
1018
+ const upserted = await upsertEpisodeNotebook({
1019
+ episodeYamlPath: manifestPath,
1020
+ entry: {
1021
+ id: notebook.id,
1022
+ url: notebook.url,
1023
+ ...(folder.path ? { path: folder.path } : {}),
1024
+ relation,
1025
+ ...(input.made_by?.trim() ? { made_by: input.made_by.trim() } : {}),
1026
+ ...(sources?.length ? { sources } : {}),
1027
+ ...(folder.files.length ? { files: folder.files } : {}),
1028
+ added: nowOf(context).toISOString(),
1029
+ },
1030
+ dryRun: input.dryRun,
1031
+ }).catch(asNotebookDoorError);
1032
+ const wheel = await projectEpisodeNotebook(context.wheelUrl, { episodeNodeId: nodeEpisode, episodeFolder: identity.name, entry: upserted.entry, title: folder.title, relation: upserted.entry.relation ?? relation }, { dryRun: input.dryRun, fetchImpl: context.fetchImpl });
1033
+ let landing;
1034
+ if (input.land !== false) {
1035
+ landing = await landIdentity(context, identity, { dryRun: input.dryRun }, {
1036
+ subject: `ep${identity.number ?? "?"}: notebook ${folder.title ?? notebook.id.slice(0, 8)}`,
1037
+ body: commitBody(`Notebook held through POST /api/chronicle/episodes/{ref}/notebooks.\n\nNotebook: ${notebook.url}`, []),
1038
+ });
1039
+ }
1040
+ const closing = await closingFor(context, identity.name);
1041
+ return {
1042
+ ok: closingIsProven(closing) && wheel.node.state !== "pending" && wheel.edge.state !== "pending",
1043
+ dryRun: input.dryRun === true,
1044
+ identity,
1045
+ entry: upserted.entry,
1046
+ manifest: { created: upserted.created, filled: upserted.filled, wrote: upserted.wrote },
1047
+ wheel,
1048
+ ...(relationNote ? { relationNote } : {}),
1049
+ landing,
1050
+ closing: closing.closing,
1051
+ drift: closing.drift,
1052
+ owedActions: closing.owedActions,
1053
+ };
1054
+ });
1055
+ }
1056
+ /** The notebooks an episode holds, as its manifest records them. */
1057
+ export async function listEpisodeNotebooks(context, ref) {
1058
+ const identity = await resolveDoorEpisode(context, ref);
1059
+ let manifest = null;
1060
+ try {
1061
+ manifest = parse(await readFile(join(identity.dir, "episode.yaml"), "utf8"));
1062
+ }
1063
+ catch {
1064
+ manifest = null;
1065
+ }
1066
+ return { identity, notebooks: readEpisodeNotebooks(manifest) };
1067
+ }
1068
+ /**
1069
+ * Hold a prototype in an episode: the manifest entry (published address and
1070
+ * what the episode knows), the `prototype:<slug>` node with the editing
1071
+ * address in its metadata only, the episode→prototype edge, and the landing.
1072
+ */
1073
+ export async function attachEpisodePrototype(context, ref, input) {
1074
+ let slug;
1075
+ let url;
1076
+ let editor;
1077
+ try {
1078
+ slug = requirePrototypeSlug(input.prototype);
1079
+ url = requirePrototypeUrl(input.url);
1080
+ editor = optionalEditorUrl(input.editor);
1081
+ assertDistinctPrototypeAddresses(url, editor);
1082
+ }
1083
+ catch (error) {
1084
+ asPrototypeDoorError(error);
1085
+ }
1086
+ const asked = parsePrototypeRelation(input.relation);
1087
+ const identity = await resolveDoorEpisode(context, ref);
1088
+ if (!hasEpisodeManifest(identity.dir)) {
1089
+ throw new EpisodeServiceError("manifest_missing", missingManifestRemedy(identity.dir));
1090
+ }
1091
+ const gitRoot = await gitRootOf(context);
1092
+ return withChronicleLock(gitRoot, async () => {
1093
+ const manifestPath = join(identity.dir, "episode.yaml");
1094
+ const nodeEpisode = episodeNodeId(identity.path);
1095
+ const nodeId = prototypeNodeId(slug);
1096
+ let previous;
1097
+ try {
1098
+ previous = readEpisodePrototypes(parse(await readFile(manifestPath, "utf8"))).find((entry) => entry.id === slug);
1099
+ }
1100
+ catch {
1101
+ previous = undefined;
1102
+ }
1103
+ const onWheel = await existingPrototypeRelation(context.wheelUrl, nodeEpisode, nodeId, { fetchImpl: context.fetchImpl });
1104
+ const relation = onWheel ?? previous?.relation ?? asked ?? DEFAULT_PROTOTYPE_RELATION;
1105
+ const relationNote = asked && asked !== relation
1106
+ ? `asked for '${asked}', kept '${relation}': ${onWheel ? "the wheel already holds that edge" : "the episode already recorded that relation"}`
1107
+ : undefined;
1108
+ const text = (value) => (value?.trim() ? value.trim() : undefined);
1109
+ const reads = input.reads?.map((r) => r.trim()).filter(Boolean);
1110
+ const upserted = await upsertEpisodePrototype({
1111
+ episodeYamlPath: manifestPath,
1112
+ entry: {
1113
+ id: slug,
1114
+ ...(text(input.title) ? { title: text(input.title) } : {}),
1115
+ url,
1116
+ ...(text(input.made_with) ? { made_with: text(input.made_with) } : {}),
1117
+ ...(text(input.source) ? { source: text(input.source) } : {}),
1118
+ ...(text(input.commit) ? { commit: text(input.commit) } : {}),
1119
+ ...(text(input.issue) ? { issue: text(input.issue) } : {}),
1120
+ relation,
1121
+ ...(reads?.length ? { reads } : {}),
1122
+ added: nowOf(context).toISOString(),
1123
+ },
1124
+ dryRun: input.dryRun,
1125
+ }).catch(asPrototypeDoorError);
1126
+ const wheel = await projectEpisodePrototype(context.wheelUrl, { episodeNodeId: nodeEpisode, episodeFolder: identity.name, entry: upserted.entry, editor, relation: upserted.entry.relation ?? relation }, { dryRun: input.dryRun, fetchImpl: context.fetchImpl });
1127
+ let landing;
1128
+ if (input.land !== false) {
1129
+ landing = await landIdentity(context, identity, { dryRun: input.dryRun }, {
1130
+ subject: `ep${identity.number ?? "?"}: prototype ${upserted.entry.title ?? slug}`,
1131
+ body: commitBody(`Prototype held through POST /api/chronicle/episodes/{ref}/prototypes.\n\nPrototype: miadi-prototype:${slug} (${url})`, upserted.entry.issue ? [upserted.entry.issue] : []),
1132
+ });
1133
+ }
1134
+ const closing = await closingFor(context, identity.name);
1135
+ return {
1136
+ ok: closingIsProven(closing) && wheel.node.state !== "pending" && wheel.edge.state !== "pending",
1137
+ dryRun: input.dryRun === true,
1138
+ identity,
1139
+ entry: upserted.entry,
1140
+ manifest: { created: upserted.created, filled: upserted.filled, wrote: upserted.wrote },
1141
+ wheel,
1142
+ ...(relationNote ? { relationNote } : {}),
1143
+ landing,
1144
+ closing: closing.closing,
1145
+ drift: closing.drift,
1146
+ owedActions: closing.owedActions,
1147
+ };
1148
+ });
1149
+ }
1150
+ /**
1151
+ * The prototypes an episode holds. With `includeEditor`, each one's editing
1152
+ * address is read from its wheel node; the caller has checked the viewer
1153
+ * before asking, because that address is for people who may edit the episode.
1154
+ */
1155
+ export async function listEpisodePrototypes(context, ref, options = {}) {
1156
+ const identity = await resolveDoorEpisode(context, ref);
1157
+ let manifest = null;
1158
+ try {
1159
+ manifest = parse(await readFile(join(identity.dir, "episode.yaml"), "utf8"));
1160
+ }
1161
+ catch {
1162
+ manifest = null;
1163
+ }
1164
+ const held = readEpisodePrototypes(manifest);
1165
+ if (!options.includeEditor)
1166
+ return { identity, prototypes: held };
1167
+ const prototypes = [];
1168
+ for (const entry of held) {
1169
+ const editor = await prototypeEditorUrl(context.wheelUrl, entry.id, { fetchImpl: context.fetchImpl });
1170
+ prototypes.push(editor ? { ...entry, editor } : entry);
1171
+ }
1172
+ return { identity, prototypes };
1173
+ }
1174
+ /**
1175
+ * Hold a tracing resource in an episode: the manifest entry (domain, resource,
1176
+ * id, name, version, label, session, relation, backstage), the
1177
+ * `tracing:<domain>/<resource>/<id>` node with the provider and, when given,
1178
+ * the provider's page in its metadata, the episode→resource edge, the landing.
1179
+ */
1180
+ export async function attachEpisodeTracing(context, ref, input) {
1181
+ let target;
1182
+ let uiUrl;
1183
+ try {
1184
+ target = requireTracingRef(input);
1185
+ uiUrl = optionalUiUrl(input.ui_url);
1186
+ }
1187
+ catch (error) {
1188
+ asTracingDoorError(error);
1189
+ }
1190
+ const asked = parseTracingRelation(input.relation);
1191
+ const identity = await resolveDoorEpisode(context, ref);
1192
+ if (!hasEpisodeManifest(identity.dir)) {
1193
+ throw new EpisodeServiceError("manifest_missing", missingManifestRemedy(identity.dir));
1194
+ }
1195
+ const gitRoot = await gitRootOf(context);
1196
+ return withChronicleLock(gitRoot, async () => {
1197
+ const manifestPath = join(identity.dir, "episode.yaml");
1198
+ const nodeEpisode = episodeNodeId(identity.path);
1199
+ const nodeId = tracingNodeId(target.domain, target.resource, target.id);
1200
+ const key = tracingKey(target.domain, target.resource, target.id);
1201
+ let previous;
1202
+ try {
1203
+ previous = readEpisodeTracing(parse(await readFile(manifestPath, "utf8"))).find((entry) => tracingKey(entry.domain, entry.resource, entry.id) === key);
1204
+ }
1205
+ catch {
1206
+ previous = undefined;
1207
+ }
1208
+ const onWheel = await existingTracingRelation(context.wheelUrl, nodeEpisode, nodeId, { fetchImpl: context.fetchImpl });
1209
+ const relation = onWheel ?? previous?.relation ?? asked ?? defaultTracingRelation(target.resource);
1210
+ const relationNote = asked && asked !== relation
1211
+ ? `asked for '${asked}', kept '${relation}': ${onWheel ? "the wheel already holds that edge" : "the episode already recorded that relation"}`
1212
+ : undefined;
1213
+ const text = (value) => (value?.trim() ? value.trim() : undefined);
1214
+ const backstage = input.backstage?.map((b) => b.trim()).filter(Boolean);
1215
+ const upserted = await upsertEpisodeTracing({
1216
+ episodeYamlPath: manifestPath,
1217
+ entry: {
1218
+ domain: target.domain,
1219
+ resource: target.resource,
1220
+ id: target.id,
1221
+ ...(text(input.name) && text(input.name) !== target.id ? { name: text(input.name) } : {}),
1222
+ ...(target.version !== undefined ? { version: target.version } : {}),
1223
+ ...(text(input.label) ? { label: text(input.label) } : {}),
1224
+ ...(text(input.session) ? { session: text(input.session) } : {}),
1225
+ relation,
1226
+ ...(backstage?.length ? { backstage } : {}),
1227
+ added: nowOf(context).toISOString(),
1228
+ },
1229
+ dryRun: input.dryRun,
1230
+ }).catch(asTracingDoorError);
1231
+ const wheel = await projectEpisodeTracing(context.wheelUrl, { episodeNodeId: nodeEpisode, episodeFolder: identity.name, entry: upserted.entry, provider: tracingProviderName(), uiUrl, relation: upserted.entry.relation ?? relation }, { dryRun: input.dryRun, fetchImpl: context.fetchImpl });
1232
+ let landing;
1233
+ if (input.land !== false) {
1234
+ landing = await landIdentity(context, identity, { dryRun: input.dryRun }, {
1235
+ subject: `ep${identity.number ?? "?"}: ${target.resource} ${upserted.entry.name ?? target.id} (${target.domain})`,
1236
+ body: commitBody(`Tracing resource held through POST /api/chronicle/episodes/{ref}/tracing.\n\nResource: miadi-tracing:${key}`, []),
1237
+ });
1238
+ }
1239
+ const closing = await closingFor(context, identity.name);
1240
+ return {
1241
+ ok: closingIsProven(closing) && wheel.node.state !== "pending" && wheel.edge.state !== "pending",
1242
+ dryRun: input.dryRun === true,
1243
+ identity,
1244
+ entry: upserted.entry,
1245
+ manifest: { created: upserted.created, filled: upserted.filled, wrote: upserted.wrote },
1246
+ wheel,
1247
+ ...(relationNote ? { relationNote } : {}),
1248
+ landing,
1249
+ closing: closing.closing,
1250
+ drift: closing.drift,
1251
+ owedActions: closing.owedActions,
1252
+ };
1253
+ });
1254
+ }
1255
+ /** The tracing resources an episode holds. With `includeLinks`, each one's provider page is read from its node. */
1256
+ export async function listEpisodeTracing(context, ref, options = {}) {
1257
+ const identity = await resolveDoorEpisode(context, ref);
1258
+ let manifest = null;
1259
+ try {
1260
+ manifest = parse(await readFile(join(identity.dir, "episode.yaml"), "utf8"));
1261
+ }
1262
+ catch {
1263
+ manifest = null;
1264
+ }
1265
+ const held = readEpisodeTracing(manifest);
1266
+ const provider = tracingProviderName();
1267
+ if (!options.includeLinks)
1268
+ return { identity, tracing: held, provider };
1269
+ const tracing = [];
1270
+ for (const entry of held) {
1271
+ const ui = await tracingUiUrl(context.wheelUrl, entry, { fetchImpl: context.fetchImpl });
1272
+ tracing.push(ui ? { ...entry, ui_url: ui } : entry);
1273
+ }
1274
+ return { identity, tracing, provider };
1275
+ }
1276
+ /** Hold a RISE specification in an episode: the manifest entry, the `rise:` node, the edge, the landing. */
1277
+ export async function attachEpisodeRispec(context, ref, input) {
1278
+ let spec;
1279
+ try {
1280
+ spec = requireRiseRef(input.spec);
1281
+ }
1282
+ catch (error) {
1283
+ asRispecDoorError(error);
1284
+ }
1285
+ const asked = parseRispecRelation(input.relation);
1286
+ const identity = await resolveDoorEpisode(context, ref);
1287
+ if (!hasEpisodeManifest(identity.dir)) {
1288
+ throw new EpisodeServiceError("manifest_missing", missingManifestRemedy(identity.dir));
1289
+ }
1290
+ const gitRoot = await gitRootOf(context);
1291
+ return withChronicleLock(gitRoot, async () => {
1292
+ const manifestPath = join(identity.dir, "episode.yaml");
1293
+ const nodeEpisode = episodeNodeId(identity.path);
1294
+ const nodeId = riseNodeId(spec);
1295
+ const address = riseReference(spec.owner, spec.repo, spec.path);
1296
+ let previous;
1297
+ try {
1298
+ previous = readEpisodeRispecs(parse(await readFile(manifestPath, "utf8"))).find((entry) => entry.ref === address);
1299
+ }
1300
+ catch {
1301
+ previous = undefined;
1302
+ }
1303
+ const onWheel = await existingRispecRelation(context.wheelUrl, nodeEpisode, nodeId, { fetchImpl: context.fetchImpl });
1304
+ const relation = onWheel ?? previous?.relation ?? asked ?? DEFAULT_RISPEC_RELATION;
1305
+ const relationNote = asked && asked !== relation
1306
+ ? `asked for '${asked}', kept '${relation}': ${onWheel ? "the wheel already holds that edge" : "the episode already recorded that relation"}`
1307
+ : undefined;
1308
+ const text = (value) => (value?.trim() ? value.trim() : undefined);
1309
+ const upserted = await upsertEpisodeRispec({
1310
+ episodeYamlPath: manifestPath,
1311
+ entry: {
1312
+ ref: address,
1313
+ ...(text(input.title) ? { title: text(input.title) } : {}),
1314
+ ...(text(input.commit) ?? spec.commit ? { commit: text(input.commit) ?? spec.commit } : {}),
1315
+ relation,
1316
+ ...(text(input.derived_from) ? { derived_from: text(input.derived_from) } : {}),
1317
+ ...(text(input.system) ? { system: text(input.system) } : {}),
1318
+ added: nowOf(context).toISOString(),
1319
+ },
1320
+ dryRun: input.dryRun,
1321
+ }).catch(asRispecDoorError);
1322
+ const wheel = await projectEpisodeRispec(context.wheelUrl, { episodeNodeId: nodeEpisode, episodeFolder: identity.name, ref: spec, entry: upserted.entry, relation: upserted.entry.relation ?? relation }, { dryRun: input.dryRun, fetchImpl: context.fetchImpl });
1323
+ let landing;
1324
+ if (input.land !== false) {
1325
+ landing = await landIdentity(context, identity, { dryRun: input.dryRun }, {
1326
+ subject: `ep${identity.number ?? "?"}: spec ${upserted.entry.title ?? spec.path}`,
1327
+ body: commitBody(`Specification held through POST /api/chronicle/episodes/{ref}/rispecs.\n\nSpec: ${address}${upserted.entry.commit ? `@${upserted.entry.commit}` : ""}`, []),
1328
+ });
1329
+ }
1330
+ const closing = await closingFor(context, identity.name);
1331
+ return {
1332
+ ok: closingIsProven(closing) && wheel.node.state !== "pending" && wheel.edge.state !== "pending",
1333
+ dryRun: input.dryRun === true,
1334
+ identity,
1335
+ entry: upserted.entry,
1336
+ manifest: { created: upserted.created, filled: upserted.filled, wrote: upserted.wrote },
1337
+ wheel,
1338
+ ...(relationNote ? { relationNote } : {}),
1339
+ landing,
1340
+ closing: closing.closing,
1341
+ drift: closing.drift,
1342
+ owedActions: closing.owedActions,
1343
+ };
1344
+ });
1345
+ }
1346
+ /** The specs an episode holds, as its manifest records them. */
1347
+ export async function listEpisodeRispecs(context, ref) {
1348
+ const identity = await resolveDoorEpisode(context, ref);
1349
+ let manifest = null;
1350
+ try {
1351
+ manifest = parse(await readFile(join(identity.dir, "episode.yaml"), "utf8"));
1352
+ }
1353
+ catch {
1354
+ manifest = null;
1355
+ }
1356
+ return { identity, rispecs: readEpisodeRispecs(manifest) };
1357
+ }
907
1358
  /** The screenwalks an episode holds (`captures/screenwalk-<id>/`), for the door's GET. */
908
1359
  export async function listEpisodeScreenwalks(context, ref) {
909
1360
  const identity = await resolveDoorEpisode(context, ref);