@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,140 @@
1
+ /**
2
+ * lib/plan/schema.mjs — the OBLIGATION schema + a fail-closed validator.
3
+ *
4
+ * The plan is the agent's half of the ownership split: hq owns the mandate, the
5
+ * agent owns the obligations, the schedule and the backlog. An obligation is the
6
+ * unit of that plan — one of three kinds:
7
+ *
8
+ * REACT — "when <topic/kind> arrives and <predicate> holds, do this"
9
+ * SCHEDULE — "at <interval|calendar>, run this" (compiles to a launchd plist)
10
+ * OUTCOME — "this number, measured by this sensor, must reach this target"
11
+ *
12
+ * Validation is dependency-free and FAIL-CLOSED (mirrors lib/archetype.mjs's
13
+ * validateProfile): a plan that does not validate is not written, because a
14
+ * half-valid plan silently emits half a schedule, and a missing launchd job is
15
+ * exactly the class of silent failure this system keeps growing.
16
+ *
17
+ * Two structural laws are enforced here rather than by convention:
18
+ * 1. every `uses[]` id must be REACHABLE in the capability manifest
19
+ * 2. every OUTCOME obligation must carry a sensor, and a `source:"llm"` sensor
20
+ * is never allowed to close a loop (it is advisory only)
21
+ *
22
+ * PURE. No fs, no clock, no network.
23
+ *
24
+ * @module lib/plan/schema
25
+ */
26
+
27
+ "use strict";
28
+
29
+ /** The three obligation kinds. */
30
+ export const OBLIGATION_KINDS = Object.freeze(["REACT", "SCHEDULE", "OUTCOME"]);
31
+ /** Where an obligation came from (its provenance origin). */
32
+ export const ORIGINS = Object.freeze(["standard", "archetype", "mandate", "react-standard"]);
33
+ /** Execution modes the cadence consumer understands (unchanged vocabulary). */
34
+ export const MODES = Object.freeze(["inline", "guarded", "escalate"]);
35
+ /** Blast-radius classes that force an approval before any rung executes. */
36
+ export const ACTION_CLASSES = Object.freeze(["internal", "external", "irreversible", "financial"]);
37
+ /** Sensor provenance — the honesty gate. */
38
+ export const SENSOR_SOURCES = Object.freeze(["method", "human", "llm"]);
39
+ /** Statuses an obligation can hold locally. */
40
+ export const STATUSES = Object.freeze(["active", "suspended", "proposed"]);
41
+
42
+ /**
43
+ * Validate ONE obligation.
44
+ * @param {object} ob
45
+ * @param {{reachable?:Set<string>}} [o]
46
+ * @returns {string[]} error strings (empty = valid)
47
+ */
48
+ export function validateObligation(ob, o = {}) {
49
+ const errs = [];
50
+ const at = (m) => `obligation ${(ob && ob.key) || "<no key>"}: ${m}`;
51
+ if (!ob || typeof ob !== "object") return ["obligation is not an object"];
52
+
53
+ if (!ob.key || typeof ob.key !== "string") errs.push(at("missing key"));
54
+ else if (!/^[a-z0-9][a-z0-9._-]*$/.test(ob.key)) errs.push(at(`key "${ob.key}" is not a stable slug ([a-z0-9._-])`));
55
+
56
+ if (!OBLIGATION_KINDS.includes(ob.kind)) errs.push(at(`kind must be one of ${OBLIGATION_KINDS.join("|")} (got ${JSON.stringify(ob.kind)})`));
57
+
58
+ if (!ob.source || typeof ob.source !== "object") errs.push(at("missing source provenance"));
59
+ else if (!ORIGINS.includes(ob.source.origin)) errs.push(at(`source.origin must be one of ${ORIGINS.join("|")}`));
60
+
61
+ if (!STATUSES.includes(ob.status)) errs.push(at(`status must be one of ${STATUSES.join("|")}`));
62
+
63
+ if (!Array.isArray(ob.uses)) errs.push(at("uses[] must be an array"));
64
+ if (!Array.isArray(ob.allowed_tools)) errs.push(at("allowed_tools[] must be an array"));
65
+ if (!Array.isArray(ob.action_classes)) errs.push(at("action_classes[] must be an array"));
66
+ else for (const c of ob.action_classes) if (!ACTION_CLASSES.includes(c)) errs.push(at(`unknown action class "${c}"`));
67
+
68
+ if (typeof ob.offline_safe !== "boolean") errs.push(at("offline_safe must be a boolean"));
69
+ if (ob.budget_cents_per_period != null && !Number.isFinite(ob.budget_cents_per_period)) errs.push(at("budget_cents_per_period must be a number"));
70
+
71
+ // Kind-specific structure.
72
+ if (ob.kind === "SCHEDULE") {
73
+ const hasInterval = ob.schedule && Number.isFinite(ob.schedule.interval);
74
+ const hasCalendar = ob.schedule && ob.schedule.calendar && typeof ob.schedule.calendar === "object";
75
+ if (!hasInterval && !hasCalendar) errs.push(at("SCHEDULE needs schedule.interval or schedule.calendar"));
76
+ if (!MODES.includes(ob.mode)) errs.push(at(`SCHEDULE needs a mode (${MODES.join("|")})`));
77
+ // The hole this closes: an archetype cadence used to get {mode, prompt} and a
78
+ // blanket-permission session. An empty allowed_tools list is legal ONLY for
79
+ // an inline (deterministic, no-session) cadence.
80
+ if (ob.mode !== "inline" && (!Array.isArray(ob.allowed_tools) || ob.allowed_tools.length === 0)) {
81
+ errs.push(at("a session-spawning SCHEDULE must declare a non-empty allowed_tools list"));
82
+ }
83
+ }
84
+ if (ob.kind === "OUTCOME") {
85
+ if (!ob.objective_id) errs.push(at("OUTCOME must be provenance-linked to an objective_id"));
86
+ if (!ob.sensor || typeof ob.sensor !== "object") errs.push(at("OUTCOME must carry a sensor"));
87
+ else {
88
+ if (!SENSOR_SOURCES.includes(ob.sensor.source)) errs.push(at(`sensor.source must be one of ${SENSOR_SOURCES.join("|")}`));
89
+ if (ob.sensor.source === "llm") errs.push(at('sensor.source "llm" cannot back an OUTCOME obligation (advisory only)'));
90
+ if (ob.sensor.source === "method" && !ob.sensor.capability) errs.push(at('sensor.source "method" requires a capability'));
91
+ }
92
+ if (ob.target != null && !Number.isFinite(ob.target)) errs.push(at("target must be a number when set"));
93
+ }
94
+ if (ob.kind === "REACT") {
95
+ if (!ob.trigger || typeof ob.trigger !== "object" || !ob.trigger.topic) errs.push(at("REACT needs trigger.topic"));
96
+ }
97
+
98
+ // The capability law.
99
+ if (o.reachable instanceof Set && Array.isArray(ob.uses)) {
100
+ for (const id of ob.uses) {
101
+ if (!o.reachable.has(id)) errs.push(at(`cites capability "${id}" which is not reachable:true in the capability manifest`));
102
+ }
103
+ }
104
+ return errs;
105
+ }
106
+
107
+ /**
108
+ * Validate a whole plan. Fail-closed: `ok:false` means DO NOT WRITE.
109
+ * @param {{obligations:object[]}} plan
110
+ * @param {{manifest?:object, reachable?:Set<string>}} [o]
111
+ * @returns {{ok:boolean, errors:string[], counts:object}}
112
+ */
113
+ export function validatePlan(plan, o = {}) {
114
+ const errors = [];
115
+ const obligations = plan && Array.isArray(plan.obligations) ? plan.obligations : null;
116
+ if (!obligations) return { ok: false, errors: ["plan.obligations must be an array"], counts: {} };
117
+
118
+ const reachable = o.reachable instanceof Set
119
+ ? o.reachable
120
+ : o.manifest
121
+ ? new Set(((o.manifest.entries) || []).filter((e) => e && e.reachable).map((e) => e.id))
122
+ : null;
123
+
124
+ const seen = new Set();
125
+ for (const ob of obligations) {
126
+ if (ob && ob.key) {
127
+ if (seen.has(ob.key)) errors.push(`duplicate obligation key "${ob.key}"`);
128
+ seen.add(ob.key);
129
+ }
130
+ errors.push(...validateObligation(ob, { reachable }));
131
+ }
132
+
133
+ const counts = {};
134
+ for (const k of OBLIGATION_KINDS) counts[k] = obligations.filter((ob) => ob && ob.kind === k).length;
135
+ counts.total = obligations.length;
136
+
137
+ return { ok: errors.length === 0, errors, counts };
138
+ }
139
+
140
+ export default { OBLIGATION_KINDS, ORIGINS, MODES, ACTION_CLASSES, SENSOR_SOURCES, STATUSES, validateObligation, validatePlan };
@@ -169,11 +169,57 @@ export function _resetLiveCache() { _liveCache = { at: 0, value: null }; }
169
169
  * Build the real-world deps snapshot the governor reasons over. Tests bypass
170
170
  * this entirely by passing their own `deps`.
171
171
  */
172
+ // How long an availableMemoryBytes() probe is reused. vm_stat costs a few ms and
173
+ // the governor is consulted on every admission decision; memory pressure does not
174
+ // meaningfully move inside a 2s window.
175
+ const AVAIL_CACHE_MS = 2000;
176
+ let availCache = { at: 0, bytes: 0 };
177
+
178
+ /**
179
+ * Bytes of memory the OS can actually hand to a new session.
180
+ *
181
+ * On Darwin `os.freemem()` reports ONLY vm_stat's "Pages free" — it excludes
182
+ * inactive, speculative and purgeable pages, every one of which macOS reclaims on
183
+ * demand. Any warm Mac therefore reports near-zero free memory forever (measured:
184
+ * 263MB "free" on a 24GB box that `memory_pressure` simultaneously called 76%
185
+ * free), so a percentage floor could never be satisfied and the governor deferred
186
+ * every spawn indefinitely. Counting the reclaimable pages is what macOS itself
187
+ * means by available memory.
188
+ *
189
+ * Falls back to `os.freemem()` on non-Darwin platforms and on any probe failure —
190
+ * a governor that cannot measure must not silently admit everything.
191
+ *
192
+ * @param {number} [now] injectable clock (tests)
193
+ * @returns {number} bytes
194
+ */
195
+ export function availableMemoryBytes(now = Date.now()) {
196
+ if (process.platform !== "darwin") return os.freemem();
197
+ if (availCache.at && now - availCache.at < AVAIL_CACHE_MS) return availCache.bytes;
198
+
199
+ let bytes = os.freemem();
200
+ try {
201
+ const out = execFileSync("vm_stat", { encoding: "utf8", timeout: 2000 });
202
+ const pageSize = Number(/page size of (\d+) bytes/.exec(out)?.[1]) || 4096;
203
+ const pages = (label) => {
204
+ const m = new RegExp(`^${label}:\\s+(\\d+)\\.`, "m").exec(out);
205
+ return m ? Number(m[1]) : 0;
206
+ };
207
+ const reclaimable =
208
+ pages("Pages free") + pages("Pages inactive") + pages("Pages speculative") + pages("Pages purgeable");
209
+ if (reclaimable > 0) bytes = reclaimable * pageSize;
210
+ } catch {
211
+ // vm_stat missing or unparseable — keep the os.freemem() reading.
212
+ }
213
+
214
+ availCache = { at: now, bytes };
215
+ return bytes;
216
+ }
217
+
172
218
  export function defaultDeps(extra = {}) {
173
219
  const live = liveClaudeStats();
174
220
  const throttle = readThrottle(extra);
175
221
  return {
176
- freemem: os.freemem(),
222
+ freemem: availableMemoryBytes(),
177
223
  totalmem: os.totalmem(),
178
224
  loadavg: os.loadavg(),
179
225
  cpus: os.cpus().length || 1,
@@ -11,7 +11,7 @@ import { test } from "node:test";
11
11
  import assert from "node:assert/strict";
12
12
  import { promises as fsp } from "node:fs";
13
13
  import { writeFileSync } from "node:fs";
14
- import { tmpdir } from "node:os";
14
+ import { tmpdir, freemem as osFreemem, totalmem as totalmemBytes } from "node:os";
15
15
  import { join } from "node:path";
16
16
 
17
17
  import {
@@ -21,8 +21,28 @@ import {
21
21
  PER_SESSION_MB,
22
22
  RAM_FRACTION,
23
23
  DECISIONS,
24
+ availableMemoryBytes,
25
+ FREEMEM_FLOOR_FRACTION,
24
26
  } from "./resource-governor.mjs";
25
27
 
28
+ test("availableMemoryBytes counts reclaimable pages, not just free ones", () => {
29
+ const bytes = availableMemoryBytes();
30
+ assert.ok(Number.isFinite(bytes) && bytes > 0, "always a usable number");
31
+
32
+ if (process.platform !== "darwin") {
33
+ assert.equal(bytes, osFreemem(), "non-Darwin defers to os.freemem()");
34
+ return;
35
+ }
36
+
37
+ // The regression: os.freemem() on macOS reports only "Pages free", which sits
38
+ // near zero on any warm box and pinned the governor at DEFER. Availability must
39
+ // include the inactive/speculative/purgeable pages macOS reclaims on demand,
40
+ // so it is always >= the raw reading and should clear the floor on an idle host.
41
+ assert.ok(bytes >= osFreemem(), "availability is never below raw free memory");
42
+ const floor = totalmemBytes() * FREEMEM_FLOOR_FRACTION;
43
+ assert.ok(bytes > floor, `an idle Mac must clear its own floor (${bytes} vs ${floor})`);
44
+ });
45
+
26
46
  const GB = 1024 * 1024 * 1024;
27
47
  const MB = 1024 * 1024;
28
48
 
@@ -40,15 +40,20 @@ import { isPlaceholder } from "./completeness.mjs";
40
40
  // Archetype axes the identity section also offers. Keep these in sync with
41
41
  // lib/setup/sections/identity.mjs FUNCTIONS/ALTITUDES — a derived value MUST be
42
42
  // one the wizard's select() can render and the archetype resolver accepts.
43
+ // These are the seven archetypes that exist on disk. `people-leader`/`compliance-leader`
44
+ // were derivable here but resolve to no archetype file — a pull could therefore
45
+ // write an un-resolvable function into config/agent.json and make the identity
46
+ // section's apply() fatal. They map onto `operations-leader`/`compliance-officer`.
43
47
  const FUNCTIONS = Object.freeze([
44
48
  "executive-operator",
45
49
  "technical-leader",
46
50
  "commercial-leader",
47
- "people-leader",
51
+ "product-leader",
52
+ "operations-leader",
48
53
  "finance-leader",
49
- "compliance-leader",
54
+ "compliance-officer",
50
55
  ]);
51
- const ALTITUDES = Object.freeze(["c-suite", "svp", "vp", "senior-manager"]);
56
+ const ALTITUDES = Object.freeze(["founder", "c-suite", "svp", "vp", "senior-manager"]);
52
57
 
53
58
  const DEFAULT_FUNCTION = "executive-operator";
54
59
  const DEFAULT_ALTITUDE = "c-suite";
@@ -69,9 +74,50 @@ export function cohortPullEnv(env = process.env) {
69
74
  }
70
75
 
71
76
  function str(v) {
72
- return v == null ? "" : String(v).trim();
77
+ if (v == null) return "";
78
+ // NEVER stringify an object/array leaf. The live MemberProfileDTO nests
79
+ // role/team as objects; `String({})` yields the TRUTHY literal
80
+ // "[object Object]", which used to satisfy every `if (value)` guard below and
81
+ // then poison the keyword derivations — a silent fall-back to defaults rather
82
+ // than an honest empty. Nested shapes are read via the accessors below.
83
+ if (typeof v === "object") return "";
84
+ return String(v).trim();
73
85
  }
74
86
 
87
+ /**
88
+ * The live Cohort MemberProfileDTO nests the fields the mapper needs:
89
+ * role: { title, level, status } team: { slug, name, shortLabel }
90
+ * Older/flattened payloads (and the wizard's own fixtures) carry them as plain
91
+ * strings. These accessors read BOTH shapes so a pull maps identically either
92
+ * way. Each returns "" when the profile genuinely carries nothing.
93
+ */
94
+ function profileTitle(p) {
95
+ return str(p.title) || str(p.role?.title);
96
+ }
97
+
98
+ function profileRoleText(p) {
99
+ return str(p.role) || str(p.role?.title) || str(p.role?.level);
100
+ }
101
+
102
+ function profileTeamText(p) {
103
+ return str(p.team) || str(p.team?.name) || str(p.team?.shortLabel) || str(p.team?.slug);
104
+ }
105
+
106
+ /** The org's explicit seniority band, when it ships one (`role.level`). */
107
+ function profileLevel(p) {
108
+ return str(p.level) || str(p.role?.level);
109
+ }
110
+
111
+ // Cohort's `role.level` enum → the archetype `altitude` axis. An EXPLICIT band
112
+ // from the org beats guessing at the title string, so this is consulted first.
113
+ const LEVEL_TO_ALTITUDE = Object.freeze({
114
+ FOUNDER: "founder",
115
+ C_SUITE: "c-suite",
116
+ SVP: "svp",
117
+ VP: "vp",
118
+ SENIOR_MANAGER: "senior-manager",
119
+ });
120
+
75
121
  /**
76
122
  * Map a Cohort MemberProfileDTO to the config/agent.json field subset it
77
123
  * authoritatively provides. Only sets a field when the profile carries a real
@@ -88,6 +134,7 @@ function str(v) {
88
134
  * operatingPrinciples ← profileSections (operating-principles section)
89
135
  * communication.defaultTone ← profileSections (communication/tone section)
90
136
  * responsibilities ← profileSections (responsibilities section)
137
+ * persona ← profileSections (persona/background/bio section)
91
138
  *
92
139
  * @param {object|null} profile MemberProfileDTO
93
140
  * @returns {object} the config/agent.json patch (deep-mergeable)
@@ -104,7 +151,7 @@ export function mapProfileToAgentConfig(profile) {
104
151
  const fullName = display || [first, last].filter(Boolean).join(" ").trim();
105
152
  if (fullName) patch.fullName = fullName;
106
153
 
107
- const title = str(p.title);
154
+ const title = profileTitle(p);
108
155
  if (title) patch.title = title;
109
156
 
110
157
  const email = str(p.agentEmail) || str(p.email);
@@ -115,7 +162,7 @@ export function mapProfileToAgentConfig(profile) {
115
162
  // leaving the wizard to prompt). When signal IS present, derivation always
116
163
  // resolves to a VALID axis (safe default when unmappable) so the wizard renders
117
164
  // + confirms it rather than failing the pull.
118
- const hasAxisSignal = !!(str(p.role) || str(p.team) || str(p.title));
165
+ const hasAxisSignal = !!(profileRoleText(p) || profileTeamText(p) || profileTitle(p) || profileLevel(p));
119
166
  if (hasAxisSignal) {
120
167
  patch.function = deriveFunction(p);
121
168
  patch.altitude = deriveAltitude(p);
@@ -135,6 +182,12 @@ export function mapProfileToAgentConfig(profile) {
135
182
  if (responsibilities.length) patch.responsibilities = responsibilities;
136
183
  const tone = sectionText(sections, ["communication", "tone", "communication-style"]);
137
184
  if (tone) patch.communication = { defaultTone: tone };
185
+ // persona/background — free prose describing who this member is. Rendered into
186
+ // the "Background" section of the prompt persona block (lib/identity/persona.mjs)
187
+ // so the agent writes with its own history behind it rather than as a generic
188
+ // seat-holder. The org is the SoT for this; we never synthesise one.
189
+ const persona = sectionText(sections, ["persona", "background", "bio", "about", "profile"]);
190
+ if (persona) patch.persona = persona;
138
191
 
139
192
  // charter — the Cohort-authored operating charter (CharterDTO:
140
193
  // approvedByLine/gatesText/sections). When present this is the SOURCE OF TRUTH
@@ -218,14 +271,17 @@ function mapSupervisor(sup) {
218
271
  }
219
272
  if (typeof sup === "object") {
220
273
  const fullName = str(sup.displayName) || str(sup.fullName) || [str(sup.firstName), str(sup.lastName)].filter(Boolean).join(" ").trim();
221
- if (!fullName && !str(sup.email) && !str(sup.title)) return null;
274
+ // A supervisor DTO nests its title under `role`, exactly as the member does.
275
+ const title = profileTitle(sup);
276
+ const email = str(sup.email) || str(sup.agentEmail);
277
+ if (!fullName && !email && !title) return null;
222
278
  const [pf, ...pr] = fullName.split(/\s+/);
223
279
  return {
224
280
  firstName: str(sup.firstName) || pf || "",
225
281
  lastName: str(sup.lastName) || pr.join(" "),
226
282
  fullName,
227
- title: str(sup.title),
228
- email: str(sup.email) || str(sup.agentEmail),
283
+ title,
284
+ email,
229
285
  };
230
286
  }
231
287
  return null;
@@ -239,14 +295,16 @@ function mapSupervisor(sup) {
239
295
  * @returns {string} one of FUNCTIONS
240
296
  */
241
297
  export function deriveFunction(p) {
242
- const hay = `${str(p.role)} ${str(p.team)} ${str(p.title)}`.toLowerCase();
298
+ const hay = `${profileRoleText(p)} ${profileTeamText(p)} ${profileTitle(p)}`.toLowerCase();
243
299
  const rules = [
244
300
  [/\b(eng|engineer|platform|infra|technical|cto|devops|software|sre)\b/, "technical-leader"],
301
+ [/\b(product|design|ux|research|pm|cpo)\b/, "product-leader"],
245
302
  [/\b(sales|revenue|gtm|growth|commercial|marketing|bd|biz dev|partnerships)\b/, "commercial-leader"],
246
- [/\b(people|hr|talent|recruit|org design|human resources)\b/, "people-leader"],
303
+ [/\b(people|hr|talent|recruit|org design|human resources)\b/, "operations-leader"],
247
304
  [/\b(finance|fp&a|accounting|treasury|capital|corp dev|cfo)\b/, "finance-leader"],
248
- [/\b(compliance|legal|risk|regulatory|counsel|audit|dfsa)\b/, "compliance-leader"],
249
- [/\b(ops|operations|chief of staff|coo|executive|operator)\b/, "executive-operator"],
305
+ [/\b(compliance|legal|risk|regulatory|counsel|audit|dfsa)\b/, "compliance-officer"],
306
+ [/\b(ops|operations|delivery|supply|logistics)\b/, "operations-leader"],
307
+ [/\b(chief of staff|coo|executive|operator)\b/, "executive-operator"],
250
308
  ];
251
309
  for (const [re, fn] of rules) if (re.test(hay)) return fn;
252
310
  return DEFAULT_FUNCTION;
@@ -259,9 +317,19 @@ export function deriveFunction(p) {
259
317
  * @returns {string} one of ALTITUDES
260
318
  */
261
319
  export function deriveAltitude(p) {
262
- const hay = `${str(p.role)} ${str(p.title)}`.toLowerCase();
263
- // Order matters: most-senior cue wins (check c-suite/SVP before VP/manager).
264
- if (/\b(c-?suite|chief|ceo|cto|cfo|coo|cmo|cpo|founder)\b/.test(hay)) return "c-suite";
320
+ // An explicit `role.level` from the org is authoritative — prefer it over
321
+ // guessing at prose. (An SVP reporting to a CPTO reads as "chief" to the
322
+ // keyword pass below only because their manager's title is nearby; the band
323
+ // removes that whole class of error.)
324
+ const level = profileLevel(p).toUpperCase().replace(/[\s-]+/g, "_");
325
+ if (LEVEL_TO_ALTITUDE[level]) return LEVEL_TO_ALTITUDE[level];
326
+
327
+ const hay = `${profileRoleText(p)} ${profileTitle(p)}`.toLowerCase();
328
+ // Order matters: most-senior cue wins (check founder/c-suite/SVP before
329
+ // VP/manager). `founder` is its own altitude — it resolves the runway-review
330
+ // and investor-update cadences a generic c-suite seat does not get.
331
+ if (/\b(founder|co-?founder)\b/.test(hay)) return "founder";
332
+ if (/\b(c-?suite|chief|ceo|cto|cfo|coo|cmo|cpo)\b/.test(hay)) return "c-suite";
265
333
  if (/\bsvp\b|senior vice president/.test(hay)) return "svp";
266
334
  if (/\bvp\b|vice president|head of|director/.test(hay)) return "vp";
267
335
  if (/\b(senior manager|sr\.? manager|manager|lead)\b/.test(hay)) return "senior-manager";
@@ -52,6 +52,46 @@ const PROFILE = {
52
52
  ],
53
53
  };
54
54
 
55
+ // The shape the LIVE org-data API actually returns: role/team are objects, and
56
+ // the supervisor nests its title the same way. The flat PROFILE above is the
57
+ // legacy/wizard shape — both must map identically. Regression fixture for the
58
+ // enrolment that shipped an agent with title:"", altitude:"c-suite" and a
59
+ // principal with no title, because every field below was read one level too high.
60
+ const NESTED_PROFILE = {
61
+ slug: "A016",
62
+ kind: "AI_AGENT",
63
+ status: "ACTIVE",
64
+ displayName: "Agent One",
65
+ firstName: "Agent",
66
+ lastName: "One",
67
+ team: { slug: "ai-systems-agent-platform", name: "AI Systems & Agent Platform", shortLabel: "AI Systems" },
68
+ role: { title: "SVP AI Systems & Agent Platform", level: "SVP", status: "ACTIVE" },
69
+ agentEmail: "agent-one@example.test",
70
+ supervisor: {
71
+ slug: "A002",
72
+ displayName: "Samir Al Sulaiti",
73
+ role: { title: "Chief Product & Technology Officer", level: "C_SUITE", status: "ACTIVE" },
74
+ team: { slug: "product-technology-platform", name: "Product, Technology & Platform" },
75
+ },
76
+ };
77
+
78
+ test("mapProfileToAgentConfig reads the nested (live) DTO shape", () => {
79
+ const patch = mapProfileToAgentConfig(NESTED_PROFILE);
80
+ assert.equal(patch.title, "SVP AI Systems & Agent Platform", "title comes from role.title");
81
+ assert.equal(patch.function, "technical-leader", "derived from the platform/AI-systems team");
82
+ assert.equal(patch.altitude, "svp", "role.level beats keyword-guessing the title");
83
+ assert.equal(patch.principal.fullName, "Samir Al Sulaiti");
84
+ assert.equal(patch.principal.title, "Chief Product & Technology Officer", "supervisor title comes from role.title");
85
+ });
86
+
87
+ test("an object leaf never stringifies into a derivation", () => {
88
+ // `String({})` is the truthy "[object Object]"; letting it through made every
89
+ // derivation silently fall back to its default instead of reporting no signal.
90
+ assert.equal(mapProfileToAgentConfig({ role: {}, team: {} }).function, undefined, "no axis signal → no axis emitted");
91
+ assert.equal(deriveFunction({ role: { title: "Staff Engineer" } }), "technical-leader");
92
+ assert.equal(deriveAltitude({ role: { level: "SENIOR_MANAGER" } }), "senior-manager");
93
+ });
94
+
55
95
  test("mapProfileToAgentConfig maps the authoritative fields", () => {
56
96
  const patch = mapProfileToAgentConfig(PROFILE);
57
97
  assert.equal(patch.firstName, "Robin");
@@ -130,7 +170,9 @@ test("charter/orgProfile absent → no charter/orgProfile keys (empty patch stay
130
170
  test("deriveFunction + deriveAltitude resolve to valid axes (safe default)", () => {
131
171
  assert.ok(FUNCTIONS.includes(deriveFunction({ role: "sales lead", team: "Revenue" })));
132
172
  assert.equal(deriveFunction({ role: "sales lead", team: "Revenue" }), "commercial-leader");
133
- assert.equal(deriveFunction({ role: "general counsel", team: "Legal" }), "compliance-leader");
173
+ // `compliance-leader` resolves to no archetype on disk; the rule map returns
174
+ // the archetype that does exist. The expectation, not the code, was stale.
175
+ assert.equal(deriveFunction({ role: "general counsel", team: "Legal" }), "compliance-officer");
134
176
  assert.equal(deriveFunction({ role: "weird-unknown-role" }), "executive-operator");
135
177
  assert.ok(ALTITUDES.includes(deriveAltitude({ title: "Chief Technology Officer" })));
136
178
  assert.equal(deriveAltitude({ title: "Chief Technology Officer" }), "c-suite");
@@ -20,15 +20,26 @@ import { runGenerator } from "../run-generator.mjs";
20
20
  import { cohortPullEnv, pullAndPlan } from "../enroll-from-cohort.mjs";
21
21
  import { fetchSelfProfile } from "../../org/client.mjs";
22
22
 
23
- const FUNCTIONS = [
23
+ // The seven archetypes that ACTUALLY EXIST on disk (archetypes/*.yaml). This
24
+ // list used to offer `people-leader` and `compliance-leader`, neither of which
25
+ // has an archetype file — picking either resolved to nothing and made apply()
26
+ // fatal, and it omitted `operations-leader` and `product-leader`, which do
27
+ // exist. The whole init pipeline downstream (capability pack → cadences →
28
+ // plan compiler) keys off this value, so it must name a real archetype.
29
+ // Keep in sync with lib/setup/enroll-from-cohort.mjs FUNCTIONS.
30
+ export const FUNCTIONS = [
24
31
  { value: "executive-operator", label: "Executive operator (chief of staff / COO-style)" },
25
32
  { value: "technical-leader", label: "Technical leader (eng / platform / CTO-style)" },
26
33
  { value: "commercial-leader", label: "Commercial leader (sales / GTM / revenue)" },
27
- { value: "people-leader", label: "People leader (HR / talent / org)" },
34
+ { value: "product-leader", label: "Product leader (product / design / research)" },
35
+ { value: "operations-leader", label: "Operations leader (ops / people / delivery)" },
28
36
  { value: "finance-leader", label: "Finance leader (finance / corp dev / capital)" },
29
- { value: "compliance-leader", label: "Compliance leader (legal / risk / regulatory)" },
37
+ { value: "compliance-officer", label: "Compliance officer (legal / risk / regulatory)" },
30
38
  ];
31
- const ALTITUDES = [
39
+ // `founder` carries its own ALTITUDE_CADENCES entry (runway review + investor
40
+ // update) in lib/cadences.mjs but was unreachable from the wizard.
41
+ export const ALTITUDES = [
42
+ { value: "founder", label: "Founder" },
32
43
  { value: "c-suite", label: "C-suite" },
33
44
  { value: "svp", label: "SVP" },
34
45
  { value: "vp", label: "VP" },
@@ -138,3 +138,97 @@ test("pull is fail-open: a fetch error leaves config untouched", async () => {
138
138
  );
139
139
  assert.equal(readAgentJson(root).firstName, "UNCONFIGURED");
140
140
  });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // ARCHETYPE AXES (the fatal apply() regression).
144
+ //
145
+ // The wizard used to offer `people-leader` and `compliance-leader`, neither of
146
+ // which has an archetype file on disk. Picking either resolved to nothing and
147
+ // made apply() throw — and because the whole init pipeline downstream
148
+ // (capability pack -> cadences -> plan compiler) keys off this value, a bad axis
149
+ // poisons everything after it. These assert the invariant against the real
150
+ // filesystem, so adding an archetype without offering it (or offering one that
151
+ // does not exist) fails here rather than in a user's setup run.
152
+ // ---------------------------------------------------------------------------
153
+
154
+ import { FUNCTIONS, ALTITUDES } from "./identity.mjs";
155
+ import { listFunctions, listAltitudes, resolveArchetype } from "../../archetype.mjs";
156
+ import { FUNCTIONS as ENROLL_FUNCTIONS, ALTITUDES as ENROLL_ALTITUDES, deriveFunction, deriveAltitude } from "../enroll-from-cohort.mjs";
157
+ import { loadSections } from "../runner.mjs";
158
+
159
+ test("every function the wizard offers has an archetype file on disk", () => {
160
+ const onDisk = listFunctions();
161
+ for (const f of FUNCTIONS) {
162
+ assert.ok(onDisk.includes(f.value), `wizard offers "${f.value}" but archetypes/functions/${f.value}.yaml does not exist`);
163
+ }
164
+ });
165
+
166
+ test("every archetype on disk is offered by the wizard (no orphaned archetype)", () => {
167
+ const offered = new Set(FUNCTIONS.map((f) => f.value));
168
+ for (const f of listFunctions()) {
169
+ assert.ok(offered.has(f), `archetypes/functions/${f}.yaml exists but the wizard never offers it`);
170
+ }
171
+ });
172
+
173
+ test("every altitude the wizard offers has an archetype file, and vice versa", () => {
174
+ const onDisk = listAltitudes();
175
+ for (const a of ALTITUDES) assert.ok(onDisk.includes(a.value), `wizard offers altitude "${a.value}" with no archetypes/altitudes/${a.value}.yaml`);
176
+ const offered = new Set(ALTITUDES.map((a) => a.value));
177
+ for (const a of onDisk) assert.ok(offered.has(a), `archetypes/altitudes/${a}.yaml exists but the wizard never offers it`);
178
+ });
179
+
180
+ test("`founder` is reachable from the wizard (it owns runway + investor cadences)", () => {
181
+ assert.ok(ALTITUDES.some((a) => a.value === "founder"), "founder altitude is offered");
182
+ });
183
+
184
+ test("the pull path can only derive an axis the wizard can render", () => {
185
+ // enroll-from-cohort writes function/altitude straight into config/agent.json,
186
+ // so a value it can derive but the wizard cannot render is an un-resolvable seat.
187
+ const offeredFns = new Set(FUNCTIONS.map((f) => f.value));
188
+ for (const f of ENROLL_FUNCTIONS) assert.ok(offeredFns.has(f), `enroll can derive "${f}" which the wizard cannot render`);
189
+ const offeredAlts = new Set(ALTITUDES.map((a) => a.value));
190
+ for (const a of ENROLL_ALTITUDES) assert.ok(offeredAlts.has(a), `enroll can derive altitude "${a}" which the wizard cannot render`);
191
+ });
192
+
193
+ test("the retired axes are gone from BOTH lists", () => {
194
+ for (const dead of ["people-leader", "compliance-leader"]) {
195
+ assert.ok(!FUNCTIONS.some((f) => f.value === dead), `wizard still offers the non-existent "${dead}"`);
196
+ assert.ok(!ENROLL_FUNCTIONS.includes(dead), `enroll can still derive the non-existent "${dead}"`);
197
+ }
198
+ });
199
+
200
+ test("the roles that used to derive a dead axis now derive a real one", () => {
201
+ assert.equal(deriveFunction({ role: "Head of People", team: "HR" }), "operations-leader");
202
+ assert.equal(deriveFunction({ role: "general counsel", team: "Legal" }), "compliance-officer");
203
+ assert.equal(deriveFunction({ role: "Head of Product", team: "Product" }), "product-leader");
204
+ assert.equal(deriveAltitude({ title: "Co-Founder & CEO" }), "founder");
205
+ });
206
+
207
+ test("every offered (function, altitude) pair actually resolves to an archetype", async () => {
208
+ // The real end-to-end guarantee: no combination the wizard can produce throws.
209
+ for (const f of FUNCTIONS) {
210
+ for (const a of ALTITUDES) {
211
+ const arch = await resolveArchetype({ function: f.value, altitude: a.value });
212
+ assert.ok(arch && typeof arch === "object", `resolveArchetype failed for ${f.value}/${a.value}`);
213
+ }
214
+ }
215
+ });
216
+
217
+ test("every derivable pull result resolves to an archetype too", async () => {
218
+ for (const f of ENROLL_FUNCTIONS) {
219
+ for (const a of ENROLL_ALTITUDES) {
220
+ const arch = await resolveArchetype({ function: f, altitude: a });
221
+ assert.ok(arch && typeof arch === "object", `resolveArchetype failed for pulled ${f}/${a}`);
222
+ }
223
+ }
224
+ });
225
+
226
+ test("the identity section is registered by the directory glob at order 10", async () => {
227
+ const sections = await loadSections();
228
+ const s = sections.find((x) => x.id === "identity");
229
+ assert.ok(s, "identity is discovered by loadSections()");
230
+ assert.equal(s.order, 10);
231
+ assert.equal(s.order, identity.order);
232
+ // It must run before the capability inventory, which reads its archetype.
233
+ assert.ok(s.order < sections.find((x) => x.id === "inventory").order);
234
+ });