@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,248 @@
1
+ /**
2
+ * lock.test.mjs — the registry's on-disk state (§3, §4): the lock (provenance),
3
+ * the cache (the offline fourth layer), the outbox (offline writes) and the
4
+ * headless gaps report.
5
+ * Run: node --test lib/subagents/lock.test.mjs
6
+ */
7
+ "use strict";
8
+
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+
15
+ import {
16
+ CACHE_REL,
17
+ GAPS_REL,
18
+ LOCK_REL,
19
+ OUTBOX_REL,
20
+ acceptDrop,
21
+ cacheDir,
22
+ cachePath,
23
+ emptyLock,
24
+ gapsPath,
25
+ isAcceptedDrop,
26
+ latestCached,
27
+ listOutbox,
28
+ lockPath,
29
+ mergeLock,
30
+ outboxDir,
31
+ queueOutbox,
32
+ readCache,
33
+ readGaps,
34
+ readLock,
35
+ removeOutbox,
36
+ writeCache,
37
+ writeGaps,
38
+ writeLock,
39
+ } from "./lock.mjs";
40
+
41
+ const root = () => mkdtempSync(join(tmpdir(), "subagent-lock-"));
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // lock
45
+ // ---------------------------------------------------------------------------
46
+
47
+ test("paths: the lock is provenance (.maestro/), the rest is scratch (state/)", () => {
48
+ // Blowing away state/ must DEGRADE the registry (cold cache) but never lose
49
+ // provenance — that split is the whole reason for two directories.
50
+ assert.equal(LOCK_REL, join(".maestro", "subagents.lock.json"));
51
+ assert.equal(CACHE_REL, join("state", "subagents", "cache"));
52
+ assert.equal(OUTBOX_REL, join("state", "subagents", "outbox"));
53
+ assert.equal(GAPS_REL, join("state", "subagents", "gaps.json"));
54
+ const r = root();
55
+ assert.equal(lockPath(r), join(r, LOCK_REL));
56
+ assert.equal(cacheDir(r), join(r, CACHE_REL));
57
+ assert.equal(outboxDir(r), join(r, OUTBOX_REL));
58
+ assert.equal(gapsPath(r), join(r, GAPS_REL));
59
+ });
60
+
61
+ test("readLock: missing OR corrupt → the empty-but-valid shape, never null", () => {
62
+ const r = root();
63
+ assert.deepEqual(readLock(r), emptyLock());
64
+ mkdirSync(join(r, ".maestro"), { recursive: true });
65
+ writeFileSync(lockPath(r), "{ not json");
66
+ assert.deepEqual(readLock(r), emptyLock());
67
+ writeFileSync(lockPath(r), '"a string"');
68
+ assert.deepEqual(readLock(r), emptyLock());
69
+ });
70
+
71
+ test("readLock: coerces every field so a hand-edited lock cannot crash a consumer", () => {
72
+ const r = root();
73
+ mkdirSync(join(r, ".maestro"), { recursive: true });
74
+ writeFileSync(lockPath(r), JSON.stringify({
75
+ version: "nope",
76
+ rosterVersion: "7",
77
+ degraded: "workspace",
78
+ acceptedDrops: [{ ref: "x" }, "junk", null],
79
+ agents: { alpha: { layer: 3, version: "9", contentHash: 5, localEdits: "yes" }, bad: "not-an-object" },
80
+ }));
81
+ const lock = readLock(r);
82
+ assert.equal(lock.version, 1);
83
+ assert.equal(lock.rosterVersion, 0, "a string rosterVersion is not finite → 0");
84
+ assert.deepEqual(lock.degraded, []);
85
+ assert.deepEqual(lock.acceptedDrops, [{ ref: "x" }]);
86
+ assert.equal(lock.agents.alpha.layer, "3");
87
+ assert.equal(lock.agents.alpha.version, null);
88
+ assert.equal(lock.agents.alpha.contentHash, "5");
89
+ assert.equal(lock.agents.alpha.localEdits, true);
90
+ assert.equal("bad" in lock.agents, false);
91
+ });
92
+
93
+ test("writeLock / mergeLock: per-slug merge, null deletes, arrays replace", () => {
94
+ const r = root();
95
+ assert.equal(writeLock(r, { ...emptyLock(), agents: { alpha: { layer: "sdk", fileHash: "h1" } } }), true);
96
+
97
+ const merged = mergeLock(r, { agents: { alpha: { fileHash: "h2" }, beta: { layer: "workspace" } }, rosterVersion: 5 });
98
+ assert.equal(merged.agents.alpha.layer, "sdk", "existing keys survive the merge");
99
+ assert.equal(merged.agents.alpha.fileHash, "h2");
100
+ assert.equal(merged.agents.beta.layer, "workspace");
101
+ assert.equal(merged.rosterVersion, 5);
102
+ assert.deepEqual(readLock(r).agents.beta.layer, "workspace", "written to disk");
103
+
104
+ const dropped = mergeLock(r, { agents: { beta: null } });
105
+ assert.equal("beta" in dropped.agents, false);
106
+ assert.equal(dropped.rosterVersion, 5, "unsupplied fields are untouched");
107
+ });
108
+
109
+ test("mergeLock: degraded is deduped", () => {
110
+ const r = root();
111
+ const l = mergeLock(r, { degraded: ["workspace", "workspace", "assessment"] });
112
+ assert.deepEqual(l.degraded, ["workspace", "assessment"]);
113
+ });
114
+
115
+ test("writeLock: fail-open (returns false) rather than throwing", () => {
116
+ // A failed lock write degrades the NEXT run to "everything looks locally
117
+ // edited" — safe. It must not abort the current one, because apply() throws
118
+ // are fatal to the whole wizard.
119
+ const r = root();
120
+ writeFileSync(join(r, ".maestro"), "i am a file, not a directory");
121
+ assert.equal(writeLock(r, emptyLock()), false);
122
+ });
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // accepted drops — the section NEVER edits a workflow, it records the location
126
+ // ---------------------------------------------------------------------------
127
+
128
+ test("acceptDrop: records ref + file:line, dedupes on the same location", () => {
129
+ const r = root();
130
+ acceptDrop(r, { ref: "ghost", file: "workflows/a.yaml", line: 12, reason: "vestigial" });
131
+ acceptDrop(r, { ref: "ghost", file: "workflows/a.yaml", line: 12, reason: "vestigial again" });
132
+ acceptDrop(r, { ref: "ghost", file: "workflows/b.yaml", line: 3 });
133
+ const lock = readLock(r);
134
+ assert.equal(lock.acceptedDrops.length, 2);
135
+ assert.equal(lock.acceptedDrops[0].reason, "vestigial again");
136
+ assert.ok(lock.acceptedDrops[0].acceptedAt);
137
+ assert.equal(isAcceptedDrop(lock, "ghost"), true);
138
+ assert.equal(isAcceptedDrop(lock, "other"), false);
139
+ assert.equal(isAcceptedDrop(null, "ghost"), false);
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // cache — the offline fourth layer
144
+ // ---------------------------------------------------------------------------
145
+
146
+ test("writeCache / readCache: version lives in the filename so versions coexist", () => {
147
+ const r = root();
148
+ assert.equal(writeCache(r, "alpha", 1, "v1 body"), true);
149
+ assert.equal(writeCache(r, "alpha", 2, "v2 body"), true);
150
+ assert.equal(readCache(r, "alpha", 1), "v1 body");
151
+ assert.equal(readCache(r, "alpha", 2), "v2 body");
152
+ assert.equal(readCache(r, "alpha", 3), null);
153
+ assert.equal(cachePath(r, "alpha", 2).endsWith("alpha@2.md"), true);
154
+ assert.equal(cachePath(r, "alpha", null).endsWith("alpha@head.md"), true);
155
+ });
156
+
157
+ test("latestCached: highest numeric version wins; null when nothing is cached", () => {
158
+ const r = root();
159
+ assert.equal(latestCached(r, "alpha"), null);
160
+ writeCache(r, "alpha", 2, "two");
161
+ writeCache(r, "alpha", 10, "ten");
162
+ writeCache(r, "alpha", 3, "three");
163
+ writeCache(r, "beta", 1, "beta-one");
164
+ const best = latestCached(r, "alpha");
165
+ assert.equal(best.version, 10);
166
+ assert.equal(best.text, "ten");
167
+ assert.equal(latestCached(r, "gamma"), null, "prefix match must not leak across slugs");
168
+ });
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // outbox — offline writes
172
+ // ---------------------------------------------------------------------------
173
+
174
+ test("queueOutbox / listOutbox: chronological, payload stored VERBATIM", () => {
175
+ const r = root();
176
+ // Verbatim matters: at replay time the local file may have changed again, and
177
+ // replaying a different body than the operator approved is the worst outcome.
178
+ const p1 = queueOutbox(r, "subagent.create", { slug: "alpha", body: "b1" });
179
+ const p2 = queueOutbox(r, "subagent.pin", { slug: "alpha" });
180
+ assert.ok(p1 && p2);
181
+ const list = listOutbox(r);
182
+ assert.equal(list.length, 2);
183
+ assert.deepEqual(list.map((e) => e.op).sort(), ["subagent.create", "subagent.pin"]);
184
+ const create = list.find((e) => e.op === "subagent.create");
185
+ assert.deepEqual(create.payload, { slug: "alpha", body: "b1" });
186
+ assert.ok(create.queuedAt);
187
+ assert.equal(listOutbox(r).map((e) => e.path).every(existsSync), true);
188
+ });
189
+
190
+ test("queueOutbox: two writes of the SAME op in the same millisecond do not collide", () => {
191
+ // `create` immediately followed by `pin` queues twice inside one millisecond.
192
+ // A timestamp-only filename made the second overwrite the first and silently
193
+ // lost the operator's work — the exact failure the outbox exists to prevent.
194
+ const r = root();
195
+ for (let i = 0; i < 25; i++) queueOutbox(r, "subagent.pin", { slug: `s${i}` });
196
+ const list = listOutbox(r);
197
+ assert.equal(list.length, 25);
198
+ assert.deepEqual(list.map((e) => e.payload.slug), Array.from({ length: 25 }, (_, i) => `s${i}`), "and replay order is still chronological");
199
+ });
200
+
201
+ test("listOutbox: a corrupt entry is skipped, never fatal; missing dir → []", () => {
202
+ const r = root();
203
+ assert.deepEqual(listOutbox(r), []);
204
+ queueOutbox(r, "subagent.pin", { slug: "alpha" });
205
+ writeFileSync(join(outboxDir(r), "0000-corrupt.json"), "{ nope");
206
+ const list = listOutbox(r);
207
+ assert.equal(list.length, 1);
208
+ assert.equal(list[0].op, "subagent.pin");
209
+ });
210
+
211
+ test("removeOutbox: removes a replayed entry, fail-open on a missing path", () => {
212
+ const r = root();
213
+ const p = queueOutbox(r, "subagent.pin", {});
214
+ assert.equal(removeOutbox(p), true);
215
+ assert.deepEqual(listOutbox(r), []);
216
+ assert.equal(removeOutbox(p), false);
217
+ });
218
+
219
+ test("queueOutbox: sanitises the op so a hostile op name cannot escape the outbox", () => {
220
+ const r = root();
221
+ const p = queueOutbox(r, "subagent.pin/../../evil", {});
222
+ // The separators are stripped, so the file lands in the outbox directory
223
+ // itself — path traversal via the op name is not representable.
224
+ assert.equal(dirname(p), outboxDir(r));
225
+ assert.equal(listOutbox(r).length, 1);
226
+ });
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // gaps report
230
+ // ---------------------------------------------------------------------------
231
+
232
+ test("writeGaps / readGaps: stamps generatedAt and round trips", () => {
233
+ const r = root();
234
+ assert.equal(readGaps(r), null);
235
+ assert.equal(writeGaps(r, { summary: "1 missing", counts: { missing: 1 } }), true);
236
+ const g = readGaps(r);
237
+ assert.equal(g.summary, "1 missing");
238
+ assert.deepEqual(g.counts, { missing: 1 });
239
+ assert.ok(g.generatedAt);
240
+ assert.equal(JSON.parse(readFileSync(gapsPath(r), "utf8")).summary, "1 missing");
241
+ });
242
+
243
+ test("readGaps: fail-open on a corrupt report", () => {
244
+ const r = root();
245
+ mkdirSync(join(r, "state", "subagents"), { recursive: true });
246
+ writeFileSync(gapsPath(r), "{ nope");
247
+ assert.equal(readGaps(r), null);
248
+ });
@@ -0,0 +1,224 @@
1
+ /**
2
+ * lib/subagents/manifest.mjs — layer 0 (the SDK package) gets a manifest (§2.2).
3
+ *
4
+ * WHY. `agents/` is copied verbatim by `create` (bin/maestro.mjs:99-116) and is in
5
+ * `UPGRADE_PATHS` as a MERGE path — an upgrade adds new files but never touches
6
+ * one that already exists. That is the correct default (a human's edit is
7
+ * sacred) and it is also why layer-0 fixes never reached deployed agents: an
8
+ * upgraded agent.md is indistinguishable from an edited one.
9
+ *
10
+ * The manifest breaks that tie WITHOUT a breaking change. `agents/manifest.json`
11
+ * carries a sha256 per shipped agent; a file on disk whose hash equals a manifest
12
+ * sha is a pristine COPY (managed — safe to re-materialise) and anything else is
13
+ * a local edit (sacred — never overwritten without `--force`). This is the graft
14
+ * that lets `create`'s copy list and `UPGRADE_PATHS` stay exactly as they are —
15
+ * no migration, no breaking change, no re-scaffold of the deployed fleet.
16
+ *
17
+ * It also finally satisfies the phantom read at
18
+ * `scripts/poller/slack-cloud-relay-client.mjs:66`, which has been reading a
19
+ * never-generated `agents/index.json` since it was written.
20
+ *
21
+ * The manifest is CHECKED IN and regenerated by
22
+ * `scripts/setup/gen-subagent-manifest.mjs` at prepublish. `agents/` is already
23
+ * in package.json `files`, so it ships with the npm package with no packaging
24
+ * change.
25
+ *
26
+ * Fail-open everywhere: a missing/corrupt manifest degrades layer 0 to "no
27
+ * managed files", which makes every on-disk file look locally edited — the SAFE
28
+ * direction (we never clobber), never the destructive one.
29
+ *
30
+ * Node builtins only. ESM.
31
+ *
32
+ * @module lib/subagents/manifest
33
+ */
34
+
35
+ "use strict";
36
+
37
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
38
+ import { join } from "node:path";
39
+ import { writeJsonAtomic } from "../fs-atomic.mjs";
40
+ import { checkAgentFile, hashBody, hashFile, normaliseModel, parseAgentMd } from "./schema.mjs";
41
+
42
+ /** Path of the manifest relative to a maestro/SDK root. */
43
+ export const MANIFEST_REL = join("agents", "manifest.json");
44
+
45
+ /** @param {string} maestroRoot @returns {string} */
46
+ export function manifestPath(maestroRoot) {
47
+ return join(maestroRoot, MANIFEST_REL);
48
+ }
49
+
50
+ /** @param {string} maestroRoot @param {string} slug @returns {string} */
51
+ export function sdkAgentPath(maestroRoot, slug) {
52
+ return join(maestroRoot, "agents", String(slug), "agent.md");
53
+ }
54
+
55
+ /**
56
+ * Read the shipped manifest. Returns a normalised, always-shaped object so
57
+ * callers never null-check the inner maps. Fail-open → `{sdkVersion:"", agents:{}}`.
58
+ * @param {string} maestroRoot
59
+ * @returns {{sdkVersion:string, generatedAt?:string, agents:Record<string,object>}}
60
+ */
61
+ export function readManifest(maestroRoot) {
62
+ const p = manifestPath(maestroRoot);
63
+ if (!existsSync(p)) return { sdkVersion: "", agents: {} };
64
+ try {
65
+ const doc = JSON.parse(readFileSync(p, "utf8"));
66
+ const agents = doc && typeof doc.agents === "object" && doc.agents ? doc.agents : {};
67
+ return { sdkVersion: String((doc && doc.sdkVersion) || ""), generatedAt: doc && doc.generatedAt, agents };
68
+ } catch {
69
+ // A corrupt manifest means "layer 0 has no managed files" — every on-disk
70
+ // file then classifies as a local edit and is left alone. Degraded, safe.
71
+ return { sdkVersion: "", agents: {} };
72
+ }
73
+ }
74
+
75
+ /** Slugs the SDK ships. @param {object} manifest @returns {string[]} */
76
+ export function manifestSlugs(manifest) {
77
+ return Object.keys((manifest && manifest.agents) || {}).sort();
78
+ }
79
+
80
+ /**
81
+ * Every file-sha the SDK has EVER shipped for any agent, as one set.
82
+ *
83
+ * Deliberately flattened across slugs: classification asks "is this file
84
+ * something the SDK put here", and a file that was moved/renamed between slugs
85
+ * is still not a human edit. Keeping this a set (rather than a per-slug lookup)
86
+ * also lets a future manifest carry a `history:[]` of prior shas so an agent that
87
+ * skipped three releases still classifies its files as managed.
88
+ * @param {object} manifest
89
+ * @returns {Set<string>}
90
+ */
91
+ export function manifestShas(manifest) {
92
+ const out = new Set();
93
+ for (const entry of Object.values((manifest && manifest.agents) || {})) {
94
+ if (!entry || typeof entry !== "object") continue;
95
+ if (entry.sha256) out.add(String(entry.sha256));
96
+ for (const h of Array.isArray(entry.history) ? entry.history : []) if (h) out.add(String(h));
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Is this exact file text something the SDK shipped (i.e. NOT a local edit)?
103
+ * @param {object} manifest @param {string} fileText @returns {boolean}
104
+ */
105
+ export function isManagedByManifest(manifest, fileText) {
106
+ if (typeof fileText !== "string" || !fileText) return false;
107
+ return manifestShas(manifest).has(hashFile(fileText));
108
+ }
109
+
110
+ /**
111
+ * Read a shipped agent.md from the SDK root.
112
+ * @param {string} maestroRoot @param {string} slug
113
+ * @returns {{slug:string, path:string, text:string, fileHash:string, frontmatter:object, body:string}|null}
114
+ */
115
+ export function readSdkAgent(maestroRoot, slug) {
116
+ const p = sdkAgentPath(maestroRoot, slug);
117
+ if (!existsSync(p)) return null;
118
+ let text;
119
+ try { text = readFileSync(p, "utf8"); } catch { return null; }
120
+ const parsed = parseAgentMd(text);
121
+ return { slug: String(slug), path: p, text, fileHash: hashFile(text), frontmatter: parsed.frontmatter, body: parsed.body };
122
+ }
123
+
124
+ /**
125
+ * List the agent slugs physically present under `<root>/agents/` — a directory
126
+ * with an `agent.md` in it. A directory WITHOUT one is not an agent (that empty
127
+ * directory is exactly what used to pass `doctor`), so it is reported separately
128
+ * as `empty` rather than silently ignored.
129
+ * @param {string} root
130
+ * @returns {{slugs:string[], empty:string[]}}
131
+ */
132
+ export function listAgentDirs(root) {
133
+ const dir = join(root, "agents");
134
+ if (!existsSync(dir)) return { slugs: [], empty: [] };
135
+ const slugs = [];
136
+ const empty = [];
137
+ let names;
138
+ try { names = readdirSync(dir); } catch { return { slugs: [], empty: [] }; }
139
+ for (const name of names.sort()) {
140
+ if (name.startsWith(".")) continue;
141
+ const full = join(dir, name);
142
+ let st;
143
+ try { st = statSync(full); } catch { continue; }
144
+ if (!st.isDirectory()) continue;
145
+ if (existsSync(join(full, "agent.md"))) slugs.push(name);
146
+ else empty.push(name);
147
+ }
148
+ return { slugs, empty };
149
+ }
150
+
151
+ /**
152
+ * Build the manifest by scanning `<maestroRoot>/agents/*`/agent.md.
153
+ *
154
+ * Each entry carries the frontmatter facts a REMOTE consumer needs to make a
155
+ * routing/rewire decision without downloading the body (description, model,
156
+ * tools, tier, functions) plus the two hashes:
157
+ * sha256 — of the whole FILE, for local-edit classification
158
+ * bodyHash — of the body only, so it is comparable with hq's
159
+ * `SubagentVersion.contentHash` (same preimage as `hashBody`)
160
+ *
161
+ * Validation is reported, not enforced: a malformed shipped agent.md still gets
162
+ * an entry (so it stays classifiable as managed) but is listed under `invalid`
163
+ * so the generator can fail CI on it.
164
+ *
165
+ * @param {{maestroRoot:string, sdkVersion?:string, previous?:object, now?:Date}} o
166
+ * @returns {{manifest:object, invalid:Array<{slug:string, errors:string[]}>}}
167
+ */
168
+ export function buildManifest(o = {}) {
169
+ const maestroRoot = o.maestroRoot;
170
+ const { slugs, empty } = listAgentDirs(maestroRoot);
171
+ const prev = (o.previous && o.previous.agents) || {};
172
+ const agents = {};
173
+ const invalid = [];
174
+
175
+ for (const slug of slugs) {
176
+ const p = sdkAgentPath(maestroRoot, slug);
177
+ let text;
178
+ try { text = readFileSync(p, "utf8"); } catch { continue; }
179
+ const checked = checkAgentFile(text, slug);
180
+ if (!checked.ok) invalid.push({ slug, errors: checked.errors });
181
+
182
+ const fm = checked.frontmatter || {};
183
+ const sha256 = hashFile(text);
184
+ // Carry forward the PREVIOUS sha as history so an agent upgrading across
185
+ // several releases still classifies its untouched file as managed rather
186
+ // than as a local edit (which would freeze it forever).
187
+ const prevEntry = prev[slug] || {};
188
+ const history = [...new Set([...(Array.isArray(prevEntry.history) ? prevEntry.history : []), prevEntry.sha256].filter(Boolean))]
189
+ .filter((h) => h !== sha256)
190
+ .slice(-10);
191
+
192
+ agents[slug] = {
193
+ description: String(fm.description || ""),
194
+ model: normaliseModel(fm.model) || String(fm.model || ""),
195
+ tools: Array.isArray(fm.tools) ? fm.tools.map(String) : [],
196
+ tier: String(fm.tier || "standard"),
197
+ functions: Array.isArray(fm.functions) ? fm.functions.map(String) : [],
198
+ tokens: Array.isArray(checked.normalised && checked.normalised.frontmatter.tokens)
199
+ ? checked.normalised.frontmatter.tokens
200
+ : [],
201
+ sha256,
202
+ bodyHash: hashBody(checked.body || ""),
203
+ ...(history.length ? { history } : {}),
204
+ };
205
+ }
206
+
207
+ const manifest = {
208
+ sdkVersion: String(o.sdkVersion || ""),
209
+ generatedAt: (o.now || new Date()).toISOString(),
210
+ agents,
211
+ };
212
+ if (empty.length) manifest.emptyDirs = empty;
213
+ return { manifest, invalid };
214
+ }
215
+
216
+ /**
217
+ * Write the manifest atomically. Returns the path.
218
+ * @param {string} maestroRoot @param {object} manifest @returns {string}
219
+ */
220
+ export function writeManifest(maestroRoot, manifest) {
221
+ const p = manifestPath(maestroRoot);
222
+ writeJsonAtomic(p, manifest);
223
+ return p;
224
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * manifest.test.mjs — layer 0's index (§2.2): the sha256 classification that
3
+ * makes shipped agent.md files updatable WITHOUT touching `create`'s copy list
4
+ * or `UPGRADE_PATHS`.
5
+ * Run: node --test lib/subagents/manifest.test.mjs
6
+ */
7
+ "use strict";
8
+
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ import {
17
+ MANIFEST_REL,
18
+ buildManifest,
19
+ isManagedByManifest,
20
+ listAgentDirs,
21
+ manifestPath,
22
+ manifestShas,
23
+ manifestSlugs,
24
+ readManifest,
25
+ readSdkAgent,
26
+ writeManifest,
27
+ } from "./manifest.mjs";
28
+ import { hashBody, hashFile, stringifyAgentMd } from "./schema.mjs";
29
+
30
+ const MAESTRO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
31
+
32
+ function sdkRoot() {
33
+ return mkdtempSync(join(tmpdir(), "subagent-manifest-"));
34
+ }
35
+ function putAgent(root, slug, over = {}, body = "# body\n\nprose\n") {
36
+ const fm = { name: slug, description: `${slug} does things`, model: "sonnet", tools: ["Read"], ...over };
37
+ const text = stringifyAgentMd(fm, body);
38
+ mkdirSync(join(root, "agents", slug), { recursive: true });
39
+ writeFileSync(join(root, "agents", slug, "agent.md"), text);
40
+ return text;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+
45
+ test("listAgentDirs: an agents/<slug>/ with NO agent.md is reported empty, not ignored", () => {
46
+ // That empty directory is exactly what used to pass `doctor`.
47
+ const root = sdkRoot();
48
+ putAgent(root, "alpha");
49
+ mkdirSync(join(root, "agents", "hollow"), { recursive: true });
50
+ writeFileSync(join(root, "agents", "loose-file.txt"), "x");
51
+ const { slugs, empty } = listAgentDirs(root);
52
+ assert.deepEqual(slugs, ["alpha"]);
53
+ assert.deepEqual(empty, ["hollow"]);
54
+ });
55
+
56
+ test("listAgentDirs: fail-open when agents/ does not exist", () => {
57
+ assert.deepEqual(listAgentDirs(sdkRoot()), { slugs: [], empty: [] });
58
+ });
59
+
60
+ test("buildManifest: one entry per agent, with BOTH hashes and the routing facts", () => {
61
+ const root = sdkRoot();
62
+ const text = putAgent(root, "alpha", { tier: "standard", functions: ["executive-operator"] }, "# alpha\n\nhi {{agent.firstName}}\n");
63
+ const { manifest, invalid } = buildManifest({ maestroRoot: root, sdkVersion: "9.9.9", now: new Date("2026-01-01T00:00:00Z") });
64
+
65
+ assert.deepEqual(invalid, []);
66
+ assert.equal(manifest.sdkVersion, "9.9.9");
67
+ assert.equal(manifest.generatedAt, "2026-01-01T00:00:00.000Z");
68
+ const e = manifest.agents.alpha;
69
+ assert.equal(e.sha256, hashFile(text), "sha256 is the WHOLE file (local-edit classification)");
70
+ assert.equal(e.bodyHash, hashBody("# alpha\n\nhi {{agent.firstName}}\n"), "bodyHash matches hq's contentHash preimage");
71
+ assert.notEqual(e.sha256, e.bodyHash);
72
+ assert.equal(e.model, "claude-sonnet-4-6", "the alias is normalised in the manifest");
73
+ assert.deepEqual(e.tools, ["Read"]);
74
+ assert.equal(e.tier, "standard");
75
+ assert.deepEqual(e.functions, ["executive-operator"]);
76
+ assert.deepEqual(e.tokens, ["agent.firstName"]);
77
+ });
78
+
79
+ test("buildManifest: an INVALID agent.md still gets an entry (so it stays classifiable)", () => {
80
+ const root = sdkRoot();
81
+ putAgent(root, "alpha", { name: "wrong-name" });
82
+ const { manifest, invalid } = buildManifest({ maestroRoot: root });
83
+ assert.equal(invalid.length, 1);
84
+ assert.equal(invalid[0].slug, "alpha");
85
+ assert.ok(manifest.agents.alpha, "still listed — otherwise every deployed copy would freeze as a local edit");
86
+ });
87
+
88
+ test("buildManifest: carries the PREVIOUS sha forward as history (multi-release upgrades)", () => {
89
+ const root = sdkRoot();
90
+ putAgent(root, "alpha", {}, "v1\n");
91
+ const first = buildManifest({ maestroRoot: root, sdkVersion: "1.0.0" }).manifest;
92
+ const shaV1 = first.agents.alpha.sha256;
93
+
94
+ putAgent(root, "alpha", {}, "v2\n");
95
+ const second = buildManifest({ maestroRoot: root, sdkVersion: "2.0.0", previous: first }).manifest;
96
+ assert.notEqual(second.agents.alpha.sha256, shaV1);
97
+ assert.deepEqual(second.agents.alpha.history, [shaV1]);
98
+
99
+ // An agent still holding the v1 bytes classifies as MANAGED, not locally edited.
100
+ assert.equal(manifestShas(second).has(shaV1), true);
101
+ });
102
+
103
+ test("buildManifest: history never contains the current sha and is capped at 10", () => {
104
+ const root = sdkRoot();
105
+ let manifest = { agents: {} };
106
+ for (let i = 0; i < 14; i++) {
107
+ putAgent(root, "alpha", {}, `v${i}\n`);
108
+ manifest = buildManifest({ maestroRoot: root, previous: manifest }).manifest;
109
+ }
110
+ const e = manifest.agents.alpha;
111
+ assert.equal(e.history.length, 10);
112
+ assert.equal(e.history.includes(e.sha256), false);
113
+ });
114
+
115
+ test("buildManifest: empty directories are recorded on the manifest", () => {
116
+ const root = sdkRoot();
117
+ putAgent(root, "alpha");
118
+ mkdirSync(join(root, "agents", "hollow"), { recursive: true });
119
+ assert.deepEqual(buildManifest({ maestroRoot: root }).manifest.emptyDirs, ["hollow"]);
120
+ });
121
+
122
+ test("writeManifest / readManifest round trip, and manifestSlugs is sorted", () => {
123
+ const root = sdkRoot();
124
+ putAgent(root, "zeta");
125
+ putAgent(root, "alpha");
126
+ const { manifest } = buildManifest({ maestroRoot: root, sdkVersion: "3.2.1" });
127
+ const p = writeManifest(root, manifest);
128
+ assert.equal(p, manifestPath(root));
129
+ assert.equal(p.endsWith(MANIFEST_REL), true);
130
+ const read = readManifest(root);
131
+ assert.equal(read.sdkVersion, "3.2.1");
132
+ assert.deepEqual(manifestSlugs(read), ["alpha", "zeta"]);
133
+ });
134
+
135
+ test("readManifest: fail-open on a missing OR corrupt manifest — degrades SAFELY", () => {
136
+ // Degrading to "layer 0 has no managed files" makes every on-disk file look
137
+ // locally edited, which means nothing is overwritten. Safe direction.
138
+ const root = sdkRoot();
139
+ assert.deepEqual(readManifest(root), { sdkVersion: "", agents: {} });
140
+ mkdirSync(join(root, "agents"), { recursive: true });
141
+ writeFileSync(manifestPath(root), "{ not json");
142
+ assert.deepEqual(readManifest(root), { sdkVersion: "", agents: {} });
143
+ assert.deepEqual([...manifestShas(readManifest(root))], []);
144
+ });
145
+
146
+ test("isManagedByManifest: exact file bytes only", () => {
147
+ const root = sdkRoot();
148
+ const text = putAgent(root, "alpha");
149
+ const { manifest } = buildManifest({ maestroRoot: root });
150
+ assert.equal(isManagedByManifest(manifest, text), true);
151
+ assert.equal(isManagedByManifest(manifest, text + "\n# edited\n"), false);
152
+ assert.equal(isManagedByManifest(manifest, ""), false);
153
+ assert.equal(isManagedByManifest(manifest, null), false);
154
+ });
155
+
156
+ test("readSdkAgent: returns parsed text + fileHash, null when absent", () => {
157
+ const root = sdkRoot();
158
+ const text = putAgent(root, "alpha");
159
+ const a = readSdkAgent(root, "alpha");
160
+ assert.equal(a.slug, "alpha");
161
+ assert.equal(a.text, text);
162
+ assert.equal(a.fileHash, hashFile(text));
163
+ assert.equal(a.frontmatter.name, "alpha");
164
+ assert.equal(readSdkAgent(root, "nope"), null);
165
+ });
166
+
167
+ test("the REAL SDK tree builds a manifest with no invalid agents", () => {
168
+ const { manifest, invalid } = buildManifest({ maestroRoot: MAESTRO_ROOT, sdkVersion: "test" });
169
+ assert.deepEqual(invalid.map((i) => i.slug), []);
170
+ assert.ok(Object.keys(manifest.agents).length >= 1);
171
+ for (const [slug, e] of Object.entries(manifest.agents)) {
172
+ const text = readFileSync(join(MAESTRO_ROOT, "agents", slug, "agent.md"), "utf8");
173
+ assert.equal(e.sha256, hashFile(text), `${slug} sha must be the file hash`);
174
+ }
175
+ });