@cohortapp/agent-sdk 2.3.1 → 2.4.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 (162) hide show
  1. package/bin/maestro.mjs +37 -50
  2. package/framework-features.json +30 -0
  3. package/lib/backlog.mjs +136 -0
  4. package/lib/cadences.mjs +63 -2
  5. package/lib/cadences.test.mjs +105 -0
  6. package/lib/capability/inventory.mjs +542 -0
  7. package/lib/capability/inventory.test.mjs +232 -0
  8. package/lib/capability/probe.mjs +255 -0
  9. package/lib/channels/contract.mjs +37 -1
  10. package/lib/channels/contract.test.mjs +25 -1
  11. package/lib/channels/inbox-item.mjs +20 -0
  12. package/lib/claude-bin.mjs +37 -3
  13. package/lib/claude-bin.test.mjs +42 -8
  14. package/lib/execution/disposition.mjs +501 -0
  15. package/lib/execution/disposition.test.mjs +482 -0
  16. package/lib/execution/drive.mjs +352 -0
  17. package/lib/execution/drive.test.mjs +270 -0
  18. package/lib/execution/effects.mjs +340 -0
  19. package/lib/execution/effects.test.mjs +193 -0
  20. package/lib/execution/index.mjs +152 -0
  21. package/lib/execution/intake.mjs +581 -0
  22. package/lib/execution/intake.test.mjs +343 -0
  23. package/lib/execution/journal.mjs +374 -0
  24. package/lib/execution/journal.test.mjs +261 -0
  25. package/lib/execution/match.mjs +331 -0
  26. package/lib/execution/match.test.mjs +235 -0
  27. package/lib/execution/pipeline.mjs +341 -0
  28. package/lib/execution/pipeline.test.mjs +389 -0
  29. package/lib/execution/route.mjs +332 -0
  30. package/lib/execution/route.test.mjs +186 -0
  31. package/lib/execution/surface-policy.mjs +446 -0
  32. package/lib/execution/surface-policy.test.mjs +162 -0
  33. package/lib/goals/admission.mjs +209 -0
  34. package/lib/goals/admission.test.mjs +139 -0
  35. package/lib/goals/classify.mjs +206 -0
  36. package/lib/goals/classify.test.mjs +109 -0
  37. package/lib/goals/collaborate.mjs +415 -0
  38. package/lib/goals/collaborate.test.mjs +324 -0
  39. package/lib/goals/gaps.mjs +111 -0
  40. package/lib/goals/gaps.test.mjs +284 -0
  41. package/lib/goals/loop.mjs +537 -0
  42. package/lib/goals/loop.test.mjs +719 -0
  43. package/lib/identity/persona.mjs +247 -0
  44. package/lib/identity/persona.test.mjs +117 -0
  45. package/lib/kpi.mjs +469 -0
  46. package/lib/kpi.test.mjs +244 -0
  47. package/lib/mandate/audit.mjs +168 -0
  48. package/lib/mandate/audit.test.mjs +195 -0
  49. package/lib/mandate/cache.mjs +162 -0
  50. package/lib/mandate/derive.mjs +317 -0
  51. package/lib/mandate/derive.test.mjs +224 -0
  52. package/lib/mandate/model.mjs +352 -0
  53. package/lib/mandate/model.test.mjs +145 -0
  54. package/lib/mandate/refresh.mjs +187 -0
  55. package/lib/mandate/refresh.test.mjs +293 -0
  56. package/lib/mcp/server.test.mjs +4 -4
  57. package/lib/org/approvals.mjs +14 -2
  58. package/lib/org/client.mjs +79 -25
  59. package/lib/org/client.test.mjs +54 -1
  60. package/lib/org/doctor.mjs +64 -0
  61. package/lib/org/doctor.test.mjs +31 -2
  62. package/lib/org/inbound/directedness.mjs +720 -0
  63. package/lib/org/inbound/directedness.test.mjs +543 -0
  64. package/lib/org/inbound/facts.mjs +501 -0
  65. package/lib/org/inbound/facts.test.mjs +375 -0
  66. package/lib/org/inbound/hydrate.mjs +535 -0
  67. package/lib/org/inbound/hydrate.test.mjs +326 -0
  68. package/lib/org/inbound/index.mjs +233 -0
  69. package/lib/org/inbound/index.test.mjs +324 -0
  70. package/lib/org/inbound/io.mjs +141 -0
  71. package/lib/org/inbound/project.mjs +201 -0
  72. package/lib/org/inbound/project.test.mjs +287 -0
  73. package/lib/org/inbound/surfaces.mjs +257 -0
  74. package/lib/org/knowledge.mjs +10 -1
  75. package/lib/org/knowledge.test.mjs +8 -1
  76. package/lib/org/leases.mjs +5 -0
  77. package/lib/org/mesh.mjs +45 -2
  78. package/lib/org/mesh.test.mjs +55 -0
  79. package/lib/org/messaging.mjs +180 -15
  80. package/lib/org/messaging.test.mjs +117 -0
  81. package/lib/org/param-contract.mjs +694 -0
  82. package/lib/org/param-contract.test.mjs +451 -0
  83. package/lib/org/protocol.checksum +1 -1
  84. package/lib/org/protocol.mjs +8 -0
  85. package/lib/org/protocol.test.mjs +5 -1
  86. package/lib/org/push.mjs +1025 -0
  87. package/lib/org/push.test.mjs +690 -0
  88. package/lib/org/tool-surface.mjs +138 -38
  89. package/lib/org/tool-surface.test.mjs +13 -8
  90. package/lib/org/typing.mjs +341 -0
  91. package/lib/org/typing.test.mjs +291 -0
  92. package/lib/plan/compile.mjs +510 -0
  93. package/lib/plan/compile.test.mjs +286 -0
  94. package/lib/plan/emit.mjs +256 -0
  95. package/lib/plan/emit.test.mjs +246 -0
  96. package/lib/plan/explain.mjs +226 -0
  97. package/lib/plan/explain.test.mjs +188 -0
  98. package/lib/plan/schema.mjs +140 -0
  99. package/lib/resource-governor.mjs +47 -1
  100. package/lib/resource-governor.test.mjs +21 -1
  101. package/lib/setup/enroll-from-cohort.mjs +84 -16
  102. package/lib/setup/enroll-from-cohort.test.mjs +43 -1
  103. package/lib/setup/sections/identity.mjs +15 -4
  104. package/lib/setup/sections/identity.test.mjs +94 -0
  105. package/lib/setup/sections/inventory.mjs +178 -0
  106. package/lib/setup/sections/inventory.test.mjs +198 -0
  107. package/lib/setup/sections/mandate.mjs +392 -0
  108. package/lib/setup/sections/mandate.test.mjs +373 -0
  109. package/lib/setup/sections/subagents.mjs +427 -0
  110. package/lib/setup/sections/subagents.test.mjs +429 -0
  111. package/lib/setup/sections/verify.mjs +121 -0
  112. package/lib/setup/sections/verify.test.mjs +175 -0
  113. package/lib/setup/sot.mjs +2 -0
  114. package/lib/subagents/cli.mjs +463 -0
  115. package/lib/subagents/cli.test.mjs +389 -0
  116. package/lib/subagents/client.mjs +373 -0
  117. package/lib/subagents/client.test.mjs +309 -0
  118. package/lib/subagents/gap.mjs +268 -0
  119. package/lib/subagents/gap.test.mjs +234 -0
  120. package/lib/subagents/lock.mjs +296 -0
  121. package/lib/subagents/lock.test.mjs +248 -0
  122. package/lib/subagents/manifest.mjs +224 -0
  123. package/lib/subagents/manifest.test.mjs +175 -0
  124. package/lib/subagents/refs.mjs +274 -0
  125. package/lib/subagents/refs.test.mjs +204 -0
  126. package/lib/subagents/resolve.mjs +455 -0
  127. package/lib/subagents/resolve.test.mjs +422 -0
  128. package/lib/subagents/schema.mjs +467 -0
  129. package/lib/subagents/schema.test.mjs +306 -0
  130. package/package.json +9 -4
  131. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  132. package/policies/ai-disclosure.yaml +42 -2
  133. package/scaffold/CLAUDE.md +16 -2
  134. package/schedules/triggers/goal-steward.md +79 -0
  135. package/scripts/ci/conformance-org-api.mjs +792 -0
  136. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  137. package/scripts/daemon/agent-daemon.mjs +70 -11
  138. package/scripts/daemon/cadence-handlers.mjs +187 -5
  139. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  140. package/scripts/daemon/inbox-deferral.mjs +45 -2
  141. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  142. package/scripts/daemon/inbox-wake.mjs +282 -0
  143. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  144. package/scripts/daemon/maestro-daemon.mjs +23 -0
  145. package/scripts/daemon/prompt-builder.mjs +41 -1
  146. package/scripts/daemon/responder.mjs +56 -0
  147. package/scripts/daemon/typing-registry.mjs +55 -2
  148. package/scripts/daemon/typing-registry.test.mjs +25 -0
  149. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  150. package/scripts/poller/inbox-scan-poller.mjs +26 -1
  151. package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
  152. package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
  153. package/scripts/poller/slack-poller.mjs +32 -0
  154. package/scripts/poller/slack-socket-mode.mjs +27 -1
  155. package/scripts/poller/slack-socket-mode.test.mjs +52 -0
  156. package/scripts/poller/utils.mjs +47 -0
  157. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  158. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  159. package/scripts/setup/generate-plan.mjs +108 -0
  160. package/scripts/setup/init-capability-manifest.mjs +70 -0
  161. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  162. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -0,0 +1,467 @@
1
+ /**
2
+ * lib/subagents/schema.mjs — the repo's first sub-agent frontmatter parser +
3
+ * validator (Sub-Agent Registry v1 §2.1).
4
+ *
5
+ * WHY THIS EXISTS. Until now nothing in maestro parsed `agents/<slug>/agent.md`.
6
+ * `doctor` only grepped for `agent:` references and checked that a DIRECTORY
7
+ * existed — so an empty `agents/foo/` passed, and a generated agent.md whose
8
+ * `model:` said `sonnet` while every shipped one said `claude-sonnet-4-6` drifted
9
+ * silently for months. The registry cannot distribute prose it cannot validate,
10
+ * so the validator is the first thing built and it is the SAME rule set the hq
11
+ * handler (`src/server/subagents/validate.ts`) applies server-side. A body that
12
+ * fails here is refused at materialisation; a body that fails there is refused at
13
+ * publish. Two gates, one rule set.
14
+ *
15
+ * WHY A HAND-ROLLED PARSER RATHER THAN js-yaml. Three reasons, in order:
16
+ * 1. `resolve()`, `verify()` and `doctor` must be SYNCHRONOUS and must run with
17
+ * zero I/O beyond the file read. Every other yaml consumer in this repo does
18
+ * `await import("js-yaml")`, which would make the whole resolution path
19
+ * async for no benefit.
20
+ * 2. The frontmatter grammar is genuinely tiny and CLOSED: `key: scalar`,
21
+ * `key: [a, b]` (flow sequence, quoted or bare) and a block sequence of
22
+ * `- item` lines. Every shipped agent.md and every generated one uses only
23
+ * those. A closed grammar we can refuse outside of is safer than a general
24
+ * parser that will happily accept an anchor/alias/merge-key we then have to
25
+ * round-trip byte-exactly into hq.
26
+ * 3. The registry has to WRITE frontmatter back (provenance stamping), and a
27
+ * full YAML emitter reformats the whole block — which would change the file
28
+ * hash of every managed file on every materialisation and make local-edit
29
+ * detection (§3 rule 1) permanently wrong.
30
+ * Anything outside the grammar is an ERROR, never a silent mis-parse.
31
+ *
32
+ * Node builtins only (node:crypto). Pure — no I/O. ESM.
33
+ *
34
+ * @module lib/subagents/schema
35
+ */
36
+
37
+ "use strict";
38
+
39
+ import { createHash } from "node:crypto";
40
+ import { KNOWN_TOKENS, collectTokens } from "../render.mjs";
41
+
42
+ /**
43
+ * Model alias → the full Anthropic id the runtime actually dispatches on.
44
+ *
45
+ * This map is the place the shipped-vs-generated drift dies. `agents/*.md` that
46
+ * shipped with the SDK all say `claude-sonnet-4-6`; `generate-capability.mjs`
47
+ * emitted the bare alias `sonnet`. Both are accepted on INPUT and both normalise
48
+ * to the same full id on OUTPUT, so a fork of a shipped agent and a freshly
49
+ * generated one are byte-comparable. Full ids map to themselves so normalisation
50
+ * is idempotent (critical: it runs on every materialisation, and a
51
+ * non-idempotent normalisation would change the file hash every pass).
52
+ *
53
+ * Ids match lib/model-router/resolve.mjs (`claude-opus-4-8` / `claude-sonnet-4-6`
54
+ * / `claude-haiku-4-5`). Extend deliberately and in lockstep with hq's
55
+ * `validate.ts` — a divergence here means hq stores a model id the agent refuses.
56
+ * @type {Readonly<Record<string,string>>}
57
+ */
58
+ export const MODEL_ALIASES = Object.freeze({
59
+ opus: "claude-opus-4-8",
60
+ sonnet: "claude-sonnet-4-6",
61
+ haiku: "claude-haiku-4-5",
62
+ "claude-opus-4-8": "claude-opus-4-8",
63
+ "claude-sonnet-4-6": "claude-sonnet-4-6",
64
+ "claude-haiku-4-5": "claude-haiku-4-5",
65
+ // `inherit` is Claude Code's "use the parent session's model" sentinel. It is
66
+ // a legitimate value, not an alias, so it passes through untouched.
67
+ inherit: "inherit",
68
+ });
69
+
70
+ /**
71
+ * Frontmatter keys the registry OWNS. They are stamped at materialisation and
72
+ * stripped before a body is published back, so a round-trip
73
+ * (publish → resolve → materialise) is stable and never accumulates provenance.
74
+ * @type {readonly string[]}
75
+ */
76
+ export const PROVENANCE_KEYS = Object.freeze(["layer", "source", "version", "contentHash"]);
77
+
78
+ /** The authored keys, in the canonical emit order. */
79
+ export const AUTHORED_KEYS = Object.freeze(["name", "description", "model", "tools", "tokens"]);
80
+
81
+ /** sha256 hex of a string. The one hashing primitive the registry uses. */
82
+ function sha256(s) {
83
+ return createHash("sha256").update(String(s), "utf8").digest("hex");
84
+ }
85
+
86
+ /**
87
+ * sha256 of a version BODY — the identity hq stores as
88
+ * `SubagentVersion.contentHash`. Used for dedupe and for "is my pinned version
89
+ * still the one the roster says". NOT used for local-edit detection.
90
+ * @param {string} body
91
+ * @returns {string}
92
+ */
93
+ export function hashBody(body) {
94
+ return sha256(body == null ? "" : body);
95
+ }
96
+
97
+ /**
98
+ * sha256 of a whole agent.md FILE (frontmatter + body, exact bytes).
99
+ *
100
+ * This — not {@link hashBody} — is what local-edit detection (§3 rule 1) and the
101
+ * SDK manifest compare, because what is on disk is the whole file including the
102
+ * provenance stamp. The spec's rule 1 says "sha256(file) ≠ lock[slug].contentHash";
103
+ * we keep BOTH hashes in the lock (`fileHash` for the disk comparison,
104
+ * `contentHash` for the hq body identity) rather than conflating two different
105
+ * preimages under one name, which would make every stamped file look locally
106
+ * edited the moment it was written.
107
+ * @param {string} text
108
+ * @returns {string}
109
+ */
110
+ export function hashFile(text) {
111
+ return sha256(text == null ? "" : text);
112
+ }
113
+
114
+ /**
115
+ * Split an agent.md into its frontmatter text and body.
116
+ *
117
+ * Requires the document to OPEN with `---` on line 1 (Claude Code's own rule) and
118
+ * close with a `---` line. A file without frontmatter is an error, not an empty
119
+ * frontmatter — silently treating a prose-only file as valid is how an empty
120
+ * `agents/foo/` directory used to pass doctor.
121
+ *
122
+ * @param {string} text
123
+ * @returns {{ok:boolean, frontmatterText:string, body:string, error?:string}}
124
+ */
125
+ export function splitFrontmatter(text) {
126
+ const src = typeof text === "string" ? text : "";
127
+ // Tolerate a UTF-8 BOM and CRLF line endings — both show up in files that have
128
+ // been round-tripped through an editor on a shared drive.
129
+ const clean = src.replace(/^/, "").replace(/\r\n/g, "\n");
130
+ if (!clean.startsWith("---\n")) {
131
+ return { ok: false, frontmatterText: "", body: clean, error: "missing opening --- frontmatter fence" };
132
+ }
133
+ const end = clean.indexOf("\n---", 3);
134
+ if (end === -1) {
135
+ return { ok: false, frontmatterText: "", body: clean, error: "unterminated frontmatter (no closing ---)" };
136
+ }
137
+ const frontmatterText = clean.slice(4, end + 1);
138
+ // Body starts after the closing fence's own newline (or EOF if it is last).
139
+ const afterFence = clean.indexOf("\n", end + 1);
140
+ const body = afterFence === -1 ? "" : clean.slice(afterFence + 1);
141
+ return { ok: true, frontmatterText, body };
142
+ }
143
+
144
+ /**
145
+ * Parse the closed frontmatter grammar into a plain object.
146
+ *
147
+ * Grammar (anything else is an error):
148
+ * key: scalar → string (quotes stripped, `#` comment stripped
149
+ * ONLY when unquoted and preceded by a space)
150
+ * key: [a, "b", 'c'] → string[]
151
+ * key: → string[] built from following `- item` lines
152
+ * - a
153
+ * - b
154
+ *
155
+ * @param {string} frontmatterText
156
+ * @returns {{ok:boolean, value:Object, errors:string[]}}
157
+ */
158
+ export function parseFrontmatter(frontmatterText) {
159
+ const out = {};
160
+ const errors = [];
161
+ const lines = String(frontmatterText || "").split("\n");
162
+ let pendingListKey = null;
163
+
164
+ for (let i = 0; i < lines.length; i++) {
165
+ const raw = lines[i];
166
+ if (!raw.trim()) continue;
167
+ if (/^\s*#/.test(raw)) continue;
168
+
169
+ // Block-sequence item belonging to the previous `key:` with an empty value.
170
+ const item = raw.match(/^\s+-\s+(.*)$/);
171
+ if (item) {
172
+ if (!pendingListKey) {
173
+ errors.push(`line ${i + 1}: list item with no owning key ("${raw.trim()}")`);
174
+ continue;
175
+ }
176
+ out[pendingListKey].push(unquote(stripComment(item[1])));
177
+ continue;
178
+ }
179
+
180
+ const kv = raw.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
181
+ if (!kv) {
182
+ errors.push(`line ${i + 1}: not a "key: value" pair ("${raw.trim()}") — nested maps are outside the frontmatter grammar`);
183
+ pendingListKey = null;
184
+ continue;
185
+ }
186
+ const key = kv[1];
187
+ const rest = kv[2];
188
+
189
+ if (rest.trim() === "") {
190
+ // `key:` with nothing after it opens a block sequence.
191
+ out[key] = [];
192
+ pendingListKey = key;
193
+ continue;
194
+ }
195
+ pendingListKey = null;
196
+
197
+ const flow = rest.trim();
198
+ if (flow.startsWith("[")) {
199
+ if (!flow.endsWith("]")) {
200
+ errors.push(`line ${i + 1}: unterminated flow sequence for "${key}"`);
201
+ continue;
202
+ }
203
+ out[key] = splitFlowSeq(flow.slice(1, -1));
204
+ continue;
205
+ }
206
+ out[key] = unquote(stripComment(rest));
207
+ }
208
+
209
+ return { ok: errors.length === 0, value: out, errors };
210
+ }
211
+
212
+ /**
213
+ * Split the inside of a `[...]` flow sequence on commas that are not inside
214
+ * quotes. A naive `.split(",")` breaks on `tools: ["Read, maybe"]` — unlikely but
215
+ * the registry round-trips these bytes into a database, so "unlikely" is not a
216
+ * licence to corrupt.
217
+ * @param {string} inner
218
+ * @returns {string[]}
219
+ */
220
+ function splitFlowSeq(inner) {
221
+ const out = [];
222
+ let cur = "";
223
+ let quote = "";
224
+ for (const ch of inner) {
225
+ if (quote) {
226
+ if (ch === quote) quote = "";
227
+ else cur += ch;
228
+ continue;
229
+ }
230
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
231
+ if (ch === ",") { out.push(cur.trim()); cur = ""; continue; }
232
+ cur += ch;
233
+ }
234
+ if (cur.trim()) out.push(cur.trim());
235
+ return out.filter(Boolean);
236
+ }
237
+
238
+ /** Strip a trailing ` # comment` from an UNQUOTED scalar. */
239
+ function stripComment(s) {
240
+ const t = String(s);
241
+ if (t.startsWith('"') || t.startsWith("'")) return t; // quoted: `#` is content
242
+ const idx = t.indexOf(" #");
243
+ return (idx === -1 ? t : t.slice(0, idx)).trim();
244
+ }
245
+
246
+ /** Strip a single layer of matching quotes. */
247
+ function unquote(s) {
248
+ const t = String(s).trim();
249
+ if (t.length >= 2 && ((t[0] === '"' && t.endsWith('"')) || (t[0] === "'" && t.endsWith("'")))) {
250
+ return t.slice(1, -1);
251
+ }
252
+ return t;
253
+ }
254
+
255
+ /**
256
+ * Emit a frontmatter object back to text using the SAME shapes the shipped files
257
+ * use (scalars bare, sequences as JSON-ish flow arrays with double quotes), in a
258
+ * canonical key order: authored keys first, then provenance, then anything else.
259
+ *
260
+ * Canonical ordering matters: it is what makes `hashFile` stable across a
261
+ * re-materialisation, which is what makes local-edit detection trustworthy.
262
+ *
263
+ * @param {object} fm
264
+ * @returns {string} the text BETWEEN the fences (trailing newline included)
265
+ */
266
+ export function stringifyFrontmatter(fm) {
267
+ const obj = fm && typeof fm === "object" ? fm : {};
268
+ const seen = new Set();
269
+ const order = [...AUTHORED_KEYS, ...PROVENANCE_KEYS];
270
+ const keys = [];
271
+ for (const k of order) if (Object.prototype.hasOwnProperty.call(obj, k)) { keys.push(k); seen.add(k); }
272
+ for (const k of Object.keys(obj)) if (!seen.has(k)) keys.push(k);
273
+
274
+ const lines = [];
275
+ for (const k of keys) {
276
+ const v = obj[k];
277
+ if (v === undefined || v === null) continue;
278
+ if (Array.isArray(v)) {
279
+ lines.push(`${k}: [${v.map((x) => JSON.stringify(String(x))).join(", ")}]`);
280
+ continue;
281
+ }
282
+ lines.push(`${k}: ${String(v)}`);
283
+ }
284
+ return lines.length ? `${lines.join("\n")}\n` : "";
285
+ }
286
+
287
+ /** Re-assemble a full agent.md from a frontmatter object + body. */
288
+ export function stringifyAgentMd(fm, body) {
289
+ const b = typeof body === "string" ? body : "";
290
+ return `---\n${stringifyFrontmatter(fm)}---\n${b.startsWith("\n") ? b.slice(1) : b}`;
291
+ }
292
+
293
+ /**
294
+ * Parse a whole agent.md. Never throws.
295
+ * @param {string} text
296
+ * @returns {{ok:boolean, frontmatter:object, body:string, errors:string[]}}
297
+ */
298
+ export function parseAgentMd(text) {
299
+ const split = splitFrontmatter(text);
300
+ if (!split.ok) return { ok: false, frontmatter: {}, body: split.body, errors: [split.error] };
301
+ const fm = parseFrontmatter(split.frontmatterText);
302
+ return { ok: fm.ok, frontmatter: fm.value, body: split.body, errors: fm.errors };
303
+ }
304
+
305
+ /**
306
+ * Normalise a model value to its full id. Returns "" for an empty/absent value
307
+ * and `null` for a value that is neither an alias nor a known full id — the
308
+ * caller turns that into a validation error rather than guessing.
309
+ * @param {*} m
310
+ * @returns {string|null}
311
+ */
312
+ export function normaliseModel(m) {
313
+ const s = m == null ? "" : String(m).trim();
314
+ if (!s) return "";
315
+ const hit = MODEL_ALIASES[s.toLowerCase()];
316
+ return hit === undefined ? null : hit;
317
+ }
318
+
319
+ /**
320
+ * The `{{agent.*}}`-style token paths a BODY actually uses (deduped, sorted).
321
+ * Reuses lib/render.mjs's collector so the registry and the renderer can never
322
+ * disagree about what a token even is.
323
+ * @param {string} body
324
+ * @returns {string[]}
325
+ */
326
+ export function bodyTokens(body) {
327
+ return [...new Set(collectTokens(typeof body === "string" ? body : ""))].sort();
328
+ }
329
+
330
+ /**
331
+ * Token paths in `tokens[]` that this agent's LOCAL renderer cannot resolve.
332
+ *
333
+ * This is the materialisation gate: hq deliberately does NOT vendor KNOWN_TOKENS
334
+ * (it stores and returns whatever the author declared), so the FETCHING agent is
335
+ * the one that refuses a body it could not render. Refusing here is what makes
336
+ * `scripts/ci/check-unresolved-tokens.mjs` structurally unable to fail after a
337
+ * fetch — an unrenderable body never reaches disk.
338
+ *
339
+ * @param {string[]} tokens
340
+ * @param {Set<string>} [known] injectable for tests
341
+ * @returns {string[]}
342
+ */
343
+ export function unsupportedTokens(tokens, known = KNOWN_TOKENS) {
344
+ return (Array.isArray(tokens) ? tokens : []).filter((t) => !known.has(String(t)));
345
+ }
346
+
347
+ /**
348
+ * Validate a parsed agent.md against the registry rules (§1.3 / §2.1).
349
+ *
350
+ * Rules, all of them errors:
351
+ * - `name` present and EQUAL to the slug (identity is the directory name;
352
+ * §3 makes this the whole identity story, so a mismatch is unresolvable)
353
+ * - `description` non-empty
354
+ * - `model` normalises to a known id
355
+ * - `tools` a non-empty array of non-empty strings
356
+ * - every token used in the body is declared in `tokens[]` (when `tokens` is
357
+ * declared at all — an absent `tokens` is DERIVED from the body, not an
358
+ * error, so existing shipped files validate unchanged)
359
+ * - body non-empty
360
+ *
361
+ * @param {{frontmatter:object, body:string, slug:string, known?:Set<string>}} o
362
+ * @returns {{ok:boolean, errors:string[], warnings:string[], normalised:{frontmatter:object, body:string}}}
363
+ */
364
+ export function validateAgentMd(o = {}) {
365
+ const fm = o.frontmatter && typeof o.frontmatter === "object" ? { ...o.frontmatter } : {};
366
+ const body = typeof o.body === "string" ? o.body : "";
367
+ const slug = String(o.slug || "").trim();
368
+ const errors = [];
369
+ const warnings = [];
370
+
371
+ const name = String(fm.name || "").trim();
372
+ if (!name) errors.push("frontmatter.name is required");
373
+ else if (slug && name !== slug) errors.push(`frontmatter.name "${name}" must equal the directory name "${slug}"`);
374
+
375
+ const description = String(fm.description || "").trim();
376
+ if (!description) errors.push("frontmatter.description is required and must be non-empty");
377
+
378
+ const model = normaliseModel(fm.model);
379
+ if (model === null) errors.push(`frontmatter.model "${fm.model}" is not a known model or alias (${Object.keys(MODEL_ALIASES).join(", ")})`);
380
+ else if (!model) errors.push("frontmatter.model is required");
381
+
382
+ const tools = Array.isArray(fm.tools) ? fm.tools.map((t) => String(t).trim()).filter(Boolean) : null;
383
+ if (!tools) errors.push("frontmatter.tools is required and must be an array");
384
+ else if (!tools.length) errors.push("frontmatter.tools must be non-empty");
385
+
386
+ const used = bodyTokens(body);
387
+ let tokens;
388
+ if (fm.tokens === undefined) {
389
+ // Derived, not demanded: every file that shipped before the registry existed
390
+ // has no `tokens:` key, and forcing one would fail-closed the entire fleet on
391
+ // the first `doctor` run after upgrade.
392
+ tokens = used;
393
+ } else {
394
+ tokens = Array.isArray(fm.tokens) ? fm.tokens.map((t) => String(t).trim()).filter(Boolean) : [];
395
+ if (!Array.isArray(fm.tokens)) errors.push("frontmatter.tokens must be an array when present");
396
+ const undeclared = used.filter((t) => !tokens.includes(t));
397
+ if (undeclared.length) errors.push(`body uses undeclared token(s): ${undeclared.join(", ")}`);
398
+ const unused = tokens.filter((t) => !used.includes(t));
399
+ if (unused.length) warnings.push(`tokens declared but unused in body: ${unused.join(", ")}`);
400
+ }
401
+
402
+ const unknown = unsupportedTokens(tokens, o.known);
403
+ if (unknown.length) warnings.push(`token(s) unknown to this agent's renderer: ${unknown.join(", ")}`);
404
+
405
+ if (!body.trim()) errors.push("body is empty");
406
+
407
+ const normalised = {
408
+ frontmatter: {
409
+ ...fm,
410
+ name: name || slug,
411
+ description,
412
+ model: model || fm.model,
413
+ tools: tools || [],
414
+ tokens,
415
+ },
416
+ body,
417
+ };
418
+ return { ok: errors.length === 0, errors, warnings, normalised };
419
+ }
420
+
421
+ /**
422
+ * Convenience: parse + validate a raw agent.md in one call.
423
+ * @param {string} text @param {string} slug @param {Set<string>} [known]
424
+ */
425
+ export function checkAgentFile(text, slug, known) {
426
+ const parsed = parseAgentMd(text);
427
+ if (!parsed.ok) return { ok: false, errors: parsed.errors, warnings: [], frontmatter: parsed.frontmatter, body: parsed.body };
428
+ const v = validateAgentMd({ frontmatter: parsed.frontmatter, body: parsed.body, slug, known });
429
+ return { ...v, frontmatter: parsed.frontmatter, body: parsed.body };
430
+ }
431
+
432
+ /**
433
+ * Strip the registry-owned provenance keys from a frontmatter object.
434
+ * Called before PUBLISHING a local file back to hq: provenance describes where a
435
+ * copy came from, so shipping it upstream would make the next fetch claim to have
436
+ * come from itself.
437
+ * @param {object} fm
438
+ * @returns {object}
439
+ */
440
+ export function stripProvenance(fm) {
441
+ const out = { ...(fm && typeof fm === "object" ? fm : {}) };
442
+ for (const k of PROVENANCE_KEYS) delete out[k];
443
+ return out;
444
+ }
445
+
446
+ /**
447
+ * Stamp provenance onto a body + frontmatter and emit the full agent.md text.
448
+ *
449
+ * Tokens are NEVER rendered here (§3): `{{agent.*}}` survives to disk and is
450
+ * rendered at LOAD time by lib/render.mjs. Rendering at materialisation would
451
+ * bake one agent's identity into a file the registry then treats as the shared
452
+ * definition — the exact identity-leak class lib/render.mjs exists to close.
453
+ *
454
+ * @param {{frontmatter:object, body:string, layer:string, source?:string, version?:number|string, contentHash?:string}} o
455
+ * @returns {string} the full agent.md text ready to write
456
+ */
457
+ export function stampProvenance(o = {}) {
458
+ const fm = stripProvenance(o.frontmatter);
459
+ fm.layer = String(o.layer || "sdk");
460
+ if (o.source) fm.source = String(o.source);
461
+ if (o.version !== undefined && o.version !== null && o.version !== "") fm.version = String(o.version);
462
+ const hash = o.contentHash || hashBody(o.body);
463
+ if (hash) fm.contentHash = String(hash);
464
+ return stringifyAgentMd(fm, o.body);
465
+ }
466
+
467
+ export { KNOWN_TOKENS };