@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,429 @@
1
+ /**
2
+ * subagents.test.mjs — the sub-agent registry init step (order 40).
3
+ *
4
+ * §6 names the degradation branches as the least-exercised path in the design,
5
+ * so they are tested explicitly here — cold cache, a 500 from hq, a timeout and
6
+ * a missing env — not just the happy path. The contract that makes them matter:
7
+ * a throw in `apply` is FATAL to the whole wizard (lib/setup/runner.mjs:220-227).
8
+ *
9
+ * Run: node --test lib/setup/sections/subagents.test.mjs
10
+ */
11
+ "use strict";
12
+
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import section, { HEADLESS_SAFE, assess, planActions, readAgentConfig, resolveRoster } from "./subagents.mjs";
20
+ import * as io from "../io.mjs";
21
+ import { buildManifest, writeManifest } from "../../subagents/manifest.mjs";
22
+ import { readGaps, readLock, writeCache } from "../../subagents/lock.mjs";
23
+ import { agentFilePath } from "../../subagents/resolve.mjs";
24
+ import { stringifyAgentMd } from "../../subagents/schema.mjs";
25
+
26
+ const ENV = { COHORT_BASE: "https://hq.example.com", COHORT_API_KEY: "sk-test", COHORT_ORG_ID: "org_1" };
27
+
28
+ function md(slug, body = `# ${slug}\n\nsdk body\n`, over = {}) {
29
+ return stringifyAgentMd({ name: slug, description: `${slug} does things`, model: "sonnet", tools: ["Read"], ...over }, body);
30
+ }
31
+ function put(root, slug, text) {
32
+ mkdirSync(join(root, "agents", slug), { recursive: true });
33
+ writeFileSync(join(root, "agents", slug, "agent.md"), text);
34
+ return text;
35
+ }
36
+ function sdkWith(slugs) {
37
+ const root = mkdtempSync(join(tmpdir(), "subagent-sec-sdk-"));
38
+ for (const s of slugs) put(root, s, md(s));
39
+ writeManifest(root, buildManifest({ maestroRoot: root, sdkVersion: "9.9.9" }).manifest);
40
+ return root;
41
+ }
42
+ function agentRepo(cfg = { firstName: "Isla", function: "executive-operator", altitude: "c-suite" }) {
43
+ const root = mkdtempSync(join(tmpdir(), "subagent-sec-agent-"));
44
+ mkdirSync(join(root, "config"), { recursive: true });
45
+ mkdirSync(join(root, "workflows"), { recursive: true });
46
+ writeFileSync(join(root, "config", "agent.json"), JSON.stringify(cfg));
47
+ return root;
48
+ }
49
+ function fakeFetch(reply) {
50
+ const calls = [];
51
+ const fn = async (url, init) => {
52
+ calls.push({ url: String(url), body: init && init.body ? JSON.parse(init.body) : null });
53
+ const r = typeof reply === "function" ? reply(String(url), calls.length) : reply;
54
+ if (r instanceof Error) throw r;
55
+ return { ok: r.ok !== false, status: r.status || 200, json: async () => r.body };
56
+ };
57
+ fn.calls = calls;
58
+ return fn;
59
+ }
60
+ const okFrame = (result) => ({ ok: true, status: 200, body: { ok: true, result } });
61
+ const surface = (ids) => async () => ({ standardAgents: ids.map((id) => ({ id, role: id })), roleAgents: [] });
62
+
63
+ function ctxFor(agentRoot, maestroRoot, extra = {}) {
64
+ return {
65
+ agentRoot, maestroRoot, io,
66
+ flags: { headless: true },
67
+ answers: {},
68
+ env: {},
69
+ ...extra,
70
+ };
71
+ }
72
+ /** Silence io.note for the duration of a call — the wizard is chatty by design. */
73
+ async function quiet(fn) {
74
+ const log = console.log;
75
+ console.log = () => {};
76
+ try { return await fn(); } finally { console.log = log; }
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // contract
81
+ // ---------------------------------------------------------------------------
82
+
83
+ test("section shape: id/label/order=40 and the four hooks", () => {
84
+ assert.equal(section.id, "subagents");
85
+ assert.equal(section.label, "Sub-agents");
86
+ assert.equal(section.order, 40, "the lowest free slot: before tools (50) and operating-model (60)");
87
+ for (const h of ["detect", "prompt", "apply", "verify"]) assert.equal(typeof section[h], "function");
88
+ });
89
+
90
+ test("HEADLESS_SAFE: only the two unambiguous adoptions — never create/fork", () => {
91
+ assert.deepEqual([...HEADLESS_SAFE].sort(), ["adopt-sdk", "adopt-workspace"]);
92
+ for (const v of ["create", "fork", "rewire", "drop-workflow-step"]) assert.equal(HEADLESS_SAFE.has(v), false);
93
+ });
94
+
95
+ test("readAgentConfig: fail-open on missing/corrupt config", () => {
96
+ assert.deepEqual(readAgentConfig(mkdtempSync(join(tmpdir(), "cfg-"))), {});
97
+ const root = agentRepo();
98
+ writeFileSync(join(root, "config", "agent.json"), "{ nope");
99
+ assert.deepEqual(readAgentConfig(root), {});
100
+ });
101
+
102
+ test("resolveRoster: injected surface wins; a throwing surface fails open to []", async () => {
103
+ const cfg = { function: "executive-operator", altitude: "c-suite" };
104
+ assert.deepEqual((await resolveRoster({ resolveSurfaceImpl: surface(["alpha"]) }, cfg)).map((a) => a.id), ["alpha"]);
105
+ assert.deepEqual(await resolveRoster({ resolveSurfaceImpl: async () => { throw new Error("boom"); } }, cfg), []);
106
+ assert.deepEqual(await resolveRoster({}, {}), [], "no function/altitude → no authoritative roster");
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // detect — non-blocking, no input, NO NETWORK
111
+ // ---------------------------------------------------------------------------
112
+
113
+ test("detect: never touches the network, even when the agent IS enrolled", async () => {
114
+ // It runs under --headless, doctor and CI. Everything it needs is on disk.
115
+ const f = fakeFetch(okFrame({ rosterVersion: 1, entries: [] }));
116
+ const ctx = ctxFor(agentRepo(), sdkWith(["alpha"]), { env: ENV, subagentFetchImpl: f, resolveSurfaceImpl: surface(["alpha"]) });
117
+ await section.detect(ctx);
118
+ assert.deepEqual(f.calls, []);
119
+ });
120
+
121
+ test("detect: complete when everything resolves and nothing dangles", async () => {
122
+ const agent = agentRepo();
123
+ writeFileSync(join(agent, "workflows", "w.yaml"), "steps:\n - agent: alpha\n");
124
+ const det = await section.detect(ctxFor(agent, sdkWith(["alpha"]), { resolveSurfaceImpl: surface(["alpha"]) }));
125
+ assert.equal(det.status, "complete");
126
+ assert.match(det.summary, /1 sub-agents resolved/);
127
+ assert.match(det.summary, /workspace layer off — not enrolled/, "an un-enrolled agent is a legitimate steady state");
128
+ });
129
+
130
+ test("detect: partial lists missing slugs AND dangling refs, capped", async () => {
131
+ const agent = agentRepo();
132
+ writeFileSync(join(agent, "workflows", "w.yaml"), "steps:\n - agent: ghost-writer\n");
133
+ const det = await section.detect(ctxFor(agent, sdkWith(["alpha"]), { resolveSurfaceImpl: surface(["alpha", "beta"]) }));
134
+ assert.equal(det.status, "partial");
135
+ assert.ok(det.missing.includes("missing: beta"));
136
+ assert.ok(det.missing.includes("dangling ref: ghost-writer"));
137
+ assert.ok(det.missing.length <= 12);
138
+ });
139
+
140
+ test("detect: unconfigured when the roster is known and NOTHING resolves", async () => {
141
+ const det = await section.detect(ctxFor(agentRepo(), sdkWith([]), { resolveSurfaceImpl: surface(["alpha"]) }));
142
+ assert.equal(det.status, "unconfigured");
143
+ });
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // planActions
147
+ // ---------------------------------------------------------------------------
148
+
149
+ test("planActions: workspace pin → adopt-workspace, SDK → adopt-sdk, neither → defer", async () => {
150
+ const agent = agentRepo();
151
+ const sdk = sdkWith(["from-sdk"]);
152
+ const a = await assess(ctxFor(agent, sdk, { resolveSurfaceImpl: surface(["from-sdk", "pinned", "nowhere"]) }), { network: false });
153
+ a.pins = [{ slug: "pinned" }];
154
+ a.gaps.missing = [{ slug: "from-sdk" }, { slug: "pinned" }, { slug: "nowhere" }];
155
+ const plan = planActions(a);
156
+ assert.deepEqual(plan.filter((p) => ["from-sdk", "pinned", "nowhere"].includes(p.slug)).map((p) => [p.slug, p.action]),
157
+ [["from-sdk", "adopt-sdk"], ["pinned", "adopt-workspace"], ["nowhere", "defer"]]);
158
+ });
159
+
160
+ test("planActions: create/fork are NEVER a default — both mint a row in hq", () => {
161
+ const plan = planActions({
162
+ resolved: { bySlug: new Map(), entries: [] },
163
+ manifest: { agents: {} },
164
+ pins: [],
165
+ gaps: { missing: [{ slug: "nowhere" }] },
166
+ });
167
+ assert.deepEqual(plan.map((p) => p.action), ["defer"]);
168
+ });
169
+
170
+ test("planActions: a resolved winner that is not yet on disk is an adoption", async () => {
171
+ const a = await assess(ctxFor(agentRepo(), sdkWith(["alpha"]), { resolveSurfaceImpl: surface([]) }), { network: false });
172
+ assert.deepEqual(planActions(a), [{ slug: "alpha", action: "adopt-sdk", reason: "resolved from sdk but not on disk" }]);
173
+ });
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // apply — happy path
177
+ // ---------------------------------------------------------------------------
178
+
179
+ test("apply: materialises every winner, records the lock, and is idempotent", async () => {
180
+ const agent = agentRepo();
181
+ const sdk = sdkWith(["alpha", "beta"]);
182
+ const ctx = ctxFor(agent, sdk, { resolveSurfaceImpl: surface(["alpha", "beta"]) });
183
+
184
+ await quiet(() => section.apply(ctx));
185
+ assert.equal(existsSync(agentFilePath(agent, "alpha")), true);
186
+ const lock = readLock(agent);
187
+ assert.equal(lock.agents.alpha.layer, "sdk");
188
+ assert.ok(lock.agents.alpha.fileHash);
189
+
190
+ const before = readFileSync(agentFilePath(agent, "alpha"), "utf8");
191
+ await quiet(() => section.apply(ctx));
192
+ assert.equal(readFileSync(agentFilePath(agent, "alpha"), "utf8"), before, "a second apply changes nothing");
193
+ });
194
+
195
+ test("apply: the workspace layer wins and the roster version lands in the lock", async () => {
196
+ const agent = agentRepo();
197
+ const sdk = sdkWith(["alpha"]);
198
+ const f = fakeFetch(okFrame({
199
+ rosterVersion: 42,
200
+ entries: [{ slug: "alpha", definitionId: "d1", version: 5, contentHash: "h", frontmatter: { name: "alpha", description: "d", model: "sonnet", tools: ["Read"] }, body: "# alpha\n\nWORKSPACE\n" }],
201
+ }));
202
+ await quiet(() => section.apply(ctxFor(agent, sdk, { env: ENV, subagentFetchImpl: f, resolveSurfaceImpl: surface(["alpha"]) })));
203
+ assert.match(readFileSync(agentFilePath(agent, "alpha"), "utf8"), /WORKSPACE/);
204
+ assert.equal(readLock(agent).rosterVersion, 42);
205
+ });
206
+
207
+ test("apply: a local edit survives — the wizard never force-overwrites", async () => {
208
+ const agent = agentRepo();
209
+ const mine = put(agent, "alpha", md("alpha", "# alpha\n\nMINE\n"));
210
+ await quiet(() => section.apply(ctxFor(agent, sdkWith(["alpha"]), { resolveSurfaceImpl: surface(["alpha"]) })));
211
+ assert.equal(readFileSync(agentFilePath(agent, "alpha"), "utf8"), mine);
212
+ });
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // apply — the degradation branches (§6: the least-exercised path)
216
+ // ---------------------------------------------------------------------------
217
+
218
+ test("apply degradation: a 500 from hq returns NORMALLY and records degraded", async () => {
219
+ const agent = agentRepo();
220
+ const ctx = ctxFor(agent, sdkWith(["alpha"]), {
221
+ env: ENV,
222
+ subagentFetchImpl: fakeFetch({ ok: false, status: 500, body: { ok: false, error: { code: "INTERNAL", message: "boom" } } }),
223
+ resolveSurfaceImpl: surface(["alpha"]),
224
+ });
225
+ await quiet(() => section.apply(ctx));
226
+ assert.deepEqual(readLock(agent).degraded, ["workspace"]);
227
+ assert.equal(existsSync(agentFilePath(agent, "alpha")), true, "…and the SDK layer still materialises");
228
+ });
229
+
230
+ test("apply degradation: a timeout returns NORMALLY and records degraded", async () => {
231
+ // The 5 s AbortController path itself is exercised in
232
+ // lib/subagents/client.test.mjs with an injected 20 ms budget; here we hand
233
+ // the section the AbortError that path produces, so the section's own
234
+ // degradation branch is covered without five seconds of wall clock.
235
+ const agent = agentRepo();
236
+ const timedOut = fakeFetch(Object.assign(new Error("timeout after 5000ms"), { name: "AbortError" }));
237
+ const ctx = ctxFor(agent, sdkWith(["alpha"]), { env: ENV, subagentFetchImpl: timedOut, resolveSurfaceImpl: surface(["alpha"]) });
238
+ await quiet(() => section.apply(ctx));
239
+ assert.deepEqual(readLock(agent).degraded, ["workspace"]);
240
+ assert.equal(existsSync(agentFilePath(agent, "alpha")), true, "the SDK layer still materialises");
241
+ });
242
+
243
+ test("apply degradation: a connection refusal returns NORMALLY", async () => {
244
+ const agent = agentRepo();
245
+ const ctx = ctxFor(agent, sdkWith(["alpha"]), { env: ENV, subagentFetchImpl: fakeFetch(new Error("ECONNREFUSED")), resolveSurfaceImpl: surface(["alpha"]) });
246
+ await quiet(() => section.apply(ctx));
247
+ assert.deepEqual(readLock(agent).degraded, ["workspace"]);
248
+ });
249
+
250
+ test("apply degradation: MISSING env is not degradation — layer 1 simply does not exist", async () => {
251
+ const agent = agentRepo();
252
+ const f = fakeFetch(okFrame({}));
253
+ await quiet(() => section.apply(ctxFor(agent, sdkWith(["alpha"]), { env: {}, subagentFetchImpl: f, resolveSurfaceImpl: surface(["alpha"]) })));
254
+ assert.deepEqual(f.calls, [], "an un-enrolled agent must not attempt a call");
255
+ assert.deepEqual(readLock(agent).degraded, []);
256
+ assert.equal(existsSync(agentFilePath(agent, "alpha")), true);
257
+ });
258
+
259
+ test("apply degradation: COLD CACHE + offline still resolves the workspace pin from the lock", async () => {
260
+ const agent = agentRepo();
261
+ const sdk = sdkWith(["alpha"]);
262
+ // Pretend a previous online run pinned alpha at v3 and cached its body.
263
+ const { mergeLock } = await import("../../subagents/lock.mjs");
264
+ mergeLock(agent, { agents: { alpha: { layer: "workspace", definitionId: "d1", version: 3, contentHash: "h" } } });
265
+ writeCache(agent, "alpha", 3, stringifyAgentMd({ name: "alpha", description: "d", model: "sonnet", tools: ["Read"] }, "# alpha\n\nCACHED\n"));
266
+
267
+ await quiet(() => section.apply(ctxFor(agent, sdk, { env: ENV, subagentFetchImpl: fakeFetch(new Error("offline")), resolveSurfaceImpl: surface(["alpha"]) })));
268
+ assert.match(readFileSync(agentFilePath(agent, "alpha"), "utf8"), /CACHED/, "not silently demoted to the SDK body");
269
+ });
270
+
271
+ test("apply degradation: even a failing ASSESSMENT does not abort the wizard", async () => {
272
+ const agent = agentRepo();
273
+ const ctx = ctxFor(agent, sdkWith(["alpha"]));
274
+ // A throw here would kill the whole setup run (runner.mjs:220-227).
275
+ Object.defineProperty(ctx, "subagentCfg", { get() { throw new Error("exploded"); } });
276
+ await quiet(() => section.apply(ctx));
277
+ assert.deepEqual(readLock(agent).degraded, ["assessment"]);
278
+ });
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // apply — headless safety
282
+ // ---------------------------------------------------------------------------
283
+
284
+ test("apply headless: NEVER creates or forks unattended, and leaves a gaps report", async () => {
285
+ const agent = agentRepo();
286
+ writeFileSync(join(agent, "workflows", "w.yaml"), "steps:\n - agent: ghost-writer\n");
287
+ const f = fakeFetch(okFrame({ definitionId: "d1", version: 1 }));
288
+ const ctx = ctxFor(agent, sdkWith(["alpha"]), {
289
+ env: ENV, subagentFetchImpl: f, resolveSurfaceImpl: surface(["alpha", "beta"]),
290
+ answers: { subagentChoices: { beta: "create", alpha: "fork" } },
291
+ });
292
+
293
+ await quiet(() => section.apply(ctx));
294
+ assert.deepEqual(f.calls.filter((c) => /create|fork|pin/.test(c.url)), [], "no row is minted without a human");
295
+
296
+ const gaps = readGaps(agent);
297
+ assert.ok(gaps, "state/subagents/gaps.json is the headless verdict");
298
+ assert.deepEqual(gaps.missing.map((m) => m.slug), ["beta"]);
299
+ assert.ok(gaps.plan.length >= 1);
300
+ assert.match(gaps.summary, /missing/);
301
+ });
302
+
303
+ test("apply: a drop verdict records the accepted drop and NEVER edits the workflow", async () => {
304
+ const agent = agentRepo();
305
+ const wf = join(agent, "workflows", "w.yaml");
306
+ const original = "steps:\n - agent: ghost-writer\n";
307
+ writeFileSync(wf, original);
308
+ const ctx = ctxFor(agent, sdkWith(["alpha"]), {
309
+ resolveSurfaceImpl: surface(["alpha"]),
310
+ answers: { subagentChoices: { "ref:ghost-writer": "drop-workflow-step" } },
311
+ });
312
+
313
+ await quiet(() => section.apply(ctx));
314
+ assert.equal(readFileSync(wf, "utf8"), original, "a workflow step's removal changes runtime behaviour — a human owns that diff");
315
+ const drop = readLock(agent).acceptedDrops[0];
316
+ assert.equal(drop.ref, "ghost-writer");
317
+ assert.equal(drop.file, "workflows/w.yaml");
318
+ assert.equal(drop.line, 2, "file:line is recorded so the operator knows exactly what to delete");
319
+ });
320
+
321
+ test("apply: a rewire verdict is advice only — nothing on disk changes", async () => {
322
+ const agent = agentRepo();
323
+ const wf = join(agent, "workflows", "w.yaml");
324
+ writeFileSync(wf, "steps:\n - agent: ghost-writer\n");
325
+ const ctx = ctxFor(agent, sdkWith(["alpha"]), {
326
+ resolveSurfaceImpl: surface(["alpha"]),
327
+ answers: { subagentChoices: { "ref:ghost-writer": "rewire:alpha" } },
328
+ });
329
+ await quiet(() => section.apply(ctx));
330
+ assert.equal(readFileSync(wf, "utf8"), "steps:\n - agent: ghost-writer\n");
331
+ assert.deepEqual(readLock(agent).acceptedDrops, []);
332
+ });
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // prompt
336
+ // ---------------------------------------------------------------------------
337
+
338
+ test("prompt: records the assessment and the per-slug verdicts (headless answers)", async () => {
339
+ const agent = agentRepo();
340
+ writeFileSync(join(agent, "workflows", "w.yaml"), "steps:\n - agent: ghost-writer\n");
341
+ const ctx = ctxFor(agent, sdkWith(["alpha"]), {
342
+ resolveSurfaceImpl: surface(["alpha", "beta"]),
343
+ answers: { "subagent.beta": "defer", "subagent.ref.ghost-writer": "drop-workflow-step" },
344
+ });
345
+ await quiet(() => section.prompt(ctx));
346
+ assert.deepEqual(ctx.answers.subagentAssessment.counts.missing, 1);
347
+ assert.equal(ctx.answers.subagentChoices.beta, "defer");
348
+ assert.equal(ctx.answers.subagentChoices["ref:ghost-writer"], "drop-workflow-step");
349
+ });
350
+
351
+ test("prompt: nothing to decide → no choices are recorded", async () => {
352
+ const agent = agentRepo();
353
+ writeFileSync(join(agent, "workflows", "w.yaml"), "steps:\n - agent: alpha\n");
354
+ const ctx = ctxFor(agent, sdkWith(["alpha"]), { resolveSurfaceImpl: surface(["alpha"]) });
355
+ await quiet(() => section.prompt(ctx));
356
+ assert.equal(ctx.answers.subagentChoices, undefined);
357
+ });
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // verify — pure local
361
+ // ---------------------------------------------------------------------------
362
+
363
+ test("verify: a materialised repo passes every check", async () => {
364
+ const agent = agentRepo();
365
+ writeFileSync(join(agent, "workflows", "w.yaml"), "steps:\n - agent: alpha\n");
366
+ const ctx = ctxFor(agent, sdkWith(["alpha"]), { resolveSurfaceImpl: surface(["alpha"]) });
367
+ await quiet(() => section.apply(ctx));
368
+ const v = await section.verify(ctx);
369
+ assert.equal(v.ok, true, JSON.stringify(v.checks));
370
+ assert.ok(v.checks.length >= 3);
371
+ });
372
+
373
+ test("verify: an INVALID on-disk definition fails the first check", async () => {
374
+ const agent = agentRepo();
375
+ put(agent, "alpha", md("alpha", "# alpha\n\nb\n", { name: "wrong-name" }));
376
+ const v = await section.verify(ctxFor(agent, sdkWith([]), { resolveSurfaceImpl: surface([]) }));
377
+ const c = v.checks[0];
378
+ assert.equal(c.ok, false);
379
+ assert.match(c.remedy, /must equal the directory name/);
380
+ assert.equal(v.ok, false);
381
+ });
382
+
383
+ test("verify: a lock entry pointing at a file that no longer exists is a broken lock", async () => {
384
+ const agent = agentRepo();
385
+ const { mergeLock } = await import("../../subagents/lock.mjs");
386
+ mergeLock(agent, { agents: { vanished: { layer: "sdk", fileHash: "h" } } });
387
+ const v = await section.verify(ctxFor(agent, sdkWith([]), { resolveSurfaceImpl: surface([]) }));
388
+ const c = v.checks.find((x) => x.name.startsWith("lock matches disk"));
389
+ assert.equal(c.ok, false);
390
+ assert.match(c.remedy, /subagents pull/);
391
+ });
392
+
393
+ test("verify: dangling references WARN but never fail (a fresh agent starts with ~15)", async () => {
394
+ // `create` copies maestro's own workflows verbatim, so hard-failing here would
395
+ // make every new agent's setup red on day one.
396
+ const agent = agentRepo();
397
+ writeFileSync(join(agent, "workflows", "w.yaml"), "steps:\n - agent: ghost-writer\n - agent: another-ghost\n");
398
+ const v = await section.verify(ctxFor(agent, sdkWith([]), { resolveSurfaceImpl: surface([]) }));
399
+ const c = v.checks.find((x) => x.name.startsWith("sub-agent references resolve"));
400
+ assert.equal(c.ok, true);
401
+ assert.match(c.name, /2 dangling/);
402
+ assert.match(c.remedy, /subagents status/);
403
+ assert.equal(v.ok, true);
404
+ });
405
+
406
+ test("verify: never touches the network", async () => {
407
+ const f = fakeFetch(okFrame({}));
408
+ const ctx = ctxFor(agentRepo(), sdkWith(["alpha"]), { env: ENV, subagentFetchImpl: f, resolveSurfaceImpl: surface(["alpha"]) });
409
+ await section.verify(ctx);
410
+ assert.deepEqual(f.calls, []);
411
+ });
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // assess
415
+ // ---------------------------------------------------------------------------
416
+
417
+ test("assess: network:false never calls out; network:true does", async () => {
418
+ const agent = agentRepo();
419
+ const sdk = sdkWith(["alpha"]);
420
+ const f = fakeFetch(okFrame({ rosterVersion: 2, entries: [] }));
421
+ const ctx = ctxFor(agent, sdk, { env: ENV, subagentFetchImpl: f, resolveSurfaceImpl: surface(["alpha"]) });
422
+
423
+ await assess(ctx, { network: false });
424
+ assert.deepEqual(f.calls, []);
425
+ const on = await assess(ctx, { network: true });
426
+ assert.deepEqual(f.calls.map((c) => c.url.split("/v1/")[1]), ["subagent.resolve"]);
427
+ assert.equal(on.rosterVersion, 2);
428
+ assert.deepEqual(on.degraded, []);
429
+ });
@@ -17,10 +17,120 @@
17
17
 
18
18
  "use strict";
19
19
 
20
+ import { join } from "node:path";
21
+ import { existsSync, readFileSync } from "node:fs";
20
22
  import { probeClaude } from "../claude-probe.mjs";
21
23
  import { assertComplete } from "../completeness.mjs";
22
24
  import * as io from "../io.mjs";
23
25
 
26
+ /** Freshness ceiling for the mandate cache (SPEC §6.4: < 24h is "full"). */
27
+ export const MANDATE_FRESH_MS = 24 * 60 * 60 * 1000;
28
+
29
+ /**
30
+ * The four INIT-pipeline checks (SPEC §5.8). Split out of verify() so they are
31
+ * unit-testable with injected fakes and so a caller can run them alone.
32
+ *
33
+ * These are SOFT by design — an agent whose human has not yet adopted any
34
+ * objective legitimately has no plan and no mandate cache, and setup must not
35
+ * exit non-zero for that. But every degradation is NAMED with a remedy: a
36
+ * silently absent plan is exactly the failure mode this whole design exists to
37
+ * prevent.
38
+ *
39
+ * @param {object} o - { agentRoot, now? }
40
+ * @param {object} [deps] - { existsSync, readFileSync, yaml, readManifest, readLock, readCache }
41
+ * @returns {Promise<Array<{name,ok,remedy?}>>}
42
+ */
43
+ export async function planChecks(o = {}, deps = {}) {
44
+ const agentRoot = o.agentRoot;
45
+ const now = Number.isFinite(o.now) ? o.now : Date.now();
46
+ const ex = deps.existsSync || existsSync;
47
+ const rd = deps.readFileSync || readFileSync;
48
+ const checks = [];
49
+
50
+ const planPath = join(agentRoot, "config", "plan.yaml");
51
+ if (!ex(planPath)) {
52
+ checks.push({ name: "plan compiled (config/plan.yaml)", ok: true, remedy: "no compiled plan — the agent runs standard + archetype cadences only; run `maestro setup --only mandate`" });
53
+ return checks;
54
+ }
55
+
56
+ let plan = null;
57
+ try {
58
+ const yaml = deps.yaml || (await import("js-yaml").then((m) => m.default || m));
59
+ plan = yaml.load(rd(planPath, "utf-8"));
60
+ } catch (e) {
61
+ checks.push({ name: "plan compiled (config/plan.yaml)", ok: false, remedy: `config/plan.yaml is unreadable (${e && e.message ? e.message : e}) — re-run \`node scripts/setup/generate-plan.mjs\`` });
62
+ return checks;
63
+ }
64
+ const obligations = (plan && plan.obligations) || [];
65
+ checks.push({ name: `plan compiled: ${obligations.length} obligations`, ok: !!plan && obligations.length > 0, remedy: obligations.length ? undefined : "plan.yaml has no obligations — re-run `node scripts/setup/generate-plan.mjs`" });
66
+
67
+ // 1. hashes match the compile lock (a hand-edited plan.yaml is drift).
68
+ let lock = null;
69
+ try {
70
+ const emit = deps.readLock ? { readLock: deps.readLock } : await import("../../plan/emit.mjs");
71
+ lock = emit.readLock(agentRoot, deps);
72
+ } catch { lock = null; }
73
+ const hashOk = !!(lock && plan && lock.obligationsHash && lock.obligationsHash === plan.obligationsHash);
74
+ checks.push({
75
+ name: "obligationsHash matches state/plan/compile.lock",
76
+ ok: hashOk,
77
+ remedy: hashOk ? undefined : (lock ? "config/plan.yaml was edited by hand or is stale — re-run `node scripts/setup/generate-plan.mjs`" : "no state/plan/compile.lock — re-run `node scripts/setup/generate-plan.mjs`"),
78
+ });
79
+
80
+ // 2. zero obligations citing an unreachable capability. This is THE law the
81
+ // inventory step establishes; a violation means the plan promises work the
82
+ // agent cannot actually do.
83
+ let manifest = null;
84
+ try {
85
+ const inv = deps.readManifest ? { readManifest: deps.readManifest } : await import("../../capability/inventory.mjs");
86
+ manifest = inv.readManifest(agentRoot, deps);
87
+ } catch { manifest = null; }
88
+ if (manifest) {
89
+ const reachable = new Set((manifest.entries || []).filter((e) => e && e.reachable).map((e) => e.id));
90
+ const offenders = [];
91
+ for (const ob of obligations) {
92
+ for (const u of ob.uses || []) if (!reachable.has(u)) offenders.push(`${ob.key} → ${u}`);
93
+ }
94
+ checks.push({
95
+ name: `no obligation cites an unreachable capability (${offenders.length} violation(s))`,
96
+ ok: offenders.length === 0,
97
+ remedy: offenders.length ? `unreachable: ${offenders.slice(0, 5).join(", ")}${offenders.length > 5 ? ` (+${offenders.length - 5} more)` : ""} — re-run the inventory section, then recompile` : undefined,
98
+ });
99
+ } else {
100
+ checks.push({ name: "no obligation cites an unreachable capability", ok: false, remedy: "no config/capability-manifest.json — run `maestro setup --only inventory`" });
101
+ }
102
+
103
+ // 3. zero OUTCOME obligations resting on an LLM-sourced sensor. An llm sample
104
+ // is advisory and may never close the loop into created work (§6.3 honesty gate).
105
+ const llmSensors = obligations.filter((ob) => ob.kind === "OUTCOME" && ob.sensor && ob.sensor.source && ob.sensor.source !== "method");
106
+ checks.push({
107
+ name: `every OUTCOME has a method sensor (${llmSensors.length} advisory)`,
108
+ ok: llmSensors.length === 0,
109
+ remedy: llmSensors.length ? `${llmSensors.map((o2) => o2.key).slice(0, 5).join(", ")} rest on a non-method sensor — they cannot create work; bind a reachable capability or retire them` : undefined,
110
+ });
111
+
112
+ // 4. mandate cache freshness (< 24h). Past 24h Loop C suspends; past 7d the
113
+ // daemon idles (§6.4). Soft here, because an un-enrolled agent has no cache.
114
+ let cache = null;
115
+ try {
116
+ const cm = deps.readCache ? { readCache: deps.readCache } : await import("../../mandate/cache.mjs");
117
+ cache = cm.readCache(agentRoot, deps);
118
+ } catch { cache = null; }
119
+ if (!cache) {
120
+ checks.push({ name: "mandate cache present", ok: true, remedy: "no state/mandate/cache.json — the agent has no adopted mandate yet; propose objectives with `maestro setup --only mandate`" });
121
+ } else {
122
+ const age = now - Date.parse(cache.fetchedAt || 0);
123
+ const fresh = Number.isFinite(age) && age < MANDATE_FRESH_MS;
124
+ checks.push({
125
+ name: `mandate cache fresh (${Number.isFinite(age) ? `${Math.round(age / 3600000)}h` : "unknown"} old)`,
126
+ ok: fresh,
127
+ remedy: fresh ? undefined : "mandate cache is >24h old — self-directed work is suspended until it refreshes (`mandate.get`)",
128
+ });
129
+ }
130
+
131
+ return checks;
132
+ }
133
+
24
134
  export default {
25
135
  id: "verify",
26
136
  label: "Verification & capability summary",
@@ -58,6 +168,17 @@ export default {
58
168
  const complete = await assertComplete(ctx.agentRoot);
59
169
  const probe = probeClaude(ctx.agentRoot);
60
170
 
171
+ // INIT-pipeline gate (SPEC §5.8): the plan compiles, its hashes match, it
172
+ // cites nothing unreachable, no OUTCOME rests on an LLM sensor, and the
173
+ // mandate cache is fresh. Soft-fail, but every degradation is named.
174
+ try {
175
+ for (const c of await planChecks({ agentRoot: ctx.agentRoot }, (ctx.planDeps || {}))) {
176
+ rows.push({ section: "plan", ...c });
177
+ }
178
+ } catch (e) {
179
+ rows.push({ section: "plan", name: "plan checks", ok: false, remedy: `plan verification threw: ${e && e.message ? e.message : e}` });
180
+ }
181
+
61
182
  // Hard-fail conditions.
62
183
  const noIdentity = complete.missing.some((m) => m.artifact === "config/agent.json");
63
184
  const archetypeOk = complete.archetypeOk;