@cohortapp/agent-sdk 2.3.2 → 2.4.1

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 +105 -17
  97. package/lib/setup/enroll-from-cohort.test.mjs +68 -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,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,22 @@ 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
+ // Real Cohort records put this prose in one of two places, and the section
190
+ // list alone missed both for a live seat: the section is named SUMMARY (not
191
+ // "persona"/"background"), and the DTO also carries a top-level `bio` with the
192
+ // same text. Missing it is not cosmetic — an empty persona leaves
193
+ // `{{agent.persona}}` unresolved in CLAUDE.md and drops the Background line
194
+ // from the prompt block, which is the exact "generic assistant" failure this
195
+ // whole change exists to remove. Section first (it is the richer, explicitly
196
+ // authored field), then the top-level bio.
197
+ const persona =
198
+ sectionText(sections, ["persona", "background", "bio", "about", "profile", "summary"]) ||
199
+ str(p.bio);
200
+ if (persona) patch.persona = persona;
138
201
 
139
202
  // charter — the Cohort-authored operating charter (CharterDTO:
140
203
  // approvedByLine/gatesText/sections). When present this is the SOURCE OF TRUTH
@@ -218,14 +281,17 @@ function mapSupervisor(sup) {
218
281
  }
219
282
  if (typeof sup === "object") {
220
283
  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;
284
+ // A supervisor DTO nests its title under `role`, exactly as the member does.
285
+ const title = profileTitle(sup);
286
+ const email = str(sup.email) || str(sup.agentEmail);
287
+ if (!fullName && !email && !title) return null;
222
288
  const [pf, ...pr] = fullName.split(/\s+/);
223
289
  return {
224
290
  firstName: str(sup.firstName) || pf || "",
225
291
  lastName: str(sup.lastName) || pr.join(" "),
226
292
  fullName,
227
- title: str(sup.title),
228
- email: str(sup.email) || str(sup.agentEmail),
293
+ title,
294
+ email,
229
295
  };
230
296
  }
231
297
  return null;
@@ -239,14 +305,16 @@ function mapSupervisor(sup) {
239
305
  * @returns {string} one of FUNCTIONS
240
306
  */
241
307
  export function deriveFunction(p) {
242
- const hay = `${str(p.role)} ${str(p.team)} ${str(p.title)}`.toLowerCase();
308
+ const hay = `${profileRoleText(p)} ${profileTeamText(p)} ${profileTitle(p)}`.toLowerCase();
243
309
  const rules = [
244
310
  [/\b(eng|engineer|platform|infra|technical|cto|devops|software|sre)\b/, "technical-leader"],
311
+ [/\b(product|design|ux|research|pm|cpo)\b/, "product-leader"],
245
312
  [/\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"],
313
+ [/\b(people|hr|talent|recruit|org design|human resources)\b/, "operations-leader"],
247
314
  [/\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"],
315
+ [/\b(compliance|legal|risk|regulatory|counsel|audit|dfsa)\b/, "compliance-officer"],
316
+ [/\b(ops|operations|delivery|supply|logistics)\b/, "operations-leader"],
317
+ [/\b(chief of staff|coo|executive|operator)\b/, "executive-operator"],
250
318
  ];
251
319
  for (const [re, fn] of rules) if (re.test(hay)) return fn;
252
320
  return DEFAULT_FUNCTION;
@@ -259,9 +327,19 @@ export function deriveFunction(p) {
259
327
  * @returns {string} one of ALTITUDES
260
328
  */
261
329
  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";
330
+ // An explicit `role.level` from the org is authoritative — prefer it over
331
+ // guessing at prose. (An SVP reporting to a CPTO reads as "chief" to the
332
+ // keyword pass below only because their manager's title is nearby; the band
333
+ // removes that whole class of error.)
334
+ const level = profileLevel(p).toUpperCase().replace(/[\s-]+/g, "_");
335
+ if (LEVEL_TO_ALTITUDE[level]) return LEVEL_TO_ALTITUDE[level];
336
+
337
+ const hay = `${profileRoleText(p)} ${profileTitle(p)}`.toLowerCase();
338
+ // Order matters: most-senior cue wins (check founder/c-suite/SVP before
339
+ // VP/manager). `founder` is its own altitude — it resolves the runway-review
340
+ // and investor-update cadences a generic c-suite seat does not get.
341
+ if (/\b(founder|co-?founder)\b/.test(hay)) return "founder";
342
+ if (/\b(c-?suite|chief|ceo|cto|cfo|coo|cmo|cpo)\b/.test(hay)) return "c-suite";
265
343
  if (/\bsvp\b|senior vice president/.test(hay)) return "svp";
266
344
  if (/\bvp\b|vice president|head of|director/.test(hay)) return "vp";
267
345
  if (/\b(senior manager|sr\.? manager|manager|lead)\b/.test(hay)) return "senior-manager";
@@ -277,7 +355,17 @@ function findSection(sections, names) {
277
355
  const id = str(s.id).toLowerCase();
278
356
  const title = str(s.title).toLowerCase();
279
357
  const heading = str(s.heading).toLowerCase();
280
- return wanted.includes(id) || wanted.includes(title) || wanted.includes(heading);
358
+ // `kind` is what a real Cohort ProfileSection identifies itself by
359
+ // (SUMMARY, VISUAL_PROMPT, COUNTERPARTS_NARRATIVE). Matching only
360
+ // id/title/heading silently missed EVERY section on a live seat, so a
361
+ // fully-populated org profile still yielded an empty persona.
362
+ const kind = str(s.kind).toLowerCase();
363
+ return (
364
+ wanted.includes(id) ||
365
+ wanted.includes(title) ||
366
+ wanted.includes(heading) ||
367
+ wanted.includes(kind)
368
+ );
281
369
  }) || null
282
370
  );
283
371
  }
@@ -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");
@@ -231,3 +273,28 @@ test("pullAndPlan: a local override is NOT clobbered without --force", async ()
231
273
  const forced = await pullAndPlan({ agentRoot: root, env, force: true, fetchSelfProfileImpl: async () => ({ ok: true, result: PROFILE }) });
232
274
  assert.equal(forced.patch.title, "VP of Engineering", "--force overwrites");
233
275
  });
276
+
277
+ test("persona falls back to the SUMMARY section and then the top-level bio", () => {
278
+ // A live Cohort seat had its prose in a section named SUMMARY and in the
279
+ // DTO's top-level `bio` — neither of which the original name list matched.
280
+ // The result was an empty persona, an unresolved {{agent.persona}} in
281
+ // CLAUDE.md, and no Background line in the prompt: exactly the generic-seat
282
+ // failure this mapping exists to prevent.
283
+ const fromSummary = mapProfileToAgentConfig({
284
+ profileSections: [{ kind: "SUMMARY", body: "I build the agent substrate." }],
285
+ });
286
+ assert.equal(fromSummary.persona, "I build the agent substrate.");
287
+
288
+ const fromBio = mapProfileToAgentConfig({ bio: "I run platform engineering." });
289
+ assert.equal(fromBio.persona, "I run platform engineering.");
290
+
291
+ // An explicitly authored section still wins over the terser bio.
292
+ const both = mapProfileToAgentConfig({
293
+ bio: "short bio",
294
+ profileSections: [{ kind: "background", body: "the long authored version" }],
295
+ });
296
+ assert.equal(both.persona, "the long authored version");
297
+
298
+ // Nothing to say stays empty — we never synthesise a persona.
299
+ assert.equal(mapProfileToAgentConfig({}).persona, undefined);
300
+ });
@@ -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" },