@cohortapp/agent-sdk 2.3.2 → 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 (148) hide show
  1. package/framework-features.json +30 -0
  2. package/lib/backlog.mjs +136 -0
  3. package/lib/cadences.mjs +63 -2
  4. package/lib/cadences.test.mjs +105 -0
  5. package/lib/capability/inventory.mjs +542 -0
  6. package/lib/capability/inventory.test.mjs +232 -0
  7. package/lib/capability/probe.mjs +255 -0
  8. package/lib/channels/contract.mjs +37 -1
  9. package/lib/channels/contract.test.mjs +25 -1
  10. package/lib/claude-bin.mjs +37 -3
  11. package/lib/claude-bin.test.mjs +42 -8
  12. package/lib/execution/disposition.mjs +501 -0
  13. package/lib/execution/disposition.test.mjs +482 -0
  14. package/lib/execution/drive.mjs +352 -0
  15. package/lib/execution/drive.test.mjs +270 -0
  16. package/lib/execution/effects.mjs +340 -0
  17. package/lib/execution/effects.test.mjs +193 -0
  18. package/lib/execution/index.mjs +152 -0
  19. package/lib/execution/intake.mjs +581 -0
  20. package/lib/execution/intake.test.mjs +343 -0
  21. package/lib/execution/journal.mjs +374 -0
  22. package/lib/execution/journal.test.mjs +261 -0
  23. package/lib/execution/match.mjs +331 -0
  24. package/lib/execution/match.test.mjs +235 -0
  25. package/lib/execution/pipeline.mjs +341 -0
  26. package/lib/execution/pipeline.test.mjs +389 -0
  27. package/lib/execution/route.mjs +332 -0
  28. package/lib/execution/route.test.mjs +186 -0
  29. package/lib/execution/surface-policy.mjs +446 -0
  30. package/lib/execution/surface-policy.test.mjs +162 -0
  31. package/lib/goals/admission.mjs +209 -0
  32. package/lib/goals/admission.test.mjs +139 -0
  33. package/lib/goals/classify.mjs +206 -0
  34. package/lib/goals/classify.test.mjs +109 -0
  35. package/lib/goals/collaborate.mjs +415 -0
  36. package/lib/goals/collaborate.test.mjs +324 -0
  37. package/lib/goals/gaps.mjs +111 -0
  38. package/lib/goals/gaps.test.mjs +284 -0
  39. package/lib/goals/loop.mjs +537 -0
  40. package/lib/goals/loop.test.mjs +719 -0
  41. package/lib/identity/persona.mjs +247 -0
  42. package/lib/identity/persona.test.mjs +117 -0
  43. package/lib/kpi.mjs +469 -0
  44. package/lib/kpi.test.mjs +244 -0
  45. package/lib/mandate/audit.mjs +168 -0
  46. package/lib/mandate/audit.test.mjs +195 -0
  47. package/lib/mandate/cache.mjs +162 -0
  48. package/lib/mandate/derive.mjs +317 -0
  49. package/lib/mandate/derive.test.mjs +224 -0
  50. package/lib/mandate/model.mjs +352 -0
  51. package/lib/mandate/model.test.mjs +145 -0
  52. package/lib/mandate/refresh.mjs +187 -0
  53. package/lib/mandate/refresh.test.mjs +293 -0
  54. package/lib/mcp/server.test.mjs +4 -4
  55. package/lib/org/approvals.mjs +14 -2
  56. package/lib/org/client.mjs +58 -22
  57. package/lib/org/client.test.mjs +3 -1
  58. package/lib/org/inbound/directedness.mjs +720 -0
  59. package/lib/org/inbound/directedness.test.mjs +543 -0
  60. package/lib/org/inbound/facts.mjs +501 -0
  61. package/lib/org/inbound/facts.test.mjs +375 -0
  62. package/lib/org/inbound/hydrate.mjs +535 -0
  63. package/lib/org/inbound/hydrate.test.mjs +326 -0
  64. package/lib/org/inbound/index.mjs +233 -0
  65. package/lib/org/inbound/index.test.mjs +324 -0
  66. package/lib/org/inbound/io.mjs +141 -0
  67. package/lib/org/inbound/project.mjs +201 -0
  68. package/lib/org/inbound/project.test.mjs +287 -0
  69. package/lib/org/inbound/surfaces.mjs +257 -0
  70. package/lib/org/knowledge.mjs +10 -1
  71. package/lib/org/knowledge.test.mjs +8 -1
  72. package/lib/org/leases.mjs +5 -0
  73. package/lib/org/mesh.mjs +17 -2
  74. package/lib/org/messaging.mjs +40 -4
  75. package/lib/org/messaging.test.mjs +40 -0
  76. package/lib/org/param-contract.mjs +694 -0
  77. package/lib/org/param-contract.test.mjs +451 -0
  78. package/lib/org/protocol.checksum +1 -1
  79. package/lib/org/protocol.mjs +8 -0
  80. package/lib/org/protocol.test.mjs +5 -1
  81. package/lib/org/push.mjs +1025 -0
  82. package/lib/org/push.test.mjs +690 -0
  83. package/lib/org/tool-surface.mjs +138 -38
  84. package/lib/org/tool-surface.test.mjs +13 -8
  85. package/lib/org/typing.mjs +341 -0
  86. package/lib/org/typing.test.mjs +291 -0
  87. package/lib/plan/compile.mjs +510 -0
  88. package/lib/plan/compile.test.mjs +286 -0
  89. package/lib/plan/emit.mjs +256 -0
  90. package/lib/plan/emit.test.mjs +246 -0
  91. package/lib/plan/explain.mjs +226 -0
  92. package/lib/plan/explain.test.mjs +188 -0
  93. package/lib/plan/schema.mjs +140 -0
  94. package/lib/resource-governor.mjs +47 -1
  95. package/lib/resource-governor.test.mjs +21 -1
  96. package/lib/setup/enroll-from-cohort.mjs +84 -16
  97. package/lib/setup/enroll-from-cohort.test.mjs +43 -1
  98. package/lib/setup/sections/identity.mjs +15 -4
  99. package/lib/setup/sections/identity.test.mjs +94 -0
  100. package/lib/setup/sections/inventory.mjs +178 -0
  101. package/lib/setup/sections/inventory.test.mjs +198 -0
  102. package/lib/setup/sections/mandate.mjs +392 -0
  103. package/lib/setup/sections/mandate.test.mjs +373 -0
  104. package/lib/setup/sections/subagents.mjs +427 -0
  105. package/lib/setup/sections/subagents.test.mjs +429 -0
  106. package/lib/setup/sections/verify.mjs +121 -0
  107. package/lib/setup/sections/verify.test.mjs +175 -0
  108. package/lib/setup/sot.mjs +2 -0
  109. package/lib/subagents/cli.mjs +463 -0
  110. package/lib/subagents/cli.test.mjs +389 -0
  111. package/lib/subagents/client.mjs +373 -0
  112. package/lib/subagents/client.test.mjs +309 -0
  113. package/lib/subagents/gap.mjs +268 -0
  114. package/lib/subagents/gap.test.mjs +234 -0
  115. package/lib/subagents/lock.mjs +296 -0
  116. package/lib/subagents/lock.test.mjs +248 -0
  117. package/lib/subagents/manifest.mjs +224 -0
  118. package/lib/subagents/manifest.test.mjs +175 -0
  119. package/lib/subagents/refs.mjs +274 -0
  120. package/lib/subagents/refs.test.mjs +204 -0
  121. package/lib/subagents/resolve.mjs +455 -0
  122. package/lib/subagents/resolve.test.mjs +422 -0
  123. package/lib/subagents/schema.mjs +467 -0
  124. package/lib/subagents/schema.test.mjs +306 -0
  125. package/package.json +8 -3
  126. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  127. package/policies/ai-disclosure.yaml +42 -2
  128. package/scaffold/CLAUDE.md +16 -2
  129. package/schedules/triggers/goal-steward.md +79 -0
  130. package/scripts/ci/conformance-org-api.mjs +792 -0
  131. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  132. package/scripts/daemon/agent-daemon.mjs +36 -4
  133. package/scripts/daemon/cadence-handlers.mjs +145 -1
  134. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  135. package/scripts/daemon/inbox-deferral.mjs +45 -2
  136. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  137. package/scripts/daemon/inbox-wake.mjs +282 -0
  138. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  139. package/scripts/daemon/prompt-builder.mjs +41 -1
  140. package/scripts/daemon/typing-registry.mjs +55 -2
  141. package/scripts/daemon/typing-registry.test.mjs +25 -0
  142. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  143. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  144. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  145. package/scripts/setup/generate-plan.mjs +108 -0
  146. package/scripts/setup/init-capability-manifest.mjs +70 -0
  147. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  148. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -0,0 +1,455 @@
1
+ /**
2
+ * lib/subagents/resolve.mjs — three-layer, total, per-slug resolution (§3).
3
+ *
4
+ * THE MODEL IN ONE SENTENCE: for every slug, exactly one layer wins and layers
5
+ * NEVER merge — LOCAL > WORKSPACE > SDK — and the winner is written to
6
+ * `agents/<slug>/agent.md` with its provenance stamped into the frontmatter.
7
+ *
8
+ * WHY NO MERGING. The obvious alternative (section-level `mode:"extend"` over an
9
+ * informal `## Mandate / ## Responsibilities / …` skeleton) is brittle exactly
10
+ * where it matters: the shipped agents and the generated ones already disagree
11
+ * about that skeleton, so a heading rename upstream silently drops an operator's
12
+ * override. Total precedence is legible — you can always answer "why does this
13
+ * file say that" with one word. "Extend" is spelled `subagent.fork`: copy the
14
+ * body, own it.
15
+ *
16
+ * WHY RESOLUTION IS LOCAL-FIRST AND NEVER NETWORKS. `detect` and `verify` run
17
+ * under `--headless`, in `doctor`, and in CI. The lock + `agents/` + the SDK
18
+ * manifest are sufficient for all three layers (layer 1 via the offline cache),
19
+ * so a dead hq degrades the roster, it never blocks the agent. hq is not on the
20
+ * runtime hot path at all.
21
+ *
22
+ * THE LOCAL RULE, SPELLED OUT. A file is a LOCAL EDIT when it exists and either:
23
+ * (a) the lock has an entry for the slug and sha256(file) ≠ the fileHash we
24
+ * recorded when we wrote it — i.e. something changed it after us; or
25
+ * (b) the lock has NO entry and sha256(file) matches no sha the SDK manifest
26
+ * has ever shipped — i.e. it did not come from us and it did not come from
27
+ * the package, so a human wrote it.
28
+ * Everything else is MANAGED and may be silently re-materialised. That is the
29
+ * whole reason `create`'s copy list and `UPGRADE_PATHS` can stay untouched.
30
+ *
31
+ * Note (a) and (b) also give the safe default in every degraded case: no lock and
32
+ * no manifest ⇒ everything reads as a local edit ⇒ nothing is overwritten.
33
+ *
34
+ * Node builtins only. ESM.
35
+ *
36
+ * @module lib/subagents/resolve
37
+ */
38
+
39
+ "use strict";
40
+
41
+ import { existsSync, readFileSync } from "node:fs";
42
+ import { join } from "node:path";
43
+ import { writeFileAtomic } from "../fs-atomic.mjs";
44
+ import { listAgentDirs, manifestShas, readManifest, readSdkAgent } from "./manifest.mjs";
45
+ import { latestCached, readLock } from "./lock.mjs";
46
+ import {
47
+ KNOWN_TOKENS,
48
+ hashBody,
49
+ hashFile,
50
+ parseAgentMd,
51
+ stampProvenance,
52
+ unsupportedTokens,
53
+ validateAgentMd,
54
+ } from "./schema.mjs";
55
+
56
+ /** Layer names, in precedence order (first wins). */
57
+ export const LAYERS = Object.freeze(["local", "workspace", "sdk"]);
58
+
59
+ /** @param {string} agentRoot @param {string} slug @returns {string} */
60
+ export function agentFilePath(agentRoot, slug) {
61
+ return join(agentRoot, "agents", String(slug), "agent.md");
62
+ }
63
+
64
+ /**
65
+ * Read every on-disk agent.md in the agent repo.
66
+ * @param {string} agentRoot
67
+ * @returns {{files:Map<string,{slug:string, path:string, text:string, fileHash:string}>, empty:string[]}}
68
+ */
69
+ export function readLocalAgents(agentRoot) {
70
+ const { slugs, empty } = listAgentDirs(agentRoot);
71
+ const files = new Map();
72
+ for (const slug of slugs) {
73
+ const p = agentFilePath(agentRoot, slug);
74
+ let text;
75
+ try { text = readFileSync(p, "utf8"); } catch { continue; }
76
+ files.set(slug, { slug, path: p, text, fileHash: hashFile(text) });
77
+ }
78
+ return { files, empty };
79
+ }
80
+
81
+ /**
82
+ * Classify an on-disk file as a local edit or a managed copy.
83
+ * @param {{fileHash:string, lockEntry?:object, shas:Set<string>}} o
84
+ * @returns {{localEdits:boolean, reason:string}}
85
+ */
86
+ export function classifyLocal(o = {}) {
87
+ const { fileHash, lockEntry, shas } = o;
88
+
89
+ // `fileHash` in the lock means "bytes WE materialised". It is only ever
90
+ // written after a successful write, so a match is proof the file is managed.
91
+ if (lockEntry && lockEntry.fileHash) {
92
+ if (lockEntry.fileHash === fileHash) return { localEdits: false, reason: "matches lock fileHash" };
93
+ return { localEdits: true, reason: "differs from the bytes recorded in the lock" };
94
+ }
95
+
96
+ // A file that has been restored to canonical bytes is managed again — checked
97
+ // BEFORE the sticky flag so reverting an edit re-adopts the file rather than
98
+ // marking it dirty forever.
99
+ if (shas && shas.has(fileHash)) return { localEdits: false, reason: "matches an SDK manifest sha" };
100
+
101
+ // STICKY: a previously-recorded local edit stays a local edit.
102
+ //
103
+ // Without this, a refusal was protective for exactly one run and destructive
104
+ // on the next: `materialise` correctly refused to overwrite a human's file,
105
+ // but the caller then recorded that file's OWN hash as `fileHash` — i.e. as
106
+ // bytes we had written — so the next run matched it, classified the file as
107
+ // managed, and overwrote the human's work with no `--force` and no warning.
108
+ // Reproduced end to end: run 1 refuses, run 2 silently destroys the edit.
109
+ //
110
+ // The flag is now authoritative and the hash of an unmanaged file is never
111
+ // recorded as ours (see the persist site in `materialiseAll`).
112
+ if (lockEntry && lockEntry.localEdits) {
113
+ return { localEdits: true, reason: "lock records an unresolved local edit" };
114
+ }
115
+
116
+ return { localEdits: true, reason: lockEntry ? "lock entry carries no fileHash" : "no lock entry and no SDK sha match" };
117
+ }
118
+
119
+ /**
120
+ * Build the WORKSPACE layer's per-slug body source from a `subagent.resolve`
121
+ * payload and/or the offline cache.
122
+ *
123
+ * `pins` is whatever the client last got from hq — either live or from the
124
+ * cache. Each entry is `{slug, definitionId, version, contentHash, frontmatter,
125
+ * body}`. When an entry carries no body (e.g. it came from the cheap
126
+ * `subagent.roster` read), we look for the body in the local cache; if it is not
127
+ * there either, the slug simply has no workspace layer this run and resolution
128
+ * falls through to SDK. That is the degraded-but-correct behaviour §4 demands.
129
+ *
130
+ * @param {{agentRoot:string, pins?:Array<object>}} o
131
+ * @returns {Map<string, {slug:string, definitionId:string|null, version:number|null, contentHash:string, body:string, frontmatter:object, fromCache:boolean}>}
132
+ */
133
+ export function buildWorkspaceLayer(o = {}) {
134
+ const out = new Map();
135
+ for (const p of Array.isArray(o.pins) ? o.pins : []) {
136
+ if (!p || !p.slug) continue;
137
+ let body = typeof p.body === "string" ? p.body : "";
138
+ let frontmatter = p.frontmatter && typeof p.frontmatter === "object" ? { ...p.frontmatter } : null;
139
+ let fromCache = false;
140
+ if (!body && o.agentRoot) {
141
+ const cached = latestCached(o.agentRoot, p.slug);
142
+ if (cached) {
143
+ const parsed = parseAgentMd(cached.text);
144
+ body = parsed.body;
145
+ if (!frontmatter) frontmatter = parsed.frontmatter;
146
+ fromCache = true;
147
+ }
148
+ }
149
+ if (!body) continue;
150
+ out.set(String(p.slug), {
151
+ slug: String(p.slug),
152
+ definitionId: p.definitionId ? String(p.definitionId) : null,
153
+ version: Number.isFinite(p.version) ? p.version : null,
154
+ contentHash: p.contentHash ? String(p.contentHash) : hashBody(body),
155
+ body,
156
+ frontmatter: frontmatter || {},
157
+ fromCache,
158
+ });
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * Resolve every slug in `(SDK manifest) ∪ (workspace pins) ∪ (on-disk agents/*)`.
165
+ *
166
+ * Pure over its injected inputs (the only I/O is reading files). Never networks.
167
+ *
168
+ * @param {object} o
169
+ * @param {string} o.agentRoot
170
+ * @param {string} o.maestroRoot
171
+ * @param {Array<object>} [o.pins] the workspace layer (from client/cache)
172
+ * @param {object} [o.manifest] injectable (defaults to reading the SDK manifest)
173
+ * @param {object} [o.lock] injectable (defaults to reading the lock)
174
+ * @param {boolean} [o.force] DEMOTE the local layer: a locally-edited file is
175
+ * still REPORTED in `localEdits`, but it no longer wins, so `pull --force`
176
+ * actually re-resolves to workspace/SDK. Without this the local layer absorbs
177
+ * the slug before precedence is ever consulted and `--force` would only ever
178
+ * rewrite a file from itself. `--force` is the ONLY thing that does this.
179
+ * @returns {{entries:Array<object>, bySlug:Map<string,object>, localEdits:string[], unresolved:string[], emptyDirs:string[], degraded:string[]}}
180
+ */
181
+ export function resolveSubagents(o = {}) {
182
+ const agentRoot = o.agentRoot;
183
+ const maestroRoot = o.maestroRoot;
184
+ const manifest = o.manifest || readManifest(maestroRoot);
185
+ const lock = o.lock || readLock(agentRoot);
186
+ const shas = manifestShas(manifest);
187
+ const { files, empty } = readLocalAgents(agentRoot);
188
+ const workspace = buildWorkspaceLayer({ agentRoot, pins: o.pins });
189
+
190
+ const slugs = new Set([...Object.keys(manifest.agents || {}), ...workspace.keys(), ...files.keys()]);
191
+ const entries = [];
192
+ const localEdits = [];
193
+ const unresolved = [];
194
+ const degraded = [];
195
+ if (workspace.size && [...workspace.values()].some((w) => w.fromCache)) degraded.push("workspace-from-cache");
196
+
197
+ for (const slug of [...slugs].sort()) {
198
+ const file = files.get(slug) || null;
199
+ const lockEntry = lock.agents ? lock.agents[slug] : null;
200
+
201
+ // ── Layer 2: LOCAL ────────────────────────────────────────────────────
202
+ // `localEdited` is computed once and reused by the fallback branch below, so
203
+ // an overridden (--force) file is still reported as edited even though it
204
+ // lost. Classification and precedence are deliberately separate questions.
205
+ let localEdited = false;
206
+ if (file) {
207
+ const cls = classifyLocal({ fileHash: file.fileHash, lockEntry, shas });
208
+ localEdited = cls.localEdits;
209
+ if (cls.localEdits) localEdits.push(slug);
210
+ if (cls.localEdits && !o.force) {
211
+ const parsed = parseAgentMd(file.text);
212
+ entries.push({
213
+ slug,
214
+ layer: "local",
215
+ source: `file://${file.path}`,
216
+ version: null,
217
+ definitionId: null,
218
+ contentHash: hashBody(parsed.body),
219
+ fileHash: file.fileHash,
220
+ localEdits: true,
221
+ reason: cls.reason,
222
+ frontmatter: parsed.frontmatter,
223
+ body: parsed.body,
224
+ path: file.path,
225
+ onDisk: true,
226
+ });
227
+ continue;
228
+ }
229
+ }
230
+
231
+ // ── Layer 1: WORKSPACE ────────────────────────────────────────────────
232
+ const w = workspace.get(slug);
233
+ if (w) {
234
+ entries.push({
235
+ slug,
236
+ layer: "workspace",
237
+ source: w.definitionId ? `cohort://${o.orgSlug || "workspace"}/${slug}` : `cohort://workspace/${slug}`,
238
+ version: w.version,
239
+ definitionId: w.definitionId,
240
+ contentHash: w.contentHash,
241
+ fileHash: file ? file.fileHash : "",
242
+ localEdits: false,
243
+ reason: w.fromCache ? "workspace pin (offline cache)" : "workspace pin",
244
+ frontmatter: w.frontmatter,
245
+ body: w.body,
246
+ path: agentFilePath(agentRoot, slug),
247
+ onDisk: !!file,
248
+ fromCache: w.fromCache,
249
+ });
250
+ continue;
251
+ }
252
+
253
+ // ── Layer 0: SDK ──────────────────────────────────────────────────────
254
+ if (manifest.agents && manifest.agents[slug]) {
255
+ const sdk = readSdkAgent(maestroRoot, slug);
256
+ if (sdk) {
257
+ entries.push({
258
+ slug,
259
+ layer: "sdk",
260
+ source: `sdk://${manifest.sdkVersion || "unknown"}/${slug}`,
261
+ version: manifest.sdkVersion || null,
262
+ definitionId: null,
263
+ contentHash: hashBody(sdk.body),
264
+ fileHash: file ? file.fileHash : "",
265
+ localEdits: false,
266
+ reason: "shipped in the SDK manifest",
267
+ frontmatter: sdk.frontmatter,
268
+ body: sdk.body,
269
+ path: agentFilePath(agentRoot, slug),
270
+ onDisk: !!file,
271
+ });
272
+ continue;
273
+ }
274
+ }
275
+
276
+ // A managed on-disk file whose layer has disappeared (yanked pin, dropped
277
+ // from the manifest). It still RESOLVES — the bytes are right there — but it
278
+ // is orphaned, and saying so is more useful than pretending it is SDK.
279
+ // Under --force a locally-edited file also lands here when no higher layer
280
+ // offers the slug: there is nothing to override it WITH, so it keeps the
281
+ // file and stays flagged as an edit rather than being silently blanked.
282
+ if (file) {
283
+ const parsed = parseAgentMd(file.text);
284
+ entries.push({
285
+ slug,
286
+ layer: "local",
287
+ source: `file://${file.path}`,
288
+ version: lockEntry ? lockEntry.version : null,
289
+ definitionId: lockEntry ? lockEntry.definitionId : null,
290
+ contentHash: hashBody(parsed.body),
291
+ fileHash: file.fileHash,
292
+ localEdits: localEdited,
293
+ orphaned: !localEdited,
294
+ reason: localEdited
295
+ ? "locally edited, and no other layer offers this slug"
296
+ : "managed file whose source layer no longer offers this slug",
297
+ frontmatter: parsed.frontmatter,
298
+ body: parsed.body,
299
+ path: file.path,
300
+ onDisk: true,
301
+ });
302
+ continue;
303
+ }
304
+
305
+ unresolved.push(slug);
306
+ }
307
+
308
+ const bySlug = new Map(entries.map((e) => [e.slug, e]));
309
+ return { entries, bySlug, localEdits, unresolved, emptyDirs: empty, degraded };
310
+ }
311
+
312
+ /**
313
+ * Render a resolved entry to the EXACT bytes that belong on disk.
314
+ *
315
+ * This is deliberately one function shared by `materialise` and by
316
+ * `subagents diff`. When `diff` re-derived the bytes itself it skipped
317
+ * validation — so it compared the raw layer frontmatter (`model: sonnet`, no
318
+ * `tokens:`) against the normalised bytes materialise writes (`model:
319
+ * claude-sonnet-4-6`, `tokens: []`) and reported EVERY managed file as differing
320
+ * from the layer it had just been written from. One renderer, one answer.
321
+ *
322
+ * Refusals, in order, and why each one exists:
323
+ * 1. Body fails the schema — the registry never puts prose on disk it could not
324
+ * validate; a bad publish upstream must not become a bad file downstream.
325
+ * 2. Declared tokens the local renderer does not know — hq does not vendor
326
+ * KNOWN_TOKENS, so the fetching agent is the only place this can be caught.
327
+ * Refusing here makes `scripts/ci/check-unresolved-tokens.mjs` structurally
328
+ * unable to fail after a fetch.
329
+ *
330
+ * @param {object} entry a resolveSubagents entry
331
+ * @param {{known?:Set<string>}} [o]
332
+ * @returns {{ok:boolean, text:string, reason:string, errors?:string[]}}
333
+ */
334
+ export function renderEntry(entry, o = {}) {
335
+ const known = o.known || KNOWN_TOKENS;
336
+ const check = validateAgentMd({ frontmatter: entry.frontmatter, body: entry.body, slug: entry.slug, known });
337
+ if (!check.ok) return { ok: false, text: "", reason: "failed schema validation", errors: check.errors };
338
+
339
+ const bad = unsupportedTokens(check.normalised.frontmatter.tokens, known);
340
+ if (bad.length) {
341
+ return { ok: false, text: "", reason: `declares token(s) this agent cannot render: ${bad.join(", ")}`, errors: bad };
342
+ }
343
+
344
+ return {
345
+ ok: true,
346
+ reason: "",
347
+ text: stampProvenance({
348
+ frontmatter: check.normalised.frontmatter,
349
+ body: entry.body,
350
+ layer: entry.layer,
351
+ source: entry.source,
352
+ version: entry.version,
353
+ contentHash: entry.contentHash,
354
+ }),
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Materialise a resolved entry to `agents/<slug>/agent.md`.
360
+ *
361
+ * The first refusal is precedence, the rest are {@link renderEntry}'s:
362
+ * 1. LOCAL without `--force` — the file IS the winner; rewriting it would be
363
+ * the clobber lib/setup/enroll-from-cohort.mjs:1-31 exists to forbid.
364
+ *
365
+ * Tokens are NOT rendered: `{{agent.*}}` survives to disk and lib/render.mjs
366
+ * resolves it at load, which is what keeps one shipped body valid for every agent.
367
+ *
368
+ * @param {{agentRoot:string, entry:object, force?:boolean, known?:Set<string>, orgSlug?:string}} o
369
+ * @returns {{written:boolean, path:string, reason:string, fileHash?:string, errors?:string[]}}
370
+ */
371
+ export function materialise(o = {}) {
372
+ const { agentRoot, entry } = o;
373
+ const path = agentFilePath(agentRoot, entry.slug);
374
+
375
+ if (entry.layer === "local" && !o.force) {
376
+ return { written: false, path, reason: entry.orphaned ? "orphaned local file left untouched" : "local edit wins (use --force to overwrite)" };
377
+ }
378
+
379
+ const rendered = renderEntry(entry, { known: o.known });
380
+ if (!rendered.ok) return { written: false, path, reason: rendered.reason, errors: rendered.errors };
381
+ const text = rendered.text;
382
+
383
+ // Idempotence: if the bytes already on disk are byte-identical, do not write.
384
+ // Not just an optimisation — a no-op write would churn mtime on every `doctor`
385
+ // run and make "when did this last change" useless.
386
+ if (existsSync(path)) {
387
+ try {
388
+ if (readFileSync(path, "utf8") === text) {
389
+ return { written: false, path, reason: "already up to date", fileHash: hashFile(text) };
390
+ }
391
+ } catch { /* fall through and write */ }
392
+ }
393
+
394
+ try {
395
+ writeFileAtomic(path, text);
396
+ } catch (e) {
397
+ return { written: false, path, reason: `write failed: ${e && e.message ? e.message : e}` };
398
+ }
399
+ return { written: true, path, reason: `materialised from ${entry.layer}`, fileHash: hashFile(text) };
400
+ }
401
+
402
+ /**
403
+ * Materialise every resolved entry and return the lock patch describing what
404
+ * ended up on disk. The caller writes the lock (so a caller can dry-run).
405
+ *
406
+ * @param {{agentRoot:string, resolved:object, force?:boolean, known?:Set<string>, only?:string[]}} o
407
+ * @returns {{results:Array<object>, lockAgents:Record<string,object>, written:number, refused:Array<object>}}
408
+ */
409
+ export function materialiseAll(o = {}) {
410
+ const results = [];
411
+ const lockAgents = {};
412
+ const refused = [];
413
+ let written = 0;
414
+ const only = Array.isArray(o.only) && o.only.length ? new Set(o.only) : null;
415
+
416
+ for (const entry of (o.resolved && o.resolved.entries) || []) {
417
+ if (only && !only.has(entry.slug)) continue;
418
+ const r = materialise({ agentRoot: o.agentRoot, entry, force: o.force, known: o.known });
419
+ results.push({ slug: entry.slug, layer: entry.layer, ...r });
420
+ if (r.written) written++;
421
+ if (!r.written && r.errors) refused.push({ slug: entry.slug, reason: r.reason, errors: r.errors });
422
+
423
+ // `fileHash` means ONE thing: the bytes WE materialised. It is the proof
424
+ // `classifyLocal` uses to call a file managed, so recording anything else
425
+ // there is a licence to overwrite.
426
+ //
427
+ // It previously fell back to `entry.fileHash` — the hash of whatever was on
428
+ // disk — including on a REFUSAL. So refusing to overwrite a human's file
429
+ // recorded that human's bytes as ours, and the next run overwrote them with
430
+ // no `--force` and no warning. The refusal protected the file for exactly
431
+ // one run and then destroyed it.
432
+ //
433
+ // Now: only a real write (or a confirmed byte-identical no-op) records a
434
+ // fileHash. A refusal records none, and `localEdits` carries the state
435
+ // forward instead.
436
+ const materialised = r.written || r.reason === "already up to date";
437
+ const fileHash = materialised ? r.fileHash || "" : "";
438
+ lockAgents[entry.slug] = {
439
+ layer: entry.layer,
440
+ definitionId: entry.definitionId || null,
441
+ version: entry.version === undefined ? null : entry.version,
442
+ contentHash: entry.contentHash || "",
443
+ fileHash,
444
+ source: entry.source || "",
445
+ // A successful materialise RESOLVES the edit (we just replaced the file
446
+ // with canonical bytes, by force or because nothing was protecting it).
447
+ // A refusal leaves it unresolved, and `classifyLocal` now treats that as
448
+ // authoritative on the next run.
449
+ localEdits: materialised ? false : !!entry.localEdits,
450
+ materialisedAt: r.written ? new Date().toISOString() : (r.reason === "already up to date" ? new Date().toISOString() : ""),
451
+ };
452
+ }
453
+
454
+ return { results, lockAgents, written, refused };
455
+ }