@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,247 @@
1
+ /**
2
+ * persona.mjs — renders an agent's identity into the block that leads every
3
+ * LLM prompt the agent runs under.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * The daemon used to build its preamble by scraping four prose sections out of
7
+ * the agent's CLAUDE.md (`extractPreamble` in scripts/daemon/prompt-builder.mjs).
8
+ * That had two failure modes, both of which we hit in production:
9
+ *
10
+ * 1. A freshly-scaffolded repo has "*Configured by `maestro setup`*" sitting
11
+ * in those sections. The model was handed a near-empty identity and fell
12
+ * back to the generic-assistant register it ships with — introducing and
13
+ * signing itself as "an AI assistant working on behalf of <principal>",
14
+ * deferring on matters inside its own mandate, and writing to colleagues
15
+ * as a support function rather than as the person holding the role.
16
+ *
17
+ * 2. Everything the org actually knows about the member — title, seniority
18
+ * band, background, tone of voice, responsibilities, operating principles,
19
+ * charter — is pulled from the Cohort server into config/agent.json by
20
+ * lib/setup/enroll-from-cohort.mjs, and was then read by NOTHING on the
21
+ * prompt path. A fully-enrolled agent got no more persona than an empty one.
22
+ *
23
+ * This module closes both. It renders config/agent.json (+ config/company.json)
24
+ * — the source of truth, kept in sync with Cohort — into a persona block, and
25
+ * the prompt builder prepends it unconditionally. Identity now degrades
26
+ * gracefully instead of silently: an unconfigured field is omitted, not faked.
27
+ *
28
+ * SCOPE — what this module does NOT do
29
+ * It does not touch the AI-disclosure truthfulness invariant. An agent that is
30
+ * sincerely asked whether it is an AI still answers plainly, and
31
+ * policies/ai-disclosure.yaml remains the authority on that. What changes here
32
+ * is that the agent stops volunteering a standing disclaimer on work nobody
33
+ * asked to have disclaimed. Honesty on demand, not self-deprecation by default.
34
+ * See `voiceRules()` — the honesty bullet is deliberately part of the block,
35
+ * not an afterthought bolted on elsewhere.
36
+ *
37
+ * Pure-function core (`renderPersona`) so it is testable without a filesystem;
38
+ * `loadPersonaBlock` does the I/O and never throws.
39
+ */
40
+
41
+ import { readFileSync } from "fs";
42
+ import { join } from "path";
43
+
44
+ /**
45
+ * Human-readable gloss for each archetype altitude band (archetypes/altitudes/).
46
+ * Used to tell the model what standing the seat carries, because a title string
47
+ * alone ("SVP AI Systems & Agent Platform") does not reliably convey it.
48
+ */
49
+ const ALTITUDE_STANDING = Object.freeze({
50
+ founder:
51
+ "You are a founder. You set direction rather than wait for it, and you carry final say on most things short of the board.",
52
+ "c-suite":
53
+ "You sit on the executive team. You own your function end to end and are expected to take positions, not to relay options upward.",
54
+ svp:
55
+ "You are a senior vice president. You run your area with real independence — you decide within it and escalate only what genuinely crosses another owner's remit.",
56
+ vp:
57
+ "You are a vice president. You own your area's outcomes and make the calls inside it; you bring your principal decisions, not questions.",
58
+ "senior-manager":
59
+ "You are a senior manager. You own delivery in your area and decide the how; you agree the what with your principal.",
60
+ });
61
+
62
+ /** Trim to a string, or "" for anything nullish/non-scalar. */
63
+ function s(v) {
64
+ if (v === null || v === undefined) return "";
65
+ if (typeof v === "string") return v.trim();
66
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
67
+ return "";
68
+ }
69
+
70
+ /** Normalise to a clean string[] — drops blanks, coerces scalars, ignores junk. */
71
+ function list(v) {
72
+ if (!Array.isArray(v)) return [];
73
+ return v.map(s).filter(Boolean);
74
+ }
75
+
76
+ /**
77
+ * Free-prose persona/background, wherever the org happens to have put it.
78
+ * Cohort profile sections land in `persona`; older configs used `bio`/`background`.
79
+ * @param {object} a config/agent.json
80
+ * @returns {string}
81
+ */
82
+ function personaProse(a) {
83
+ return s(a.persona) || s(a.background) || s(a.bio) || "";
84
+ }
85
+
86
+ /**
87
+ * The reporting line, phrased as a peer relationship rather than a service one.
88
+ * Returns "" when no principal is configured — better silent than invented.
89
+ * @param {object} a config/agent.json
90
+ * @returns {string}
91
+ */
92
+ function reportingLine(a) {
93
+ const p = a.principal && typeof a.principal === "object" ? a.principal : {};
94
+ const name = s(p.fullName) || s(p.firstName);
95
+ if (!name) return "";
96
+ const title = s(p.title);
97
+ return title ? `You report to ${name}, ${title}.` : `You report to ${name}.`;
98
+ }
99
+
100
+ /**
101
+ * The voice rules — the behavioural half of the block, and the reason it exists.
102
+ *
103
+ * These are phrased as standing facts about who the agent is rather than as
104
+ * prohibitions, because a negative instruction ("don't call yourself an AI")
105
+ * tends to be obeyed literally and narrowly while the underlying deferential
106
+ * register survives. The final bullet preserves the non-overridable truthfulness
107
+ * invariant from policies/ai-disclosure.yaml in the same breath — the agent is
108
+ * senior AND honest, and neither is traded for the other.
109
+ *
110
+ * @param {object} a config/agent.json
111
+ * @returns {string[]} bullet lines
112
+ */
113
+ function voiceRules(a) {
114
+ const principal = a.principal && typeof a.principal === "object" ? a.principal : {};
115
+ const pName = s(principal.firstName) || s(principal.fullName);
116
+ // Only claim a reporting line when one is actually configured. Asserting
117
+ // "you report to your principal" at an agent with no principal on record
118
+ // invents a relationship and re-introduces the deference we are removing.
119
+ const reportsClause = pName
120
+ ? ` You report to ${pName} the way any senior colleague reports to their manager, and you do not preface your work with that fact.`
121
+ : "";
122
+
123
+ return [
124
+ "You hold this role. Write as the person who holds it — with the judgement, standing and directness the title carries. You are a full member of this team, not a service attached to it.",
125
+ `Do not introduce, describe or sign yourself as an assistant, a bot, an AI helper, or as "working on behalf of" someone.${reportsClause}`,
126
+ "Do not position yourself as junior to whoever you are writing to, including your principal or the CEO. State what you did, what you found, and what you recommend. Ask for a decision only when the decision is genuinely theirs to make.",
127
+ "Do not thank people for their patience, apologise for taking up their time, or hedge a finding you are confident in. Colleagues at your level do not do this, and it reads as a tell.",
128
+ "Disagree when you disagree, and say so first rather than burying it under agreement. A concern you soften into politeness is a concern you failed to raise.",
129
+ "Being straight about what you are is not negotiable and does not conflict with any of the above: if someone sincerely asks whether they are dealing with an AI, say so plainly and without drama. That is a direct answer to a direct question — not a standing disclaimer you attach to your own work.",
130
+ ];
131
+ }
132
+
133
+ /**
134
+ * Render the persona block from resolved config. Pure — no I/O, never throws.
135
+ *
136
+ * Every section is conditional: a field the org has not filled in is omitted
137
+ * rather than rendered as a placeholder, so a partially-enrolled agent gets a
138
+ * shorter block instead of a block full of "unconfigured".
139
+ *
140
+ * @param {object} agentCfg parsed config/agent.json
141
+ * @param {object} [companyCfg] parsed config/company.json
142
+ * @param {object} [opts]
143
+ * @param {boolean} [opts.includeVoiceRules=true] emit the "How you write" section
144
+ * @returns {string} the persona block, or "" when there is no identity at all
145
+ */
146
+ export function renderPersona(agentCfg, companyCfg = {}, opts = {}) {
147
+ const a = agentCfg && typeof agentCfg === "object" ? agentCfg : {};
148
+ const co = companyCfg && typeof companyCfg === "object" ? companyCfg : {};
149
+ const { includeVoiceRules = true } = opts;
150
+
151
+ const name = s(a.fullName) || [s(a.firstName), s(a.lastName)].filter(Boolean).join(" ").trim();
152
+ const title = s(a.title);
153
+ const company = s(a.company) || s(co.name);
154
+
155
+ // No name and no title means nothing has been configured — emit nothing and
156
+ // let the caller fall back rather than render a hollow "You are ." line.
157
+ if (!name && !title) return "";
158
+
159
+ const out = [];
160
+ out.push("## Who you are");
161
+ out.push("");
162
+
163
+ const opener = [name, title && `${title}`].filter(Boolean).join(", ");
164
+ out.push(company ? `You are ${opener} at ${company}.` : `You are ${opener}.`);
165
+
166
+ const standing = ALTITUDE_STANDING[s(a.altitude)];
167
+ if (standing) out.push(standing);
168
+
169
+ const reports = reportingLine(a);
170
+ if (reports) out.push(reports);
171
+
172
+ const desc = s(a.companyDescription) || s(co.description);
173
+ if (desc) {
174
+ out.push("");
175
+ out.push(desc);
176
+ }
177
+
178
+ const prose = personaProse(a);
179
+ if (prose) {
180
+ out.push("");
181
+ out.push("### Background");
182
+ out.push("");
183
+ out.push(prose);
184
+ }
185
+
186
+ const responsibilities = list(a.responsibilities);
187
+ if (responsibilities.length) {
188
+ out.push("");
189
+ out.push("### What you own");
190
+ out.push("");
191
+ for (const r of responsibilities) out.push(`- ${r}`);
192
+ }
193
+
194
+ const principles = list(a.operatingPrinciples);
195
+ if (principles.length) {
196
+ out.push("");
197
+ out.push("### How you operate");
198
+ out.push("");
199
+ for (const p of principles) out.push(`- ${p}`);
200
+ }
201
+
202
+ const comms = a.communication && typeof a.communication === "object" ? a.communication : {};
203
+ const defaultTone = s(comms.defaultTone);
204
+ const externalTone = s(comms.externalTone);
205
+ if (defaultTone || externalTone) {
206
+ out.push("");
207
+ out.push("### Your register");
208
+ out.push("");
209
+ if (defaultTone) out.push(`- Default tone: ${defaultTone}`);
210
+ if (externalTone) out.push(`- With people outside ${company || "the company"}: ${externalTone}`);
211
+ }
212
+
213
+ if (includeVoiceRules) {
214
+ out.push("");
215
+ out.push("### How you write and carry yourself");
216
+ out.push("");
217
+ for (const rule of voiceRules(a)) out.push(`- ${rule}`);
218
+ }
219
+
220
+ return out.join("\n").trim();
221
+ }
222
+
223
+ /**
224
+ * Read config/agent.json + config/company.json from an agent repo and render the
225
+ * persona block. Fail-open: any read/parse error yields "" so a malformed config
226
+ * degrades the prompt rather than taking the daemon down.
227
+ *
228
+ * @param {string} agentRoot absolute path to the agent repo
229
+ * @param {object} [opts] forwarded to renderPersona
230
+ * @returns {string}
231
+ */
232
+ export function loadPersonaBlock(agentRoot, opts = {}) {
233
+ const read = (rel) => {
234
+ try {
235
+ return JSON.parse(readFileSync(join(agentRoot, rel), "utf-8"));
236
+ } catch {
237
+ return {};
238
+ }
239
+ };
240
+ try {
241
+ return renderPersona(read("config/agent.json"), read("config/company.json"), opts);
242
+ } catch {
243
+ return "";
244
+ }
245
+ }
246
+
247
+ export const _test = { ALTITUDE_STANDING, personaProse, reportingLine, voiceRules, list };
@@ -0,0 +1,117 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import { renderPersona, loadPersonaBlock } from "./persona.mjs";
8
+
9
+ const AGENT = {
10
+ firstName: "Jamie",
11
+ lastName: "Okafor",
12
+ fullName: "Jamie Okafor",
13
+ title: "SVP AI Systems & Agent Platform",
14
+ altitude: "svp",
15
+ company: "Northwind",
16
+ principal: { firstName: "Robin", fullName: "Robin Vance", title: "Chief Product & Technology Officer" },
17
+ responsibilities: ["Agent platform reliability", "Fleet enrolment integrity"],
18
+ operatingPrinciples: ["Evidence over opinion"],
19
+ communication: { defaultTone: "warm-professional", externalTone: "formal" },
20
+ };
21
+
22
+ test("renders name, title, company and the seniority standing for the altitude band", () => {
23
+ const out = renderPersona(AGENT);
24
+ assert.match(out, /You are Jamie Okafor, SVP AI Systems & Agent Platform at Northwind\./);
25
+ assert.match(out, /You are a senior vice president\./);
26
+ assert.match(out, /You report to Robin Vance, Chief Product & Technology Officer\./);
27
+ });
28
+
29
+ test("renders responsibilities, principles and register when present", () => {
30
+ const out = renderPersona(AGENT);
31
+ assert.match(out, /### What you own/);
32
+ assert.match(out, /- Agent platform reliability/);
33
+ assert.match(out, /### How you operate/);
34
+ assert.match(out, /- Evidence over opinion/);
35
+ assert.match(out, /Default tone: warm-professional/);
36
+ assert.match(out, /outside Northwind: formal/);
37
+ });
38
+
39
+ // The regression this module exists for: the agent signing off as an AI
40
+ // assistant working on behalf of its principal.
41
+ test("instructs against assistant framing and on-behalf-of signatures", () => {
42
+ const out = renderPersona(AGENT);
43
+ assert.match(out, /Do not introduce, describe or sign yourself as an assistant/);
44
+ assert.match(out, /working on behalf of/); // named as the thing NOT to do
45
+ assert.match(out, /You report to Robin the way any senior colleague reports to their manager/);
46
+ assert.match(out, /full member of this team, not a service attached to it/);
47
+ });
48
+
49
+ // The truthfulness invariant in policies/ai-disclosure.yaml is locked and must
50
+ // survive the seniority rewrite — senior AND honest, neither traded for the other.
51
+ test("preserves the answer-truthfully-if-asked invariant", () => {
52
+ const out = renderPersona(AGENT);
53
+ assert.match(out, /sincerely asks whether they are dealing with an AI, say so plainly/);
54
+ assert.match(out, /not a standing disclaimer/);
55
+ });
56
+
57
+ test("omits unconfigured sections rather than rendering placeholders", () => {
58
+ const out = renderPersona({ fullName: "Ada Lovelace", title: "Head of Analysis" });
59
+ assert.match(out, /You are Ada Lovelace, Head of Analysis\./);
60
+ assert.doesNotMatch(out, /### What you own/);
61
+ assert.doesNotMatch(out, /### How you operate/);
62
+ assert.doesNotMatch(out, /### Your register/);
63
+ assert.doesNotMatch(out, /You report to/);
64
+ assert.doesNotMatch(out, /undefined|null|unconfigured/i);
65
+ });
66
+
67
+ test("returns empty string when there is no identity at all", () => {
68
+ assert.equal(renderPersona({}), "");
69
+ assert.equal(renderPersona(null), "");
70
+ assert.equal(renderPersona({ company: "Northwind" }), "");
71
+ });
72
+
73
+ test("persona prose falls back across persona → background → bio", () => {
74
+ assert.match(renderPersona({ ...AGENT, persona: "P-text" }), /### Background\n\nP-text/);
75
+ assert.match(renderPersona({ ...AGENT, background: "B-text" }), /### Background\n\nB-text/);
76
+ assert.match(renderPersona({ ...AGENT, bio: "Bio-text" }), /### Background\n\nBio-text/);
77
+ });
78
+
79
+ test("an unknown altitude drops the standing line without breaking the block", () => {
80
+ const out = renderPersona({ ...AGENT, altitude: "wizard" });
81
+ assert.match(out, /You are Jamie Okafor/);
82
+ assert.doesNotMatch(out, /senior vice president/);
83
+ });
84
+
85
+ test("company falls back to config/company.json when agent.json lacks it", () => {
86
+ const out = renderPersona({ fullName: "Ada Lovelace", title: "Head of Analysis" }, { name: "Northwind" });
87
+ assert.match(out, /Head of Analysis at Northwind\./);
88
+ });
89
+
90
+ test("includeVoiceRules:false emits identity only", () => {
91
+ const out = renderPersona(AGENT, {}, { includeVoiceRules: false });
92
+ assert.match(out, /You are Jamie Okafor/);
93
+ assert.doesNotMatch(out, /How you write and carry yourself/);
94
+ });
95
+
96
+ test("junk values in list fields are dropped, not rendered", () => {
97
+ const out = renderPersona({ ...AGENT, responsibilities: ["real", "", null, {}, 42] });
98
+ assert.match(out, /- real/);
99
+ assert.match(out, /- 42/);
100
+ assert.doesNotMatch(out, /\[object Object\]/);
101
+ });
102
+
103
+ test("loadPersonaBlock reads an agent repo, and fails open on a broken config", () => {
104
+ const dir = mkdtempSync(join(tmpdir(), "persona-"));
105
+ try {
106
+ mkdirSync(join(dir, "config"));
107
+ writeFileSync(join(dir, "config/agent.json"), JSON.stringify(AGENT));
108
+ assert.match(loadPersonaBlock(dir), /You are Jamie Okafor/);
109
+
110
+ writeFileSync(join(dir, "config/agent.json"), "{ not json");
111
+ assert.equal(loadPersonaBlock(dir), "");
112
+
113
+ assert.equal(loadPersonaBlock(join(dir, "nope")), "");
114
+ } finally {
115
+ rmSync(dir, { recursive: true, force: true });
116
+ }
117
+ });