@superbee/core 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.
Files changed (145) hide show
  1. package/dist/backend.d.ts +77 -0
  2. package/dist/backend.d.ts.map +1 -0
  3. package/dist/backend.js +380 -0
  4. package/dist/backend.js.map +1 -0
  5. package/dist/bundle.d.ts +38 -0
  6. package/dist/bundle.d.ts.map +1 -0
  7. package/dist/bundle.js +107 -0
  8. package/dist/bundle.js.map +1 -0
  9. package/dist/content-type.d.ts +72 -0
  10. package/dist/content-type.d.ts.map +1 -0
  11. package/dist/content-type.js +149 -0
  12. package/dist/content-type.js.map +1 -0
  13. package/dist/document-mutation.d.ts +83 -0
  14. package/dist/document-mutation.d.ts.map +1 -0
  15. package/dist/document-mutation.js +319 -0
  16. package/dist/document-mutation.js.map +1 -0
  17. package/dist/document-precondition.d.ts +62 -0
  18. package/dist/document-precondition.d.ts.map +1 -0
  19. package/dist/document-precondition.js +134 -0
  20. package/dist/document-precondition.js.map +1 -0
  21. package/dist/document-write-policy.d.ts +42 -0
  22. package/dist/document-write-policy.d.ts.map +1 -0
  23. package/dist/document-write-policy.js +134 -0
  24. package/dist/document-write-policy.js.map +1 -0
  25. package/dist/engine.d.ts +48 -0
  26. package/dist/engine.d.ts.map +1 -0
  27. package/dist/engine.js +209 -0
  28. package/dist/engine.js.map +1 -0
  29. package/dist/errors.d.ts +36 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/errors.js +50 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/filesystem-identity.d.ts +208 -0
  34. package/dist/filesystem-identity.d.ts.map +1 -0
  35. package/dist/filesystem-identity.js +675 -0
  36. package/dist/filesystem-identity.js.map +1 -0
  37. package/dist/filesystem-lock.d.ts +65 -0
  38. package/dist/filesystem-lock.d.ts.map +1 -0
  39. package/dist/filesystem-lock.js +460 -0
  40. package/dist/filesystem-lock.js.map +1 -0
  41. package/dist/freshness.d.ts +31 -0
  42. package/dist/freshness.d.ts.map +1 -0
  43. package/dist/freshness.js +98 -0
  44. package/dist/freshness.js.map +1 -0
  45. package/dist/frontmatter-contract.d.ts +17 -0
  46. package/dist/frontmatter-contract.d.ts.map +1 -0
  47. package/dist/frontmatter-contract.js +29 -0
  48. package/dist/frontmatter-contract.js.map +1 -0
  49. package/dist/frontmatter.d.ts +30 -0
  50. package/dist/frontmatter.d.ts.map +1 -0
  51. package/dist/frontmatter.js +115 -0
  52. package/dist/frontmatter.js.map +1 -0
  53. package/dist/index-marker.d.ts +3 -0
  54. package/dist/index-marker.d.ts.map +1 -0
  55. package/dist/index-marker.js +3 -0
  56. package/dist/index-marker.js.map +1 -0
  57. package/dist/index-projection.d.ts +79 -0
  58. package/dist/index-projection.d.ts.map +1 -0
  59. package/dist/index-projection.js +245 -0
  60. package/dist/index-projection.js.map +1 -0
  61. package/dist/index.d.ts +68 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +110 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/kinds-load.d.ts +13 -0
  66. package/dist/kinds-load.d.ts.map +1 -0
  67. package/dist/kinds-load.js +34 -0
  68. package/dist/kinds-load.js.map +1 -0
  69. package/dist/kinds.d.ts +241 -0
  70. package/dist/kinds.d.ts.map +1 -0
  71. package/dist/kinds.js +1001 -0
  72. package/dist/kinds.js.map +1 -0
  73. package/dist/links.d.ts +62 -0
  74. package/dist/links.d.ts.map +1 -0
  75. package/dist/links.js +203 -0
  76. package/dist/links.js.map +1 -0
  77. package/dist/meaningful-change-time.d.ts +11 -0
  78. package/dist/meaningful-change-time.d.ts.map +1 -0
  79. package/dist/meaningful-change-time.js +16 -0
  80. package/dist/meaningful-change-time.js.map +1 -0
  81. package/dist/memory-backend.d.ts +55 -0
  82. package/dist/memory-backend.d.ts.map +1 -0
  83. package/dist/memory-backend.js +214 -0
  84. package/dist/memory-backend.js.map +1 -0
  85. package/dist/mutation-attribution.d.ts +19 -0
  86. package/dist/mutation-attribution.d.ts.map +1 -0
  87. package/dist/mutation-attribution.js +34 -0
  88. package/dist/mutation-attribution.js.map +1 -0
  89. package/dist/mutation.d.ts +89 -0
  90. package/dist/mutation.d.ts.map +1 -0
  91. package/dist/mutation.js +94 -0
  92. package/dist/mutation.js.map +1 -0
  93. package/dist/page.d.ts +87 -0
  94. package/dist/page.d.ts.map +1 -0
  95. package/dist/page.js +151 -0
  96. package/dist/page.js.map +1 -0
  97. package/dist/paths.d.ts +78 -0
  98. package/dist/paths.d.ts.map +1 -0
  99. package/dist/paths.js +148 -0
  100. package/dist/paths.js.map +1 -0
  101. package/dist/portable-frontmatter.d.ts +8 -0
  102. package/dist/portable-frontmatter.d.ts.map +1 -0
  103. package/dist/portable-frontmatter.js +35 -0
  104. package/dist/portable-frontmatter.js.map +1 -0
  105. package/dist/publication-filesystem.d.ts +6 -0
  106. package/dist/publication-filesystem.d.ts.map +1 -0
  107. package/dist/publication-filesystem.js +6 -0
  108. package/dist/publication-filesystem.js.map +1 -0
  109. package/dist/query-filter.d.ts +12 -0
  110. package/dist/query-filter.d.ts.map +1 -0
  111. package/dist/query-filter.js +29 -0
  112. package/dist/query-filter.js.map +1 -0
  113. package/dist/query-selection.d.ts +25 -0
  114. package/dist/query-selection.d.ts.map +1 -0
  115. package/dist/query-selection.js +48 -0
  116. package/dist/query-selection.js.map +1 -0
  117. package/dist/remote-backend.d.ts +152 -0
  118. package/dist/remote-backend.d.ts.map +1 -0
  119. package/dist/remote-backend.js +505 -0
  120. package/dist/remote-backend.js.map +1 -0
  121. package/dist/remote.d.ts +4 -0
  122. package/dist/remote.d.ts.map +1 -0
  123. package/dist/remote.js +3 -0
  124. package/dist/remote.js.map +1 -0
  125. package/dist/storage.d.ts +7 -0
  126. package/dist/storage.d.ts.map +1 -0
  127. package/dist/storage.js +6 -0
  128. package/dist/storage.js.map +1 -0
  129. package/dist/types.d.ts +438 -0
  130. package/dist/types.d.ts.map +1 -0
  131. package/dist/types.js +12 -0
  132. package/dist/types.js.map +1 -0
  133. package/dist/validation.d.ts +22 -0
  134. package/dist/validation.d.ts.map +1 -0
  135. package/dist/validation.js +12 -0
  136. package/dist/validation.js.map +1 -0
  137. package/dist/version-transport.d.ts +13 -0
  138. package/dist/version-transport.d.ts.map +1 -0
  139. package/dist/version-transport.js +26 -0
  140. package/dist/version-transport.js.map +1 -0
  141. package/dist/versioning.d.ts +48 -0
  142. package/dist/versioning.d.ts.map +1 -0
  143. package/dist/versioning.js +62 -0
  144. package/dist/versioning.js.map +1 -0
  145. package/package.json +84 -0
package/dist/kinds.js ADDED
@@ -0,0 +1,1001 @@
1
+ /**
2
+ * Kind conventions — the bundle-declared, opt-in document-kind mechanism. The mechanism lives in
3
+ * core while each bundle chooses whether to declare and use conventions.
4
+ *
5
+ * A bundle MAY declare document kinds as plain OKF convention docs: a normal concept
6
+ * document with `frontmatter.type: "Convention"`, living under the `conventions/`
7
+ * prefix (the ONE documented discovery contract — see {@link CONVENTIONS_PREFIX}), that
8
+ * names the `type` value it governs plus its required/optional fields, allowed enum
9
+ * values, expected body sections, and an optional freshness horizon. A convention doc
10
+ * is NOT a schema fork: it is a plain OKF doc with a well-known `type`, read by this
11
+ * ONE registry (consumed by the CLI's `kinds`/`new`/`doc write`, and additively by any
12
+ * future consumer — server/MCP).
13
+ *
14
+ * Registry discovery is PREFIX-SCOPED and built ONCE per invocation, in the COMMAND
15
+ * layer — no engine path (`readDoc`, `writeDoc`, …) loads it implicitly, so
16
+ * a conventions-free bundle (every external OKF bundle today) pays only a cheap
17
+ * list-of-nothing and behaves byte-for-byte as before.
18
+ *
19
+ * Pure derivation logic (`validateAgainstKind`, `freshnessHorizonMs`, `isTerminal`, the
20
+ * convention-doc (de)serialization) is dependency-free — this module carries ONLY that, and is
21
+ * exported as the browser-safe `@superbee/core/kinds` subpath (the ui shell's bridge
22
+ * `open` filter consumes `isTerminal` there; a browser bundler must never be dragged through the
23
+ * engine's node built-ins for a pure predicate). The one backend-touching entry point,
24
+ * `loadKinds`, lives in `kinds-load.ts` — same ONE registry, split only by dependency weight.
25
+ */
26
+ import { isUsableTimestamp } from "./frontmatter.js";
27
+ /** The bundle-relative prefix a kind convention doc MUST live under to be discovered. */
28
+ export const CONVENTIONS_PREFIX = "conventions/";
29
+ /** The OKF `type` value a kind convention doc itself carries. */
30
+ export const CONVENTION_TYPE = "Convention";
31
+ /**
32
+ * Build the one authoritative Kind registry from already-selected convention documents.
33
+ * Callers own discovery; this pure half owns parsing, warnings, and first-by-id duplicate
34
+ * resolution so prospective registries cannot drift from {@link loadKinds} semantics.
35
+ */
36
+ export function buildKindRegistry(docs, initialWarnings = []) {
37
+ const kinds = new Map();
38
+ const warnings = [...initialWarnings];
39
+ for (const doc of [...docs].sort((a, b) => a.id.localeCompare(b.id))) {
40
+ const parsed = parseConventionDoc(doc);
41
+ if (!parsed.ok) {
42
+ warnings.push({
43
+ code: "KIND_CONVENTION_MALFORMED",
44
+ message: `skipped malformed kind convention '${doc.id}': ${parsed.reason}`,
45
+ field: doc.id,
46
+ severity: "warning",
47
+ });
48
+ continue;
49
+ }
50
+ const { kind, reservedFieldsIgnored, reservedFieldPaths } = parsed;
51
+ warnings.push(...parsed.warnings);
52
+ if (reservedFieldsIgnored.length > 0) {
53
+ warnings.push({
54
+ code: "KIND_RESERVED_FIELD",
55
+ message: `kind convention '${doc.id}' declares reserved field name(s) ${reservedFieldsIgnored.join(", ")} (reserved by the CLI: ${RESERVED_KIND_FIELD_NAMES.join("/")}); ignoring them — rename those domain fields before authoring instances.`,
56
+ field: reservedFieldPaths.join(","),
57
+ severity: "warning",
58
+ });
59
+ }
60
+ if (kinds.has(kind.governs)) {
61
+ warnings.push({
62
+ code: "KIND_DUPLICATE_GOVERNS",
63
+ message: `duplicate kind convention for '${kind.governs}': '${doc.id}' ignored, keeping the first-declared '${kinds.get(kind.governs).id}'.`,
64
+ field: kind.governs,
65
+ severity: "warning",
66
+ });
67
+ continue;
68
+ }
69
+ if (kind.freshnessHorizon !== undefined && freshnessHorizonMs(kind) === undefined) {
70
+ warnings.push({
71
+ code: "KIND_HORIZON_MALFORMED",
72
+ message: `kind convention '${doc.id}' has a malformed freshness_horizon '${kind.freshnessHorizon}' (expected <n>(m|h|d)); ignoring it.`,
73
+ field: "freshness_horizon",
74
+ severity: "warning",
75
+ });
76
+ }
77
+ kinds.set(kind.governs, kind);
78
+ }
79
+ return { kinds, warnings };
80
+ }
81
+ /** Stable product-level name for a Kind's workflow progress field. */
82
+ export const PROGRESS_STATUS_FIELD = "progress_status";
83
+ /** Producer-qualified v0.2 storage coordinate for Superbee workflow progress. */
84
+ export const SUPERBEE_PROGRESS_STATUS_FIELD = "superbee_progress_status";
85
+ /** Physical coordinate used for Superbee's logical workflow-progress field in one OKF edition. */
86
+ export function progressStatusStorageField(okfVersion) {
87
+ const version = okfVersion?.trim() || "0.1";
88
+ if (version === "0.1")
89
+ return "status";
90
+ if (version === "0.2")
91
+ return SUPERBEE_PROGRESS_STATUS_FIELD;
92
+ return undefined;
93
+ }
94
+ function declaresField(kind, field) {
95
+ return kind.fields.required.includes(field) || kind.fields.optional.includes(field);
96
+ }
97
+ /**
98
+ * Compile the declaration-driven workflow-progress coordinate for one Kind. Missing root version
99
+ * means v0.1 (OKF's compatibility default). Values never activate this mapping.
100
+ */
101
+ export function progressStatusCoordinate(okfVersion, kind) {
102
+ // A Kind may already own a concrete field with the product-level name. That field remains a
103
+ // normal declared coordinate; never shadow or overwrite it with a compatibility alias.
104
+ if (declaresField(kind, PROGRESS_STATUS_FIELD))
105
+ return undefined;
106
+ const storageField = progressStatusStorageField(okfVersion);
107
+ if (storageField && declaresField(kind, storageField)) {
108
+ return {
109
+ logicalField: PROGRESS_STATUS_FIELD,
110
+ storageField,
111
+ };
112
+ }
113
+ return undefined;
114
+ }
115
+ /** Resolve an agent-facing Kind field to its declared storage coordinate. */
116
+ export function resolveKindFieldCoordinate(okfVersion, kind, field) {
117
+ if (declaresField(kind, field))
118
+ return { logicalField: field, storageField: field };
119
+ const progress = progressStatusCoordinate(okfVersion, kind);
120
+ if (field === PROGRESS_STATUS_FIELD && progress)
121
+ return progress;
122
+ return undefined;
123
+ }
124
+ /**
125
+ * Resolve a Kind's declared claim vocabulary onto this bundle edition's storage coordinates
126
+ * through the ONE field resolver ({@link resolveKindFieldCoordinate}), so a logical declaration
127
+ * (`state_field: progress_status`) lands on the edition's real key. A declared name the Kind does
128
+ * not actually carry resolves to NOTHING and is dropped: a consumer then compares no coordinate
129
+ * at all rather than a key that can never match. Undefined when the Kind declares no `claim:`
130
+ * block, or when nothing in it resolves.
131
+ */
132
+ export function claimCoordinates(okfVersion, kind) {
133
+ if (!kind.claim)
134
+ return undefined;
135
+ const resolve = (field) => field === undefined ? undefined : resolveKindFieldCoordinate(okfVersion, kind, field)?.storageField;
136
+ const ownerField = resolve(kind.claim.ownerField);
137
+ const stateField = resolve(kind.claim.stateField);
138
+ const fields = [...new Set([ownerField, stateField].filter((f) => f !== undefined))];
139
+ if (fields.length === 0)
140
+ return undefined;
141
+ return {
142
+ ...(ownerField !== undefined ? { ownerField } : {}),
143
+ ...(stateField !== undefined ? { stateField } : {}),
144
+ fields,
145
+ };
146
+ }
147
+ /** Declared authoring names plus the logical progress alias, when this Kind proves one exists. */
148
+ export function kindInputFieldNames(okfVersion, kind) {
149
+ const fields = [...new Set([...kind.fields.required, ...kind.fields.optional])];
150
+ if (progressStatusCoordinate(okfVersion, kind) && !fields.includes(PROGRESS_STATUS_FIELD)) {
151
+ fields.push(PROGRESS_STATUS_FIELD);
152
+ }
153
+ return fields;
154
+ }
155
+ /** Read a declared logical or physical Kind field without changing the stored frontmatter. */
156
+ export function readKindField(okfVersion, kind, frontmatter, field) {
157
+ const coordinate = resolveKindFieldCoordinate(okfVersion, kind, field);
158
+ return coordinate
159
+ ? frontmatter[coordinate.storageField]
160
+ : undefined;
161
+ }
162
+ /**
163
+ * Add logical field aliases to a product-facing projection while preserving every raw key. Generic
164
+ * document reads remain raw; only Kind-aware product surfaces call this helper.
165
+ */
166
+ export function projectLogicalKindFields(okfVersion, kind, frontmatter) {
167
+ const progress = progressStatusCoordinate(okfVersion, kind);
168
+ if (!progress ||
169
+ !hasOwn(frontmatter, progress.storageField) ||
170
+ hasOwn(frontmatter, progress.logicalField))
171
+ return frontmatter;
172
+ const projected = { ...frontmatter };
173
+ setOwn(projected, progress.logicalField, frontmatter[progress.storageField]);
174
+ return projected;
175
+ }
176
+ /**
177
+ * Project a parsed Kind convention into Superbee's authoring vocabulary. Stored convention bytes
178
+ * remain untouched; product-facing schema/help surfaces use this view so an edition-specific
179
+ * workflow coordinate never becomes something an author has to learn.
180
+ */
181
+ export function projectKindForAuthoring(okfVersion, kind) {
182
+ const progress = progressStatusCoordinate(okfVersion, kind);
183
+ if (!progress)
184
+ return kind;
185
+ const projectField = (field) => field === progress.storageField ? progress.logicalField : field;
186
+ const projectRecord = (record) => {
187
+ const projected = {};
188
+ for (const [field, value] of Object.entries(record)) {
189
+ setOwn(projected, projectField(field), value);
190
+ }
191
+ return projected;
192
+ };
193
+ return {
194
+ ...kind,
195
+ fields: {
196
+ required: kind.fields.required.map(projectField),
197
+ optional: kind.fields.optional.map(projectField),
198
+ values: projectRecord(kind.fields.values),
199
+ valueDescriptions: kind.fields.valueDescriptions
200
+ ? projectRecord(kind.fields.valueDescriptions)
201
+ : undefined,
202
+ terminal: projectRecord(kind.fields.terminal),
203
+ descriptions: projectRecord(kind.fields.descriptions),
204
+ },
205
+ };
206
+ }
207
+ /** Project Kind-validation findings into the same authoring vocabulary as the Kind schema. */
208
+ export function projectKindValidationWarnings(okfVersion, kind, warnings) {
209
+ const progress = progressStatusCoordinate(okfVersion, kind);
210
+ if (!progress)
211
+ return warnings;
212
+ const stored = `'${progress.storageField}'`;
213
+ const logical = `'${progress.logicalField}'`;
214
+ return warnings.map((warning) => warning.field === progress.storageField
215
+ ? {
216
+ ...warning,
217
+ field: progress.logicalField,
218
+ // Core's Kind warnings name the field first. Replace that one coordinate only; a later
219
+ // occurrence may be the user's literal value and must remain byte-truthful.
220
+ message: warning.message.replace(stored, logical),
221
+ }
222
+ : warning);
223
+ }
224
+ /** True for a plain YAML/JSON map (excludes arrays, `null`, dates, and other object instances). */
225
+ function isPlainObject(value) {
226
+ if (typeof value !== "object" || value === null || Array.isArray(value))
227
+ return false;
228
+ const prototype = Object.getPrototypeOf(value);
229
+ return prototype === Object.prototype || prototype === null;
230
+ }
231
+ /** Prototype-safe own-key check for user-authored YAML maps. */
232
+ function hasOwn(record, key) {
233
+ return Object.prototype.hasOwnProperty.call(record, key);
234
+ }
235
+ /** Define an arbitrary user-authored key without invoking the legacy `__proto__` setter. */
236
+ function setOwn(record, key, value) {
237
+ Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
238
+ }
239
+ /** True for a scalar YAML value (string/number/boolean) — the shape an enum/field-name member should be. */
240
+ function isScalar(value) {
241
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
242
+ }
243
+ /** Human-readable shape name for a warning message (`"an object"`, `"an array"`, `"null"`, …). */
244
+ function describeShape(value) {
245
+ if (Array.isArray(value))
246
+ return "an array";
247
+ if (value === null)
248
+ return "null";
249
+ if (typeof value === "object")
250
+ return "an object";
251
+ return typeof value;
252
+ }
253
+ /**
254
+ * String-coerce every scalar element of an array-ish value (tolerating YAML 1.1 boolean/Date
255
+ * coercion on unquoted enum members), WARNING and DROPPING any non-scalar member (an object or
256
+ * nested array) instead of silently stringifying it to `"[object Object]"` — the exact silent
257
+ * corruption caused by agents feeding a list of objects into `required`/`optional` or an enum's
258
+ * `values` list. A present-but-non-array `value` warns (wrong shape); an
259
+ * ABSENT (`undefined`) `value` does not, since "not declared" is normal, not a shape error.
260
+ */
261
+ function toStringArrayLenient(value, path, docId, warnings) {
262
+ if (!Array.isArray(value)) {
263
+ if (value !== undefined) {
264
+ warnings.push({
265
+ code: "KIND_CONVENTION_BAD_SHAPE",
266
+ message: `kind convention '${docId}' has a non-list '${path}' (${describeShape(value)}; expected a list of strings); ignoring it.`,
267
+ field: path,
268
+ severity: "warning",
269
+ });
270
+ }
271
+ return [];
272
+ }
273
+ const out = [];
274
+ for (const v of value) {
275
+ if (isScalar(v)) {
276
+ out.push(String(v));
277
+ }
278
+ else {
279
+ warnings.push({
280
+ code: "KIND_CONVENTION_BAD_MEMBER",
281
+ message: `kind convention '${docId}' has a non-scalar member (${describeShape(v)}) in '${path}'; skipping it.`,
282
+ field: path,
283
+ severity: "warning",
284
+ });
285
+ }
286
+ }
287
+ return out;
288
+ }
289
+ /**
290
+ * Field names the CLI reserves for its own machinery and can never treat as a kind-declared
291
+ * field: `type` is stamped from `kind.governs` (a kind declaring it as a field would let
292
+ * `new --type <v>` silently overwrite the governed type it just validated against), and
293
+ * `dir`/`remote`/`json`/`help` are consumed by every command's common flag handling before a
294
+ * `--<field> <value>` pair ever reaches kind-field mapping. `body` and `body-file` are `new`'s
295
+ * complete-body inline and file channels. Declaring any of them would make the field permanently
296
+ * unreachable, not merely confusing. Filtered out of `required`/`optional`/`values` at parse time
297
+ * with a collected warning — never silently accepted.
298
+ */
299
+ export const RESERVED_KIND_FIELD_NAMES = ["type", "dir", "remote", "json", "help", "body", "body-file"];
300
+ const RESERVED_FIELD_NAMES = new Set(RESERVED_KIND_FIELD_NAMES);
301
+ /** The only recognized keys inside a convention doc's `fields:` block. */
302
+ const VALID_FIELDS_KEYS = new Set([
303
+ "required",
304
+ "optional",
305
+ "values",
306
+ "value_descriptions",
307
+ "terminal",
308
+ "descriptions",
309
+ ]);
310
+ /**
311
+ * Top-level convention-doc keys that are near-misses for the ONE correct enum-constraint shape
312
+ * (`fields.values.<field>: [...]`) — the exact wrong shapes agents commonly reach for
313
+ * reaching for (`enum:`, `enums:`, top-level `values:`, `constraints:`). This is a SMALL, DENY-
314
+ * ADJACENT set, not a generic top-level-key linter: OKF v0.1 §9 / v0.2 §11 permits unknown
315
+ * frontmatter, and a
316
+ * bundle producer may legitimately add other top-level keys to a convention doc (title, tags,
317
+ * whatever) — those get NO warning. Strict inside the blocks we own (`fields:`), permissive
318
+ * everywhere else.
319
+ */
320
+ const MISPLACED_TOP_LEVEL_KEYS = new Set(["enum", "enums", "values", "constraints"]);
321
+ /** The `claim:` keys this parse consumes, paired with their {@link KindClaimPolicy} property. */
322
+ const CLAIM_COORDINATE_KEYS = [
323
+ ["owner_field", "ownerField"],
324
+ ["state_field", "stateField"],
325
+ ];
326
+ // Level-1 headings only: `# Foo` (not `## Foo`). The ONE heading splitter, reused by
327
+ // validateAgainstKind's section lint below and re-exported as public API from index.ts.
328
+ const H1_RE = /^#\s+(.+?)\s*$/gm;
329
+ /** Split a body into `{ headingText: sectionContent }` by its level-1 headings. */
330
+ export function splitSections(body) {
331
+ const out = {};
332
+ const matches = [...body.matchAll(H1_RE)];
333
+ for (let i = 0; i < matches.length; i++) {
334
+ const current = matches[i];
335
+ const name = (current[1] ?? "").trim();
336
+ const start = (current.index ?? 0) + current[0].length;
337
+ const end = i + 1 < matches.length ? (matches[i + 1].index ?? body.length) : body.length;
338
+ setOwn(out, name, body.slice(start, end).trim());
339
+ }
340
+ return out;
341
+ }
342
+ /** Parse one convention doc into a {@link KindConvention}, or a reason it was skipped. */
343
+ export function parseConventionDoc(doc) {
344
+ const fm = doc.frontmatter;
345
+ const governs = typeof fm.governs === "string" ? fm.governs.trim() : "";
346
+ if (governs === "") {
347
+ return { ok: false, reason: "missing or empty 'governs' field", warnings: [] };
348
+ }
349
+ const warnings = [];
350
+ // Top-level near-miss constraint keys (see MISPLACED_TOP_LEVEL_KEYS doc comment): warn ONLY on
351
+ // this small deny-adjacent set — never on arbitrary other top-level keys.
352
+ for (const key of MISPLACED_TOP_LEVEL_KEYS) {
353
+ if (key in fm) {
354
+ warnings.push({
355
+ code: "KIND_CONVENTION_MISPLACED_KEY",
356
+ message: `kind convention '${doc.id}' declares a top-level '${key}' key, which core does not read; enum constraints go under 'fields.values.<field>: [...]', not '${key}'.`,
357
+ field: key,
358
+ severity: "warning",
359
+ });
360
+ }
361
+ }
362
+ const fieldsSource = fm.fields;
363
+ let fieldsRaw = {};
364
+ if (fieldsSource === undefined) {
365
+ // absent 'fields:' — nothing to declare, nothing to warn about.
366
+ }
367
+ else if (!isPlainObject(fieldsSource)) {
368
+ warnings.push({
369
+ code: "KIND_CONVENTION_BAD_SHAPE",
370
+ message: `kind convention '${doc.id}' has a non-map 'fields' key (${describeShape(fieldsSource)}; expected a map with required/optional/values/value_descriptions/descriptions); ignoring it.`,
371
+ field: "fields",
372
+ severity: "warning",
373
+ });
374
+ }
375
+ else {
376
+ fieldsRaw = fieldsSource;
377
+ for (const key of Object.keys(fieldsRaw)) {
378
+ if (!VALID_FIELDS_KEYS.has(key)) {
379
+ warnings.push({
380
+ code: "KIND_CONVENTION_UNKNOWN_FIELDS_KEY",
381
+ message: `kind convention '${doc.id}' declares an unrecognized key 'fields.${key}' (valid keys: fields.required, fields.optional, fields.values, fields.value_descriptions, fields.terminal, fields.descriptions); ignoring it.`,
382
+ field: `fields.${key}`,
383
+ severity: "warning",
384
+ });
385
+ }
386
+ }
387
+ }
388
+ const reservedFieldsIgnored = new Set();
389
+ const reservedFieldPaths = new Set();
390
+ const dropReserved = (name, semanticPath) => {
391
+ if (!RESERVED_FIELD_NAMES.has(name))
392
+ return false;
393
+ reservedFieldsIgnored.add(name);
394
+ reservedFieldPaths.add(semanticPath);
395
+ return true;
396
+ };
397
+ const required = toStringArrayLenient(fieldsRaw.required, "fields.required", doc.id, warnings).filter((f) => !dropReserved(f, `fields.required.${f}`));
398
+ const optional = toStringArrayLenient(fieldsRaw.optional, "fields.optional", doc.id, warnings).filter((f) => !dropReserved(f, `fields.optional.${f}`));
399
+ const valuesSource = fieldsRaw.values;
400
+ const values = {};
401
+ if (valuesSource !== undefined) {
402
+ if (!isPlainObject(valuesSource)) {
403
+ warnings.push({
404
+ code: "KIND_CONVENTION_BAD_SHAPE",
405
+ message: `kind convention '${doc.id}' has a non-map 'fields.values' (${describeShape(valuesSource)}; expected a map of field name -> list of allowed values); ignoring it.`,
406
+ field: "fields.values",
407
+ severity: "warning",
408
+ });
409
+ }
410
+ else {
411
+ for (const [field, allowed] of Object.entries(valuesSource)) {
412
+ if (dropReserved(field, `fields.values.${field}`))
413
+ continue;
414
+ setOwn(values, field, toStringArrayLenient(allowed, `fields.values.${field}`, doc.id, warnings));
415
+ }
416
+ }
417
+ }
418
+ // A values-constrained field that names neither a required nor an optional field is almost
419
+ // certainly a mistake (a declared constraint on an undeclared field can never fire, since
420
+ // `validateAgainstKind` only sees fields the instance actually carries — but the AUTHOR meant
421
+ // something).
422
+ const declaredFieldNames = new Set([...required, ...optional]);
423
+ for (const field of Object.keys(values)) {
424
+ if (!declaredFieldNames.has(field)) {
425
+ warnings.push({
426
+ code: "KIND_CONVENTION_UNDECLARED_VALUES_FIELD",
427
+ message: `kind convention '${doc.id}' declares 'fields.values.${field}' but '${field}' is not in fields.required or fields.optional.`,
428
+ field: `fields.values.${field}`,
429
+ severity: "warning",
430
+ });
431
+ }
432
+ }
433
+ const valueDescriptionsSource = fieldsRaw.value_descriptions;
434
+ const valueDescriptions = {};
435
+ if (valueDescriptionsSource !== undefined) {
436
+ if (!isPlainObject(valueDescriptionsSource)) {
437
+ warnings.push({
438
+ code: "KIND_CONVENTION_BAD_SHAPE",
439
+ message: `kind convention '${doc.id}' has a non-map 'fields.value_descriptions' (${describeShape(valueDescriptionsSource)}; expected a map of enum field -> allowed value -> non-empty description); ignoring it.`,
440
+ field: "fields.value_descriptions",
441
+ severity: "warning",
442
+ });
443
+ }
444
+ else {
445
+ for (const [field, rawValueDescriptions] of Object.entries(valueDescriptionsSource)) {
446
+ const fieldPath = `fields.value_descriptions.${field}`;
447
+ if (!isPlainObject(rawValueDescriptions)) {
448
+ warnings.push({
449
+ code: "KIND_CONVENTION_BAD_SHAPE",
450
+ message: `kind convention '${doc.id}' has a non-map '${fieldPath}' (${describeShape(rawValueDescriptions)}; expected a map of allowed value -> non-empty description); ignoring it.`,
451
+ field: fieldPath,
452
+ severity: "warning",
453
+ });
454
+ continue;
455
+ }
456
+ if (!hasOwn(values, field)) {
457
+ warnings.push({
458
+ code: "KIND_CONVENTION_UNDECLARED_VALUE_DESCRIPTION_FIELD",
459
+ message: `kind convention '${doc.id}' declares '${fieldPath}' but '${field}' has no 'fields.values.${field}' enum declared; skipping it.`,
460
+ field: fieldPath,
461
+ severity: "warning",
462
+ });
463
+ continue;
464
+ }
465
+ const allowed = values[field];
466
+ const parsed = {};
467
+ for (const [value, rawDescription] of Object.entries(rawValueDescriptions)) {
468
+ const valuePath = `${fieldPath}.${value}`;
469
+ if (typeof rawDescription !== "string" || rawDescription.trim() === "") {
470
+ warnings.push({
471
+ code: "KIND_CONVENTION_BAD_MEMBER",
472
+ message: `kind convention '${doc.id}' has a malformed '${valuePath}' (${describeShape(rawDescription)}; expected a non-empty string); skipping it.`,
473
+ field: valuePath,
474
+ severity: "warning",
475
+ });
476
+ continue;
477
+ }
478
+ if (!allowed.includes(value)) {
479
+ warnings.push({
480
+ code: "KIND_CONVENTION_UNDECLARED_VALUE_DESCRIPTION_VALUE",
481
+ message: `kind convention '${doc.id}' declares '${valuePath}' but '${value}' is not one of the declared 'fields.values.${field}' values (${allowed.join(", ")}); skipping it.`,
482
+ field: valuePath,
483
+ severity: "warning",
484
+ });
485
+ continue;
486
+ }
487
+ setOwn(parsed, value, rawDescription.trim());
488
+ }
489
+ if (Object.keys(parsed).length > 0)
490
+ setOwn(valueDescriptions, field, parsed);
491
+ }
492
+ }
493
+ }
494
+ const descriptionsSource = fieldsRaw.descriptions;
495
+ const descriptions = {};
496
+ if (descriptionsSource !== undefined) {
497
+ if (!isPlainObject(descriptionsSource)) {
498
+ warnings.push({
499
+ code: "KIND_CONVENTION_BAD_SHAPE",
500
+ message: `kind convention '${doc.id}' has a non-map 'fields.descriptions' (${describeShape(descriptionsSource)}; expected a map of field name -> non-empty description); ignoring it.`,
501
+ field: "fields.descriptions",
502
+ severity: "warning",
503
+ });
504
+ }
505
+ else {
506
+ for (const [field, rawDescription] of Object.entries(descriptionsSource)) {
507
+ if (dropReserved(field, `fields.descriptions.${field}`))
508
+ continue;
509
+ if (typeof rawDescription !== "string" || rawDescription.trim() === "") {
510
+ warnings.push({
511
+ code: "KIND_CONVENTION_BAD_MEMBER",
512
+ message: `kind convention '${doc.id}' has a malformed 'fields.descriptions.${field}' (${describeShape(rawDescription)}; expected a non-empty string); skipping it.`,
513
+ field: `fields.descriptions.${field}`,
514
+ severity: "warning",
515
+ });
516
+ continue;
517
+ }
518
+ setOwn(descriptions, field, rawDescription.trim());
519
+ if (!declaredFieldNames.has(field)) {
520
+ warnings.push({
521
+ code: "KIND_CONVENTION_UNDECLARED_DESCRIPTION_FIELD",
522
+ message: `kind convention '${doc.id}' declares 'fields.descriptions.${field}' but '${field}' is not in fields.required or fields.optional.`,
523
+ field: `fields.descriptions.${field}`,
524
+ severity: "warning",
525
+ });
526
+ }
527
+ }
528
+ }
529
+ }
530
+ // `fields.terminal` — the subset of values (per field) that mark an instance "done" (task board
531
+ // `tasks/status-terminal-declaration.md`). EXACTLY the lenient posture of `fields.values` above:
532
+ // absent is normal, a non-map shape warns+ignores, a non-scalar member warns+skips.
533
+ const terminalSource = fieldsRaw.terminal;
534
+ const terminal = {};
535
+ if (terminalSource !== undefined) {
536
+ if (!isPlainObject(terminalSource)) {
537
+ warnings.push({
538
+ code: "KIND_CONVENTION_BAD_SHAPE",
539
+ message: `kind convention '${doc.id}' has a non-map 'fields.terminal' (${describeShape(terminalSource)}; expected a map of field name -> list of terminal values); ignoring it.`,
540
+ field: "fields.terminal",
541
+ severity: "warning",
542
+ });
543
+ }
544
+ else {
545
+ for (const [field, terminalValues] of Object.entries(terminalSource)) {
546
+ if (dropReserved(field, `fields.terminal.${field}`))
547
+ continue;
548
+ setOwn(terminal, field, toStringArrayLenient(terminalValues, `fields.terminal.${field}`, doc.id, warnings));
549
+ }
550
+ }
551
+ }
552
+ // Coherence warning 1: a terminal set declared over a field with no `fields.values` enum at all
553
+ // (mirrors the UNDECLARED_VALUES_FIELD check above — the author probably meant to declare the
554
+ // enum too). Coherence warning 2: a terminal VALUE that isn't one of that field's declared enum
555
+ // values (only checked when the field's enum IS declared, to avoid double-warning the same
556
+ // mistake two different ways).
557
+ for (const field of Object.keys(terminal)) {
558
+ if (!hasOwn(values, field)) {
559
+ warnings.push({
560
+ code: "KIND_CONVENTION_TERMINAL_UNDECLARED_FIELD",
561
+ message: `kind convention '${doc.id}' declares 'fields.terminal.${field}' but '${field}' has no 'fields.values.${field}' enum declared.`,
562
+ field: `fields.terminal.${field}`,
563
+ severity: "warning",
564
+ });
565
+ continue;
566
+ }
567
+ const allowed = values[field];
568
+ for (const v of terminal[field]) {
569
+ if (!allowed.includes(v)) {
570
+ warnings.push({
571
+ code: "KIND_CONVENTION_TERMINAL_VALUE",
572
+ message: `kind convention '${doc.id}' declares terminal value '${v}' for field '${field}' but it is not one of the declared 'fields.values.${field}' values (${allowed.join(", ")}).`,
573
+ field: `fields.terminal.${field}`,
574
+ severity: "warning",
575
+ });
576
+ }
577
+ }
578
+ }
579
+ // `links:` — the typed-edge vocabulary (see the KindConvention.links doc comment). Same
580
+ // lenient posture as `fields`: absent is normal (no warning), a non-map shape warns and is
581
+ // ignored, a malformed entry warns and is skipped — never thrown.
582
+ const linksSource = fm.links;
583
+ let links;
584
+ if (linksSource !== undefined) {
585
+ if (!isPlainObject(linksSource)) {
586
+ warnings.push({
587
+ code: "KIND_CONVENTION_BAD_SHAPE",
588
+ message: `kind convention '${doc.id}' has a non-map 'links' key (${describeShape(linksSource)}; expected a map of link type name -> target kind); ignoring it.`,
589
+ field: "links",
590
+ severity: "warning",
591
+ });
592
+ }
593
+ else {
594
+ const parsed = {};
595
+ for (const [linkType, target] of Object.entries(linksSource)) {
596
+ const name = linkType.trim();
597
+ if (name === "" || !isScalar(target) || String(target).trim() === "") {
598
+ warnings.push({
599
+ code: "KIND_CONVENTION_BAD_MEMBER",
600
+ message: `kind convention '${doc.id}' has a malformed 'links' entry ('${linkType}': ${describeShape(target)}; expected 'link type name: target kind'); skipping it.`,
601
+ field: `links.${linkType}`,
602
+ severity: "warning",
603
+ });
604
+ continue;
605
+ }
606
+ setOwn(parsed, name, String(target).trim());
607
+ }
608
+ if (Object.keys(parsed).length > 0)
609
+ links = parsed;
610
+ }
611
+ }
612
+ // Relationship descriptions belong to the SOURCE kind's `links` declaration. They are
613
+ // guidance only: malformed or undeclared entries warn and are skipped, never becoming a
614
+ // second link vocabulary or changing validation/storage behavior.
615
+ const linkDescriptionsSource = fm.link_descriptions;
616
+ let linkDescriptions;
617
+ if (linkDescriptionsSource !== undefined) {
618
+ if (!isPlainObject(linkDescriptionsSource)) {
619
+ warnings.push({
620
+ code: "KIND_CONVENTION_BAD_SHAPE",
621
+ message: `kind convention '${doc.id}' has a non-map 'link_descriptions' key (${describeShape(linkDescriptionsSource)}; expected a map of declared link type name -> non-empty description); ignoring it.`,
622
+ field: "link_descriptions",
623
+ severity: "warning",
624
+ });
625
+ }
626
+ else {
627
+ const parsed = {};
628
+ for (const [linkType, rawDescription] of Object.entries(linkDescriptionsSource)) {
629
+ const name = linkType.trim();
630
+ const semanticPath = `link_descriptions.${linkType}`;
631
+ if (name === "" || typeof rawDescription !== "string" || rawDescription.trim() === "") {
632
+ warnings.push({
633
+ code: "KIND_CONVENTION_BAD_MEMBER",
634
+ message: `kind convention '${doc.id}' has a malformed '${semanticPath}' (${describeShape(rawDescription)}; expected a declared link type name with a non-empty string description); skipping it.`,
635
+ field: semanticPath,
636
+ severity: "warning",
637
+ });
638
+ continue;
639
+ }
640
+ if (!links || !hasOwn(links, name)) {
641
+ warnings.push({
642
+ code: "KIND_CONVENTION_UNDECLARED_LINK_DESCRIPTION",
643
+ message: `kind convention '${doc.id}' declares '${semanticPath}' but '${name}' is not declared in links.`,
644
+ field: semanticPath,
645
+ severity: "warning",
646
+ });
647
+ continue;
648
+ }
649
+ setOwn(parsed, name, rawDescription.trim());
650
+ }
651
+ if (Object.keys(parsed).length > 0)
652
+ linkDescriptions = parsed;
653
+ }
654
+ }
655
+ // `expects_inbound:` — inbound-link expectations this kind declares on ITSELF as link TARGET
656
+ // (see the KindConvention.expectsInbound doc comment). Same lenient posture as `links`: absent
657
+ // is normal (no warning), a non-map shape warns and is ignored, a malformed entry warns and is
658
+ // skipped — never thrown.
659
+ const expectsInboundSource = fm.expects_inbound;
660
+ let expectsInbound;
661
+ if (expectsInboundSource !== undefined) {
662
+ if (!isPlainObject(expectsInboundSource)) {
663
+ warnings.push({
664
+ code: "KIND_CONVENTION_BAD_SHAPE",
665
+ message: `kind convention '${doc.id}' has a non-map 'expects_inbound' key (${describeShape(expectsInboundSource)}; expected a map of link type name -> expected source kind); ignoring it.`,
666
+ field: "expects_inbound",
667
+ severity: "warning",
668
+ });
669
+ }
670
+ else {
671
+ const parsed = {};
672
+ for (const [linkType, source] of Object.entries(expectsInboundSource)) {
673
+ const name = linkType.trim();
674
+ if (name === "" || !isScalar(source) || String(source).trim() === "") {
675
+ warnings.push({
676
+ code: "KIND_CONVENTION_BAD_MEMBER",
677
+ message: `kind convention '${doc.id}' has a malformed 'expects_inbound' entry ('${linkType}': ${describeShape(source)}; expected 'link type name: expected source kind'); skipping it.`,
678
+ field: `expects_inbound.${linkType}`,
679
+ severity: "warning",
680
+ });
681
+ continue;
682
+ }
683
+ setOwn(parsed, name, String(source).trim());
684
+ }
685
+ if (Object.keys(parsed).length > 0)
686
+ expectsInbound = parsed;
687
+ }
688
+ }
689
+ // `claim:` — the bundle-DECLARED claim vocabulary (see the KindClaimPolicy doc comment). Same
690
+ // lenient posture as `links`/`expects_inbound`: absent is normal (no warning), a non-map shape
691
+ // warns and is ignored, a malformed member warns and is skipped — never thrown. Unrecognized
692
+ // keys inside the block are IGNORED without a warning: this parse deliberately consumes only
693
+ // the two coordinates, so warning on the block's value vocabulary would be wrong.
694
+ const claimSource = fm.claim;
695
+ let claim;
696
+ if (claimSource !== undefined) {
697
+ if (!isPlainObject(claimSource)) {
698
+ warnings.push({
699
+ code: "KIND_CONVENTION_BAD_SHAPE",
700
+ message: `kind convention '${doc.id}' has a non-map 'claim' key (${describeShape(claimSource)}; expected a map declaring 'owner_field' and/or 'state_field'); ignoring it.`,
701
+ field: "claim",
702
+ severity: "warning",
703
+ });
704
+ }
705
+ else {
706
+ const parsed = {};
707
+ for (const [key, target] of CLAIM_COORDINATE_KEYS) {
708
+ const declared = claimSource[key];
709
+ if (declared === undefined)
710
+ continue;
711
+ if (!isScalar(declared) || String(declared).trim() === "") {
712
+ warnings.push({
713
+ code: "KIND_CONVENTION_BAD_MEMBER",
714
+ message: `kind convention '${doc.id}' has a malformed 'claim.${key}' (${describeShape(declared)}; expected a declared field name); skipping it.`,
715
+ field: `claim.${key}`,
716
+ severity: "warning",
717
+ });
718
+ continue;
719
+ }
720
+ parsed[target] = String(declared).trim();
721
+ }
722
+ if (parsed.ownerField !== undefined || parsed.stateField !== undefined)
723
+ claim = parsed;
724
+ }
725
+ }
726
+ const sections = Array.isArray(fm.sections)
727
+ ? fm.sections.filter((s) => typeof s === "string" && s.trim() !== "")
728
+ : undefined;
729
+ const title = typeof fm.title === "string" && fm.title.trim() !== "" ? fm.title.trim() : governs;
730
+ let description;
731
+ if (fm.description !== undefined) {
732
+ if (typeof fm.description === "string" && fm.description.trim() !== "") {
733
+ description = fm.description.trim();
734
+ }
735
+ else {
736
+ warnings.push({
737
+ code: "KIND_CONVENTION_BAD_SHAPE",
738
+ message: `kind convention '${doc.id}' has an invalid 'description' (${describeShape(fm.description)}; expected a non-empty string); ignoring it.`,
739
+ field: "description",
740
+ severity: "warning",
741
+ });
742
+ }
743
+ }
744
+ const path = typeof fm.path === "string" && fm.path.trim() !== "" ? fm.path.trim() : undefined;
745
+ const freshnessHorizon = typeof fm.freshness_horizon === "string" && fm.freshness_horizon.trim() !== ""
746
+ ? fm.freshness_horizon.trim()
747
+ : undefined;
748
+ // Strict boolean `true` only — an absent, false, or non-boolean value means "expanded" (the default).
749
+ const browseCollapsed = fm.browse_collapsed === true ? true : undefined;
750
+ const kind = {
751
+ id: doc.id,
752
+ title,
753
+ governs,
754
+ fields: { required, optional, values, valueDescriptions, terminal, descriptions },
755
+ };
756
+ if (description !== undefined)
757
+ kind.description = description;
758
+ if (path !== undefined)
759
+ kind.path = path;
760
+ if (links !== undefined)
761
+ kind.links = links;
762
+ if (linkDescriptions !== undefined)
763
+ kind.linkDescriptions = linkDescriptions;
764
+ if (expectsInbound !== undefined)
765
+ kind.expectsInbound = expectsInbound;
766
+ if (sections && sections.length > 0)
767
+ kind.sections = sections;
768
+ if (freshnessHorizon !== undefined)
769
+ kind.freshnessHorizon = freshnessHorizon;
770
+ if (browseCollapsed !== undefined)
771
+ kind.browseCollapsed = browseCollapsed;
772
+ if (claim !== undefined)
773
+ kind.claim = claim;
774
+ return {
775
+ ok: true,
776
+ kind,
777
+ reservedFieldsIgnored: [...reservedFieldsIgnored].sort(),
778
+ reservedFieldPaths: [...reservedFieldPaths].sort(),
779
+ warnings,
780
+ };
781
+ }
782
+ const HORIZON_RE = /^(\d+)(m|h|d)$/;
783
+ const HORIZON_UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000 };
784
+ /**
785
+ * Parse a kind's declared `freshness_horizon` (`<n>(m|h|d)`) to milliseconds, or
786
+ * `undefined` if the kind declares none or it is malformed. This FEEDS the existing
787
+ * {@link FreshnessOptions.maxAgeMs} at the CLI layer — it does not fork `freshness()`.
788
+ */
789
+ export function freshnessHorizonMs(kind) {
790
+ const raw = kind.freshnessHorizon;
791
+ if (raw === undefined)
792
+ return undefined;
793
+ const m = HORIZON_RE.exec(raw);
794
+ if (!m)
795
+ return undefined;
796
+ const n = Number(m[1]);
797
+ // A zero horizon (e.g. "0h") would make every instance instantly stale — reject it the same
798
+ // way as a malformed string, rather than silently accepting a horizon that can never be met.
799
+ if (n <= 0)
800
+ return undefined;
801
+ const unit = m[2];
802
+ return n * HORIZON_UNIT_MS[unit];
803
+ }
804
+ /**
805
+ * True when a required-field value counts as "present" (non-empty string / non-empty array / any
806
+ * other defined value). Exported so consumers that must AGREE with validation's presence predicate
807
+ * (e.g. the CLI's `kind draft` inference) reuse this one authority instead of forking it.
808
+ */
809
+ export function isPresent(value) {
810
+ if (value === undefined || value === null)
811
+ return false;
812
+ if (typeof value === "string")
813
+ return value.trim() !== "";
814
+ if (Array.isArray(value))
815
+ return value.length > 0;
816
+ return true;
817
+ }
818
+ /**
819
+ * Validate `doc` against `kind`: required fields present + non-empty, enum-restricted
820
+ * field values within the declared allowed set, and declared body `sections` present
821
+ * (reusing the ONE heading splitter, {@link splitSections} — no second heading parser).
822
+ * Returns core's EXISTING {@link ValidationWarning} shape; never throws. Purely
823
+ * additive derivation — callers (the CLI) decide whether a warning blocks a write.
824
+ */
825
+ export function validateAgainstKind(doc, kind) {
826
+ const warnings = [];
827
+ const fm = doc.frontmatter;
828
+ for (const field of kind.fields.required) {
829
+ if (!hasOwn(fm, field) || !isPresent(fm[field])) {
830
+ warnings.push({
831
+ code: "KIND_FIELD_MISSING",
832
+ message: `'${kind.governs}' requires a non-empty '${field}' field (declared by ${kind.id}).`,
833
+ field,
834
+ severity: "warning",
835
+ });
836
+ }
837
+ }
838
+ for (const [field, allowed] of Object.entries(kind.fields.values)) {
839
+ if (!hasOwn(fm, field))
840
+ continue;
841
+ const raw = fm[field];
842
+ if (raw === undefined || raw === null)
843
+ continue;
844
+ // Arity: a `fields.values`-constrained field has SCALAR semantics by construction
845
+ // (an enum picks ONE state — `status`), so an ARRAY value is a violation even when
846
+ // every member passes the per-element membership check below. Without this,
847
+ // `--status todo --status done` persists a two-status doc with ZERO warnings, even
848
+ // strict (repeated-flag → array is a real FEATURE for non-enum fields like `tags`,
849
+ // so the guard lives HERE on the enum constraint, not in any command's parser — one
850
+ // validation locus, every consumer inherits: `new`, `doc update`, `doc write
851
+ // --strict`, `status`'s bundle lint). A future kind wanting a multi-select enum
852
+ // needs declared arity in the convention schema — not silently via arrays.
853
+ if (Array.isArray(raw)) {
854
+ warnings.push({
855
+ code: "KIND_FIELD_ARITY",
856
+ message: `'${field}' is enum-restricted and takes exactly ONE value for '${kind.governs}'; ` +
857
+ `got ${raw.length} (${raw.map((v) => String(v)).join(", ")}).`,
858
+ field,
859
+ severity: "warning",
860
+ });
861
+ }
862
+ const allowedStrs = allowed.map((v) => String(v));
863
+ const actual = (Array.isArray(raw) ? raw : [raw]).map((v) => String(v));
864
+ for (const v of actual) {
865
+ if (!allowedStrs.includes(v)) {
866
+ warnings.push({
867
+ code: "KIND_FIELD_VALUE",
868
+ message: `'${field}' value '${v}' is not one of the allowed values for '${kind.governs}': ${allowedStrs.join(", ")}.`,
869
+ field,
870
+ severity: "warning",
871
+ });
872
+ }
873
+ }
874
+ }
875
+ if (kind.sections && kind.sections.length > 0) {
876
+ const sections = splitSections(doc.body ?? "");
877
+ for (const heading of kind.sections) {
878
+ if (!hasOwn(sections, heading)) {
879
+ warnings.push({
880
+ code: "KIND_SECTION_MISSING",
881
+ message: `'${kind.governs}' expects a '# ${heading}' body section (declared by ${kind.id}).`,
882
+ field: heading,
883
+ severity: "warning",
884
+ });
885
+ }
886
+ }
887
+ }
888
+ return warnings;
889
+ }
890
+ /**
891
+ * Apply edition-aware legacy timestamp defaulting before evaluating the governing kind. v0.1
892
+ * retains the historical default on every write; v0.2 defaults it only when the bundle-local Kind
893
+ * explicitly requires that extension. The registry is supplied by the caller so mutation does
894
+ * not perform hidden discovery.
895
+ */
896
+ export function defaultTimestampAndValidateAgainstRegistry(doc, registry, options = {}) {
897
+ const kind = registry.kinds.get(String(doc.frontmatter.type));
898
+ const shouldDefaultTimestamp = options.okfVersion !== "0.2"
899
+ || kind?.fields.required.includes("timestamp") === true;
900
+ if (shouldDefaultTimestamp && !isUsableTimestamp(doc.frontmatter.timestamp)) {
901
+ doc.frontmatter.timestamp = (options.now ?? (() => new Date().toISOString()))();
902
+ }
903
+ if (!kind)
904
+ return { warnings: [] };
905
+ return { kind, warnings: validateAgainstKind(doc, kind) };
906
+ }
907
+ /**
908
+ * True iff `frontmatter` carries a terminal value on any field `kind.fields.terminal` declares
909
+ * (task board `tasks/status-terminal-declaration.md`) — THE one derivation every consumer calls
910
+ * (list's `--open`, the `status` sweep's exclusion + sort fallback). Coercion mirrors
911
+ * `validateAgainstKind`'s enum check: `String(v)` per element, so an unquoted YAML scalar still
912
+ * matches, and an array field matches on ANY-member semantics: ONE terminal value anywhere in an
913
+ * array field marks the whole doc terminal (a doc with `status: [done, doing]` IS terminal), so
914
+ * multi-valued fields should only declare terminal values whose mere presence means "closed". A
915
+ * kind with an empty `fields.terminal` (no declaration), or a doc missing every declared terminal
916
+ * field, is never terminal — not-terminal is the safe default.
917
+ */
918
+ export function isTerminal(kind, frontmatter) {
919
+ const fm = frontmatter;
920
+ for (const [field, terminalValues] of Object.entries(kind.fields.terminal)) {
921
+ if (!hasOwn(fm, field))
922
+ continue;
923
+ const raw = fm[field];
924
+ if (raw === undefined || raw === null)
925
+ continue;
926
+ const actual = (Array.isArray(raw) ? raw : [raw]).map((v) => String(v));
927
+ if (actual.some((v) => terminalValues.includes(v)))
928
+ return true;
929
+ }
930
+ return false;
931
+ }
932
+ /**
933
+ * Build the OKF concept document for a kind convention (the shape a `Convention` doc
934
+ * takes on disk — used to serialize any KindConvention to its on-disk Convention-doc form
935
+ * (e.g. by the CLI's recipe machinery)). `timestamp` is the caller's ISO instant (kept
936
+ * explicit rather than defaulted here, since `writeDocVersioned` already guarantees one).
937
+ */
938
+ export function kindConventionDoc(kind, prose, timestamp) {
939
+ const fields = { required: kind.fields.required, optional: kind.fields.optional };
940
+ if (Object.keys(kind.fields.values).length > 0)
941
+ fields.values = kind.fields.values;
942
+ const valueDescriptions = Object.fromEntries(Object.entries(kind.fields.valueDescriptions ?? {})
943
+ .filter(([field]) => hasOwn(kind.fields.values, field))
944
+ .map(([field, descriptions]) => {
945
+ const allowed = kind.fields.values[field];
946
+ const validDescriptions = Object.fromEntries(Object.entries(descriptions)
947
+ .filter(([value, description]) => hasOwn(descriptions, value) &&
948
+ allowed.includes(value) &&
949
+ typeof description === "string" &&
950
+ description.trim() !== "")
951
+ .map(([value, description]) => [value, description.trim()]));
952
+ return [field, validDescriptions];
953
+ })
954
+ .filter(([, descriptions]) => Object.keys(descriptions).length > 0));
955
+ if (Object.keys(valueDescriptions).length > 0)
956
+ fields.value_descriptions = valueDescriptions;
957
+ if (Object.keys(kind.fields.terminal).length > 0)
958
+ fields.terminal = kind.fields.terminal;
959
+ const descriptions = Object.fromEntries(Object.entries(kind.fields.descriptions)
960
+ .filter((entry) => typeof entry[1] === "string" && entry[1].trim() !== "")
961
+ .map(([field, description]) => [field, description.trim()]));
962
+ if (Object.keys(descriptions).length > 0)
963
+ fields.descriptions = descriptions;
964
+ const frontmatter = { type: CONVENTION_TYPE, title: kind.title, governs: kind.governs, timestamp };
965
+ if (typeof kind.description === "string" && kind.description.trim() !== "") {
966
+ frontmatter.description = kind.description.trim();
967
+ }
968
+ if (kind.path !== undefined)
969
+ frontmatter.path = kind.path;
970
+ if (kind.links && Object.keys(kind.links).length > 0)
971
+ frontmatter.links = kind.links;
972
+ const linkDescriptions = Object.fromEntries(Object.entries(kind.linkDescriptions ?? {})
973
+ .filter((entry) => Boolean(kind.links && hasOwn(kind.links, entry[0])) &&
974
+ typeof entry[1] === "string" &&
975
+ entry[1].trim() !== "")
976
+ .map(([linkType, description]) => [linkType, description.trim()]));
977
+ if (Object.keys(linkDescriptions).length > 0)
978
+ frontmatter.link_descriptions = linkDescriptions;
979
+ if (kind.expectsInbound && Object.keys(kind.expectsInbound).length > 0) {
980
+ frontmatter.expects_inbound = kind.expectsInbound;
981
+ }
982
+ // Round-trip the claim declaration: a serializer that dropped it would silently un-declare
983
+ // ownership the first time a convention passed back through this writer.
984
+ if (kind.claim && (kind.claim.ownerField !== undefined || kind.claim.stateField !== undefined)) {
985
+ const claim = {};
986
+ if (kind.claim.ownerField !== undefined)
987
+ claim.owner_field = kind.claim.ownerField;
988
+ if (kind.claim.stateField !== undefined)
989
+ claim.state_field = kind.claim.stateField;
990
+ frontmatter.claim = claim;
991
+ }
992
+ frontmatter.fields = fields;
993
+ if (kind.sections && kind.sections.length > 0)
994
+ frontmatter.sections = kind.sections;
995
+ if (kind.freshnessHorizon !== undefined)
996
+ frontmatter.freshness_horizon = kind.freshnessHorizon;
997
+ if (kind.browseCollapsed)
998
+ frontmatter.browse_collapsed = true;
999
+ return { id: kind.id, frontmatter, body: prose };
1000
+ }
1001
+ //# sourceMappingURL=kinds.js.map