@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,175 @@
1
+ /**
2
+ * verify.test.mjs — the INIT-pipeline gate in the verify section (SPEC §5.8).
3
+ * Run: node --test lib/setup/sections/verify.test.mjs
4
+ *
5
+ * The four checks that decide whether the plan an agent just compiled is
6
+ * TRUSTWORTHY:
7
+ * 1. it compiles and its obligationsHash matches the compile lock
8
+ * 2. no obligation cites an unreachable capability
9
+ * 3. no OUTCOME rests on an LLM-sourced sensor
10
+ * 4. the mandate cache is fresh (< 24h)
11
+ *
12
+ * All four are SOFT — an un-adopted agent legitimately has no plan and setup must
13
+ * not exit non-zero for that — so the thing under test is that every degradation
14
+ * is NAMED with a remedy rather than passing silently.
15
+ */
16
+ "use strict";
17
+
18
+ import { test } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import yaml from "js-yaml";
21
+
22
+ import verifySection, { planChecks, MANDATE_FRESH_MS } from "./verify.mjs";
23
+ import { loadSections } from "../runner.mjs";
24
+
25
+ const NOW = Date.parse("2026-08-11T12:00:00.000Z");
26
+
27
+ const MANIFEST = {
28
+ schemaVersion: 1,
29
+ entries: [
30
+ { id: "crm_list_deals", plane: "org", reachable: true },
31
+ { id: "board_ready", plane: "org", reachable: true },
32
+ { id: "email_inbox", plane: "org", reachable: false },
33
+ ],
34
+ };
35
+
36
+ function planDoc(over = {}) {
37
+ return {
38
+ schemaVersion: 1,
39
+ obligationsHash: "HASH_OK",
40
+ obligations: [
41
+ { key: "schedule.measure.pipeline", kind: "SCHEDULE", status: "active", uses: ["crm_list_deals"] },
42
+ { key: "outcome.pipeline", kind: "OUTCOME", status: "active", uses: ["crm_list_deals"], sensor: { capability: "crm_list_deals", source: "method" } },
43
+ ],
44
+ ...over,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * A fully injected world. Nothing here touches the real filesystem — the point
50
+ * of the deps seam is that a check can be proven without a scaffolded agent.
51
+ */
52
+ function deps({ plan = planDoc(), manifest = MANIFEST, lock = { obligationsHash: "HASH_OK" }, cache = { fetchedAt: "2026-08-11T09:00:00.000Z" }, planMissing = false } = {}) {
53
+ return {
54
+ yaml,
55
+ existsSync: (p) => (String(p).endsWith("plan.yaml") ? !planMissing : true),
56
+ readFileSync: (p) => {
57
+ if (String(p).endsWith("plan.yaml")) return yaml.dump(plan);
58
+ throw new Error(`unexpected read: ${p}`);
59
+ },
60
+ readManifest: () => manifest,
61
+ readLock: () => lock,
62
+ readCache: () => cache,
63
+ };
64
+ }
65
+
66
+ const byName = (checks, re) => checks.find((c) => re.test(c.name));
67
+
68
+ test("a healthy plan passes all four checks", async () => {
69
+ const checks = await planChecks({ agentRoot: "/agent", now: NOW }, deps());
70
+ assert.ok(checks.every((c) => c.ok), `all ok, got: ${JSON.stringify(checks.filter((c) => !c.ok))}`);
71
+ assert.ok(byName(checks, /plan compiled/).ok);
72
+ assert.ok(byName(checks, /obligationsHash/).ok);
73
+ assert.ok(byName(checks, /unreachable capability/).ok);
74
+ assert.ok(byName(checks, /method sensor/).ok);
75
+ assert.ok(byName(checks, /mandate cache fresh/).ok);
76
+ });
77
+
78
+ test("no plan on disk is SOFT but named — it never fails setup silently", async () => {
79
+ const checks = await planChecks({ agentRoot: "/agent", now: NOW }, deps({ planMissing: true }));
80
+ assert.equal(checks.length, 1);
81
+ assert.equal(checks[0].ok, true, "an un-adopted agent is a legitimate state");
82
+ assert.match(checks[0].remedy, /standard \+ archetype cadences only/);
83
+ });
84
+
85
+ test("a hand-edited plan.yaml is caught by the compile-lock hash", async () => {
86
+ const checks = await planChecks({ agentRoot: "/agent", now: NOW }, deps({ lock: { obligationsHash: "SOMETHING_ELSE" } }));
87
+ const c = byName(checks, /obligationsHash/);
88
+ assert.equal(c.ok, false);
89
+ assert.match(c.remedy, /edited by hand|stale/);
90
+ });
91
+
92
+ test("a missing compile lock is reported, not treated as a pass", async () => {
93
+ const checks = await planChecks({ agentRoot: "/agent", now: NOW }, deps({ lock: null }));
94
+ const c = byName(checks, /obligationsHash/);
95
+ assert.equal(c.ok, false);
96
+ assert.match(c.remedy, /no state\/plan\/compile\.lock/);
97
+ });
98
+
99
+ test("THE LAW: an obligation citing an unreachable capability fails the check", async () => {
100
+ // email_inbox is reachable:false in the manifest.
101
+ const plan = planDoc({
102
+ obligations: [{ key: "schedule.inbox", kind: "SCHEDULE", status: "active", uses: ["crm_list_deals", "email_inbox"] }],
103
+ });
104
+ const checks = await planChecks({ agentRoot: "/agent", now: NOW }, deps({ plan }));
105
+ const c = byName(checks, /unreachable capability/);
106
+ assert.equal(c.ok, false);
107
+ assert.match(c.remedy, /schedule\.inbox → email_inbox/);
108
+ });
109
+
110
+ test("no capability manifest → the reachability law cannot be checked, and says so", async () => {
111
+ const checks = await planChecks({ agentRoot: "/agent", now: NOW }, deps({ manifest: null }));
112
+ const c = byName(checks, /unreachable capability/);
113
+ assert.equal(c.ok, false);
114
+ assert.match(c.remedy, /capability-manifest\.json/);
115
+ });
116
+
117
+ test("an OUTCOME resting on an LLM sensor fails the honesty gate", async () => {
118
+ const plan = planDoc({
119
+ obligations: [{ key: "outcome.vibes", kind: "OUTCOME", status: "active", uses: ["crm_list_deals"], sensor: { capability: "crm_list_deals", source: "llm" } }],
120
+ });
121
+ const checks = await planChecks({ agentRoot: "/agent", now: NOW }, deps({ plan }));
122
+ const c = byName(checks, /method sensor/);
123
+ assert.equal(c.ok, false);
124
+ assert.match(c.remedy, /outcome\.vibes/);
125
+ assert.match(c.remedy, /cannot create work/);
126
+ });
127
+
128
+ test("a stale mandate cache (>24h) fails freshness", async () => {
129
+ const stale = { fetchedAt: new Date(NOW - MANDATE_FRESH_MS - 60_000).toISOString() };
130
+ const checks = await planChecks({ agentRoot: "/agent", now: NOW }, deps({ cache: stale }));
131
+ const c = byName(checks, /mandate cache fresh/);
132
+ assert.equal(c.ok, false);
133
+ assert.match(c.remedy, /self-directed work is suspended/);
134
+ });
135
+
136
+ test("no mandate cache is soft (un-enrolled agent) but still named", async () => {
137
+ const checks = await planChecks({ agentRoot: "/agent", now: NOW }, deps({ cache: null }));
138
+ const c = byName(checks, /mandate cache present/);
139
+ assert.equal(c.ok, true);
140
+ assert.match(c.remedy, /no adopted mandate yet/);
141
+ });
142
+
143
+ test("an unreadable plan.yaml is a reported failure, not a thrown setup", async () => {
144
+ const d = deps();
145
+ d.readFileSync = () => "{{{ not yaml :::";
146
+ const checks = await planChecks({ agentRoot: "/agent", now: NOW }, d);
147
+ assert.equal(checks[0].ok, false);
148
+ assert.match(checks[0].remedy, /generate-plan\.mjs/);
149
+ });
150
+
151
+ test("planChecks is wired into the verify section's rows under section 'plan'", async () => {
152
+ const rows = [];
153
+ const res = await verifySection.verify({
154
+ agentRoot: "/agent",
155
+ sections: [],
156
+ planDeps: deps(),
157
+ }).catch((e) => ({ error: e }));
158
+ // The core probes (completeness/claude) may legitimately fail on a fake root;
159
+ // what must hold is that the plan rows are present and attributed.
160
+ const planRows = ((res && res.rows) || rows).filter((r) => r.section === "plan");
161
+ assert.ok(planRows.length >= 4, `expected the plan checks in the rows, got ${JSON.stringify((res && res.rows) || []).slice(0, 200)}`);
162
+ assert.ok(planRows.some((r) => /obligationsHash/.test(r.name)));
163
+ });
164
+
165
+ test("the verify section is registered by the directory glob at order 90", async () => {
166
+ const sections = await loadSections();
167
+ const v = sections.find((s) => s.id === "verify");
168
+ assert.ok(v, "verify is discovered by loadSections()");
169
+ assert.equal(v.order, 90);
170
+ // It must run AFTER the two init-pipeline sections it gates.
171
+ const inventory = sections.find((s) => s.id === "inventory");
172
+ const mandate = sections.find((s) => s.id === "mandate");
173
+ assert.ok(inventory.order < v.order, "inventory (55) precedes verify (90)");
174
+ assert.ok(mandate.order < v.order, "mandate (78) precedes verify (90)");
175
+ });
package/lib/setup/sot.mjs CHANGED
@@ -192,6 +192,8 @@ export interface AgentConfig {
192
192
  voiceModes: VoiceMode[];
193
193
  };
194
194
 
195
+ /** Free prose background/bio, authored by the org (Cohort profile section). */
196
+ persona?: string;
195
197
  responsibilities: string[];
196
198
  operatingPrinciples: string[];
197
199
  }
@@ -0,0 +1,463 @@
1
+ /**
2
+ * lib/subagents/cli.mjs — backs `maestro subagents <sub>` (§2.4).
3
+ *
4
+ * WHY A LIBRARY AND NOT A `case` BLOCK IN bin/maestro.mjs. `bin/maestro.mjs` is a
5
+ * 3000-line dispatcher that no test can import without executing; putting the
6
+ * registry's whole verb surface in it would make it untestable, which for a
7
+ * module that writes files and calls a remote API is not acceptable. `run()`
8
+ * returns `{ok, code, lines}` and only PRINTS when asked to, so every subcommand
9
+ * has a unit test. bin/maestro.mjs keeps a four-line `case "subagents"`.
10
+ *
11
+ * Commands (all local-first; only `pull`/`publish`/`add`/`fork`/`drop` network,
12
+ * and all of those degrade to the outbox):
13
+ *
14
+ * list resolved roster + winning layer + version per slug
15
+ * status the §2.3 three-way diff (what `doctor` renders)
16
+ * show <slug> resolved body + provenance
17
+ * diff <slug> local file vs the layer it was materialised from
18
+ * add <slug> new local def from the generator skeleton → create+pin
19
+ * fork <slug> [--as <n>] copy the resolved body into a new org definition
20
+ * publish <slug> publishVersion from the on-disk file
21
+ * pull [--force] re-resolve + re-materialise (--force overwrites edits)
22
+ * drop <slug> [--yank] unpin (and yank with --yank) — never a row delete
23
+ * export <slug> --to-sdk write into a local SDK checkout + print the PR line
24
+ *
25
+ * `export --to-sdk` is the ENTIRE "push upstream to the global set" story in v1:
26
+ * no new auth, no PAT, no hq surface (§5.4). The global channel is the npm
27
+ * package; promotion is a human PR.
28
+ *
29
+ * Node builtins only. ESM.
30
+ *
31
+ * @module lib/subagents/cli
32
+ */
33
+
34
+ "use strict";
35
+
36
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
37
+ import { dirname, join } from "node:path";
38
+ import { fileURLToPath } from "node:url";
39
+ import { parseArgs } from "node:util";
40
+ import { writeFileAtomic, writeJsonAtomic } from "../fs-atomic.mjs";
41
+ import { resolveAgentRoot } from "../agent-root.mjs";
42
+ import * as client from "./client.mjs";
43
+ import { acceptDrop, mergeLock, readLock } from "./lock.mjs";
44
+ import { buildManifest, manifestPath, readManifest } from "./manifest.mjs";
45
+ import { computeGaps, contextFromConfig, rosterFromSurface } from "./gap.mjs";
46
+ import { scanAgentRefs } from "./refs.mjs";
47
+ import { agentFilePath, materialiseAll, renderEntry, resolveSubagents } from "./resolve.mjs";
48
+ import { checkAgentFile, hashFile, parseAgentMd, stringifyAgentMd, stripProvenance } from "./schema.mjs";
49
+
50
+ /** Flags every subcommand shares. */
51
+ const FLAGS = {
52
+ force: { type: "boolean", default: false },
53
+ yank: { type: "boolean", default: false },
54
+ json: { type: "boolean", default: false },
55
+ as: { type: "string" },
56
+ "to-sdk": { type: "string" },
57
+ "no-network": { type: "boolean", default: false },
58
+ };
59
+
60
+ /** Read config/agent.json. Fail-open → {}. */
61
+ function readAgentConfig(agentRoot) {
62
+ const p = join(agentRoot, "config", "agent.json");
63
+ if (!existsSync(p)) return {};
64
+ try { return JSON.parse(readFileSync(p, "utf8")) || {}; } catch { return {}; }
65
+ }
66
+
67
+ /**
68
+ * Assemble everything the local-first commands need in one pass: the pins
69
+ * (network-optional), the resolution, the roster and the reference scan.
70
+ *
71
+ * `pins` come from `subagent.resolve` when the network is available and from the
72
+ * cache otherwise — the caller never has to know which, which is the point of
73
+ * §4's local-first contract.
74
+ *
75
+ * `force` is passed straight through to the resolver, where it DEMOTES the local
76
+ * layer so `pull --force` can actually re-resolve an edited file to its upstream
77
+ * layer. Every other command leaves it off, so a local edit always wins there.
78
+ *
79
+ * @param {{agentRoot:string, maestroRoot:string, network?:boolean, force?:boolean, fetchImpl?:Function, env?:object}} o
80
+ */
81
+ export async function loadState(o = {}) {
82
+ const agentRoot = o.agentRoot;
83
+ const maestroRoot = o.maestroRoot;
84
+ const cfg = client.resolveConfig({ agentRoot, env: o.env });
85
+ const lock = readLock(agentRoot);
86
+ const manifest = readManifest(maestroRoot);
87
+ const agentConfig = readAgentConfig(agentRoot);
88
+
89
+ let pins = [];
90
+ let degraded = [];
91
+ let rosterVersion = lock.rosterVersion;
92
+ if (o.network !== false && cfg.enabled) {
93
+ const r = await client.fetchResolve({
94
+ cfg,
95
+ agentRoot,
96
+ memberId: cfg.memberId || undefined,
97
+ function: agentConfig.function,
98
+ altitude: agentConfig.altitude,
99
+ fetchImpl: o.fetchImpl,
100
+ });
101
+ if (r.ok) { pins = r.entries; rosterVersion = r.rosterVersion; }
102
+ else degraded = ["workspace"];
103
+ } else if (!cfg.enabled) {
104
+ // Not enrolled is not a failure — layer 1 simply does not exist (§4).
105
+ degraded = [];
106
+ } else {
107
+ degraded = ["workspace"];
108
+ }
109
+
110
+ // With no live pins, reconstruct layer 1 from the lock + cache so an offline
111
+ // run still resolves workspace-pinned agents rather than silently demoting
112
+ // them to SDK (which would materialise the WRONG body).
113
+ if (!pins.length) {
114
+ pins = Object.entries(lock.agents || {})
115
+ .filter(([, e]) => e && e.layer === "workspace")
116
+ .map(([slug, e]) => ({ slug, definitionId: e.definitionId, version: e.version, contentHash: e.contentHash }));
117
+ }
118
+
119
+ const resolved = resolveSubagents({ agentRoot, maestroRoot, pins, manifest, lock, orgSlug: cfg.orgId, force: !!o.force });
120
+ const refs = scanAgentRefs({ agentRoot });
121
+ return { cfg, lock, manifest, agentConfig, pins, resolved, refs, degraded, rosterVersion };
122
+ }
123
+
124
+ /** Resolve the authoritative roster from the capability surface. Fail-open → []. */
125
+ export async function loadRoster(agentConfig, opts = {}) {
126
+ if (typeof opts.resolveSurfaceImpl === "function") {
127
+ try { return rosterFromSurface(await opts.resolveSurfaceImpl({ function: agentConfig.function, altitude: agentConfig.altitude })); }
128
+ catch { return []; }
129
+ }
130
+ if (!agentConfig.function || !agentConfig.altitude) return [];
131
+ try {
132
+ const mod = await import("../capability.mjs");
133
+ const surface = await mod.resolveCapabilitySurface({ function: agentConfig.function, altitude: agentConfig.altitude });
134
+ return rosterFromSurface(surface);
135
+ } catch {
136
+ // No capability pack for this function → no authoritative roster. Everything
137
+ // on disk still resolves; only `missing`/`idle` go unreported.
138
+ return [];
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Run a `maestro subagents` subcommand.
144
+ *
145
+ * @param {string[]} argv args AFTER `subagents`
146
+ * @param {{agentRoot?:string, maestroRoot?:string, fetchImpl?:Function, env?:object, print?:boolean, resolveSurfaceImpl?:Function}} [opts]
147
+ * @returns {Promise<{ok:boolean, code:number, lines:string[], data?:object}>}
148
+ */
149
+ export async function run(argv = [], opts = {}) {
150
+ const agentRoot = opts.agentRoot || resolveAgentRoot();
151
+ const maestroRoot = opts.maestroRoot || join(dirname(fileURLToPath(import.meta.url)), "..", "..");
152
+ const lines = [];
153
+ const say = (s) => { lines.push(s); if (opts.print !== false) console.log(s); };
154
+
155
+ let parsed;
156
+ try {
157
+ parsed = parseArgs({ args: argv, options: FLAGS, allowPositionals: true, strict: false });
158
+ } catch (e) {
159
+ return { ok: false, code: 2, lines: [`subagents: ${e && e.message ? e.message : e}`] };
160
+ }
161
+ const [sub, ...rest] = parsed.positionals;
162
+ const flags = parsed.values;
163
+ const network = !flags["no-network"];
164
+ const ctx = { agentRoot, maestroRoot, network, fetchImpl: opts.fetchImpl, env: opts.env };
165
+
166
+ switch (sub) {
167
+ case undefined:
168
+ case "help":
169
+ say(usage());
170
+ return { ok: true, code: 0, lines };
171
+
172
+ case "list": {
173
+ const st = await loadState(ctx);
174
+ if (flags.json) { say(JSON.stringify(st.resolved.entries.map(slim), null, 2)); return { ok: true, code: 0, lines, data: st.resolved }; }
175
+ say(`Sub-agents (${st.resolved.entries.length}) — agentRoot ${agentRoot}`);
176
+ for (const e of st.resolved.entries) {
177
+ const v = e.version == null ? "-" : String(e.version);
178
+ say(` ${e.slug.padEnd(28)} ${e.layer.padEnd(10)} v${v.padEnd(8)} ${e.localEdits ? "(local edit)" : e.orphaned ? "(orphaned)" : ""}`);
179
+ }
180
+ if (st.degraded.length) say(` ! degraded: ${st.degraded.join(", ")} — resolved from cache/local only`);
181
+ return { ok: true, code: 0, lines, data: st.resolved };
182
+ }
183
+
184
+ case "status": {
185
+ const st = await loadState(ctx);
186
+ const roster = await loadRoster(st.agentConfig, opts);
187
+ const gaps = computeGaps({
188
+ roster, resolved: st.resolved, refs: st.refs, lock: st.lock,
189
+ context: contextFromConfig({ agentConfig: st.agentConfig }),
190
+ });
191
+ if (flags.json) { say(JSON.stringify(gaps, null, 2)); return { ok: gaps.status === "complete", code: 0, lines, data: gaps }; }
192
+ say(`Sub-agent registry — ${gaps.summary}`);
193
+ if (gaps.missing.length) {
194
+ say(`\n MISSING (${gaps.missing.length}) — on the roster, no layer resolves them:`);
195
+ for (const m of gaps.missing) say(` ${m.slug.padEnd(28)} ${m.role || ""}`);
196
+ }
197
+ if (gaps.unrostered.length) {
198
+ say(`\n UNROSTERED (${gaps.unrostered.length}) — referenced but not on the roster:`);
199
+ for (const u of gaps.unrostered) {
200
+ const at = u.locations[0] ? `${u.locations[0].file}:${u.locations[0].line}` : "";
201
+ say(` ${u.ref.padEnd(28)} ${u.action}${u.suggest ? ` → ${u.suggest}` : ""}${u.accepted ? " (drop accepted)" : ""} ${at}`);
202
+ }
203
+ }
204
+ if (gaps.idle.length) {
205
+ say(`\n IDLE (${gaps.idle.length}) — on the roster, referenced by nothing:`);
206
+ for (const i of gaps.idle) say(` ${i.slug}`);
207
+ }
208
+ if (gaps.localEdits.length) say(`\n LOCAL EDITS (${gaps.localEdits.length}): ${gaps.localEdits.join(", ")}`);
209
+ if (gaps.emptyDirs.length) say(`\n EMPTY DIRS (${gaps.emptyDirs.length}) — agents/<slug>/ with no agent.md: ${gaps.emptyDirs.join(", ")}`);
210
+ return { ok: gaps.status === "complete", code: 0, lines, data: gaps };
211
+ }
212
+
213
+ case "show": {
214
+ const slug = rest[0];
215
+ if (!slug) return fail(lines, "usage: maestro subagents show <slug>");
216
+ const st = await loadState(ctx);
217
+ const e = st.resolved.bySlug.get(slug);
218
+ if (!e) return fail(lines, `no sub-agent resolves for "${slug}"`);
219
+ say(`# ${slug}`);
220
+ say(`layer: ${e.layer}`);
221
+ say(`source: ${e.source}`);
222
+ say(`version: ${e.version == null ? "-" : e.version}`);
223
+ say(`hash: ${e.contentHash}`);
224
+ say("");
225
+ say(e.body);
226
+ return { ok: true, code: 0, lines, data: e };
227
+ }
228
+
229
+ case "diff": {
230
+ const slug = rest[0];
231
+ if (!slug) return fail(lines, "usage: maestro subagents diff <slug>");
232
+ const st = await loadState(ctx);
233
+ const e = st.resolved.bySlug.get(slug);
234
+ if (!e) return fail(lines, `no sub-agent resolves for "${slug}"`);
235
+ const p = agentFilePath(agentRoot, slug);
236
+ const onDisk = existsSync(p) ? readFileSync(p, "utf8") : "";
237
+ // Render through the SAME function materialise uses. Deriving the expected
238
+ // bytes any other way skips normalisation and reports every managed file as
239
+ // differing from the layer it was written from.
240
+ const rendered = renderEntry(e);
241
+ if (!rendered.ok) return fail(lines, `${slug}: cannot render the ${e.layer} layer — ${rendered.reason}`);
242
+ const want = rendered.text;
243
+ if (onDisk === want) { say(`${slug}: identical to the ${e.layer} layer`); return { ok: true, code: 0, lines }; }
244
+ say(`${slug}: differs from the ${e.layer} layer`);
245
+ for (const l of lineDiff(onDisk, want)) say(l);
246
+ return { ok: true, code: 0, lines, data: { slug, onDiskHash: hashFile(onDisk), layerHash: hashFile(want) } };
247
+ }
248
+
249
+ case "pull": {
250
+ // --force here is two things at once: demote the local layer during
251
+ // resolution AND permit the write. Both are needed — either alone is a
252
+ // no-op against an edited file.
253
+ const st = await loadState({ ...ctx, force: !!flags.force });
254
+ const out = materialiseAll({ agentRoot, resolved: st.resolved, force: !!flags.force });
255
+ mergeLock(agentRoot, { agents: out.lockAgents, rosterVersion: st.rosterVersion, degraded: st.degraded });
256
+ say(`pull: ${out.written} written, ${out.results.length - out.written} unchanged/refused`);
257
+ for (const r of out.results) if (!r.written && r.errors) say(` ! ${r.slug}: ${r.reason}`);
258
+ if (st.degraded.length) say(` ! degraded: ${st.degraded.join(", ")}`);
259
+ return { ok: true, code: 0, lines, data: out };
260
+ }
261
+
262
+ case "add": {
263
+ const slug = rest[0];
264
+ if (!slug) return fail(lines, "usage: maestro subagents add <slug>");
265
+ const st = await loadState(ctx);
266
+ const p = agentFilePath(agentRoot, slug);
267
+ if (existsSync(p) && !flags.force) return fail(lines, `agents/${slug}/agent.md already exists (use --force to re-scaffold)`);
268
+ const text = skeleton(slug, st.agentConfig);
269
+ mkdirSync(join(agentRoot, "agents", slug), { recursive: true });
270
+ writeFileAtomic(p, text);
271
+ say(`wrote agents/${slug}/agent.md`);
272
+ const parsedMd = parseAgentMd(text);
273
+ const r = await client.createDefinition(
274
+ { slug, title: slug, summary: parsedMd.frontmatter.description || slug, frontmatter: parsedMd.frontmatter, body: parsedMd.body, generator: { source: "human" } },
275
+ { cfg: st.cfg, agentRoot, fetchImpl: opts.fetchImpl },
276
+ );
277
+ if (r.ok) {
278
+ say(`created workspace definition ${slug}`);
279
+ const pinRes = await client.pinDefinition({ slug, definitionId: r.result && r.result.definitionId, version: r.result && r.result.version }, { cfg: st.cfg, agentRoot, fetchImpl: opts.fetchImpl });
280
+ say(pinRes.ok ? `pinned ${slug}` : `pin queued/failed: ${describe(pinRes)}`);
281
+ } else {
282
+ say(`workspace create ${describe(r)}`);
283
+ }
284
+ return { ok: true, code: 0, lines, data: { slug, path: p } };
285
+ }
286
+
287
+ case "fork": {
288
+ const slug = rest[0];
289
+ if (!slug) return fail(lines, "usage: maestro subagents fork <slug> [--as <new-slug>]");
290
+ const st = await loadState(ctx);
291
+ const src = st.resolved.bySlug.get(slug);
292
+ if (!src) return fail(lines, `no sub-agent resolves for "${slug}"`);
293
+ const target = String(flags.as || slug);
294
+ // A fork OWNS its body: the copy's frontmatter.name must be the NEW slug or
295
+ // it will fail validation the moment it is materialised (name == dirname).
296
+ const fm = { ...stripProvenance(src.frontmatter), name: target };
297
+ const r = await client.forkDefinition(
298
+ { slug: target, baseLayer: src.layer === "sdk" ? "sdk" : "none", baseSlug: slug, title: target, summary: fm.description || target, frontmatter: fm, body: src.body },
299
+ { cfg: st.cfg, agentRoot, fetchImpl: opts.fetchImpl },
300
+ );
301
+ say(r.ok ? `forked ${slug} → ${target}` : `fork ${describe(r)}`);
302
+ if (r.ok) {
303
+ const pinRes = await client.pinDefinition({ slug: target, definitionId: r.result && r.result.definitionId, version: r.result && r.result.version }, { cfg: st.cfg, agentRoot, fetchImpl: opts.fetchImpl });
304
+ say(pinRes.ok ? `pinned ${target}` : `pin ${describe(pinRes)}`);
305
+ }
306
+ return { ok: !!r.ok, code: r.ok ? 0 : 1, lines, data: r };
307
+ }
308
+
309
+ case "publish": {
310
+ const slug = rest[0];
311
+ if (!slug) return fail(lines, "usage: maestro subagents publish <slug>");
312
+ const p = agentFilePath(agentRoot, slug);
313
+ if (!existsSync(p)) return fail(lines, `agents/${slug}/agent.md does not exist`);
314
+ const text = readFileSync(p, "utf8");
315
+ const checked = checkAgentFile(text, slug);
316
+ if (!checked.ok) {
317
+ // Refuse locally rather than let hq reject it — the operator gets the
318
+ // full error list instead of one server-side message.
319
+ for (const err of checked.errors) say(` ! ${err}`);
320
+ return fail(lines, `${slug} does not validate; fix it before publishing`);
321
+ }
322
+ const st = await loadState(ctx);
323
+ const lockEntry = st.lock.agents[slug] || {};
324
+ const r = await client.publishVersion(
325
+ { slug, definitionId: lockEntry.definitionId || undefined, frontmatter: stripProvenance(checked.frontmatter), body: checked.body, generator: { source: "human" } },
326
+ { cfg: st.cfg, agentRoot, fetchImpl: opts.fetchImpl },
327
+ );
328
+ say(r.ok ? `published ${slug} v${(r.result && r.result.version) || "?"}` : `publish ${describe(r)}`);
329
+ return { ok: !!r.ok, code: r.ok ? 0 : 1, lines, data: r };
330
+ }
331
+
332
+ case "drop": {
333
+ const slug = rest[0];
334
+ if (!slug) return fail(lines, "usage: maestro subagents drop <slug> [--yank]");
335
+ const st = await loadState(ctx);
336
+ const lockEntry = st.lock.agents[slug] || {};
337
+ const r = await client.unpinDefinition({ slug, definitionId: lockEntry.definitionId || undefined }, { cfg: st.cfg, agentRoot, fetchImpl: opts.fetchImpl });
338
+ say(r.ok ? `unpinned ${slug}` : `unpin ${describe(r)}`);
339
+ if (flags.yank) {
340
+ const y = await client.yankDefinition({ slug, definitionId: lockEntry.definitionId || undefined, reason: "dropped from CLI" }, { cfg: st.cfg, agentRoot, fetchImpl: opts.fetchImpl });
341
+ say(y.ok ? `yanked ${slug}` : `yank ${describe(y)}`);
342
+ }
343
+ // The file is NOT deleted: dropping a pin removes the workspace layer, and
344
+ // the next `pull` will re-resolve the slug to SDK or leave the local file.
345
+ acceptDrop(agentRoot, { ref: slug, reason: "unpinned via CLI" });
346
+ return { ok: !!r.ok, code: r.ok ? 0 : 1, lines, data: r };
347
+ }
348
+
349
+ case "export": {
350
+ const slug = rest[0];
351
+ const toSdk = flags["to-sdk"];
352
+ if (!slug || !toSdk) return fail(lines, "usage: maestro subagents export <slug> --to-sdk <path-to-sdk-checkout>");
353
+ const st = await loadState(ctx);
354
+ const e = st.resolved.bySlug.get(slug);
355
+ if (!e) return fail(lines, `no sub-agent resolves for "${slug}"`);
356
+ // Provenance is stripped: what lands in the SDK is the DEFINITION, not a
357
+ // record of one org's copy of it.
358
+ const text = stringifyAgentMd({ ...stripProvenance(e.frontmatter), name: slug }, e.body);
359
+ const dest = join(toSdk, "agents", slug, "agent.md");
360
+ mkdirSync(join(toSdk, "agents", slug), { recursive: true });
361
+ writeFileAtomic(dest, text);
362
+ const built = buildManifest({ maestroRoot: toSdk, sdkVersion: readManifest(toSdk).sdkVersion, previous: readManifest(toSdk) });
363
+ writeJsonAtomic(manifestPath(toSdk), built.manifest);
364
+ say(`wrote ${dest}`);
365
+ say(`updated ${manifestPath(toSdk)}`);
366
+ say("");
367
+ say("Promotion to the global set is a human PR (v1 has no hq global catalogue):");
368
+ say(` cd ${toSdk} && git checkout -b subagent/${slug} && git add agents/${slug} agents/manifest.json \\`);
369
+ say(` && git commit -m "feat(agents): add ${slug}" \\`);
370
+ say(` && gh pr create --title "feat(agents): add ${slug}" --body "Promoted from a workspace definition."`);
371
+ return { ok: true, code: 0, lines, data: { dest } };
372
+ }
373
+
374
+ default:
375
+ say(usage());
376
+ return { ok: false, code: 2, lines };
377
+ }
378
+ }
379
+
380
+ function fail(lines, msg) {
381
+ lines.push(msg);
382
+ console.error(msg);
383
+ return { ok: false, code: 1, lines };
384
+ }
385
+
386
+ function describe(r) {
387
+ if (r.queued) return `queued offline → ${r.queued}`;
388
+ if (r.degraded) return "not sent (server unreachable, not enrolled, or no outbox)";
389
+ return `failed: ${(r.error && r.error.message) || "unknown error"}`;
390
+ }
391
+
392
+ function slim(e) {
393
+ return { slug: e.slug, layer: e.layer, version: e.version, source: e.source, contentHash: e.contentHash, localEdits: !!e.localEdits };
394
+ }
395
+
396
+ /** A minimal unified-ish line diff — enough to see what changed, no dependency. */
397
+ function lineDiff(a, b) {
398
+ const al = String(a).split("\n");
399
+ const bl = String(b).split("\n");
400
+ const out = [];
401
+ const max = Math.max(al.length, bl.length);
402
+ for (let i = 0; i < max; i++) {
403
+ if (al[i] === bl[i]) continue;
404
+ if (al[i] !== undefined) out.push(` - ${al[i]}`);
405
+ if (bl[i] !== undefined) out.push(` + ${bl[i]}`);
406
+ }
407
+ return out.slice(0, 200);
408
+ }
409
+
410
+ /**
411
+ * The `add` skeleton — the SAME shape `scripts/setup/generate-capability.mjs`
412
+ * renders, with a FULL model id and a `tools` array (the two fields that used to
413
+ * drift between shipped and generated agents).
414
+ */
415
+ export function skeleton(slug, agentConfig = {}) {
416
+ const who = agentConfig.fullName || "the agent";
417
+ const fm = {
418
+ name: slug,
419
+ description: `TODO — one sentence describing when to delegate to ${slug}.`,
420
+ model: "claude-sonnet-4-6",
421
+ tools: ["Read", "Write", "Edit", "Glob", "Grep"],
422
+ tokens: [],
423
+ };
424
+ const body = `# ${slug}
425
+
426
+ ## Mandate
427
+
428
+ TODO — what this sub-agent owns, in one paragraph.
429
+
430
+ ## Operating rules
431
+
432
+ - Act within {{agent.firstName}}'s autonomy model and decision rights — see \`config/operating-charter.md\`.
433
+ - Follow \`policies/communication-style.md\` for any outbound communication.
434
+ - Escalate anything beyond your remit to {{agent.firstName}}.
435
+
436
+ _Scaffolded by \`maestro subagents add ${slug}\` for ${who}. Enrich this mandate before publishing._
437
+ `;
438
+ // Tokens are DECLARED from the body so the file validates on the first pass.
439
+ const used = [...new Set((body.match(/\{\{\s*([\w.]+)\s*\}\}/g) || []).map((m) => m.replace(/[{}\s]/g, "")))].sort();
440
+ fm.tokens = used;
441
+ return stringifyAgentMd(fm, body);
442
+ }
443
+
444
+ function usage() {
445
+ return [
446
+ "usage: maestro subagents <command>",
447
+ "",
448
+ " list resolved roster (winning layer + version per slug)",
449
+ " status [--json] the three-way gap diff (roster / refs / layers)",
450
+ " show <slug> resolved body + provenance",
451
+ " diff <slug> local file vs the layer it came from",
452
+ " add <slug> scaffold a new local def, then create + pin it",
453
+ " fork <slug> [--as <new>] copy the resolved body into a new org definition",
454
+ " publish <slug> promote the on-disk file as a new version",
455
+ " pull [--force] re-resolve + re-materialise (--force overwrites edits)",
456
+ " drop <slug> [--yank] unpin (and yank) — never a row delete",
457
+ " export <slug> --to-sdk <path> write into an SDK checkout + print the PR line",
458
+ "",
459
+ " --no-network never call hq (resolve from lock + cache + SDK)",
460
+ ].join("\n");
461
+ }
462
+
463
+ export { usage };