@cohortapp/agent-sdk 2.4.0 → 2.5.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 (81) hide show
  1. package/bin/maestro.mjs +9 -0
  2. package/lib/backlog.mjs +35 -0
  3. package/lib/backlog.test.mjs +36 -0
  4. package/lib/channels/contract.mjs +1 -0
  5. package/lib/channels/contract.test.mjs +2 -1
  6. package/lib/channels/inbox-item.mjs +54 -0
  7. package/lib/comms/send-gate.mjs +56 -1
  8. package/lib/comms/send-gate.test.mjs +56 -0
  9. package/lib/execution/disposition.mjs +62 -2
  10. package/lib/execution/disposition.test.mjs +54 -0
  11. package/lib/execution/drive.mjs +1 -1
  12. package/lib/execution/effects.mjs +282 -24
  13. package/lib/execution/effects.test.mjs +112 -0
  14. package/lib/execution/index.mjs +1 -0
  15. package/lib/execution/intake.mjs +43 -9
  16. package/lib/execution/intake.test.mjs +46 -0
  17. package/lib/execution/pipeline.mjs +5 -0
  18. package/lib/execution/surface-policy.mjs +80 -30
  19. package/lib/goals/classify.mjs +49 -5
  20. package/lib/goals/classify.test.mjs +58 -0
  21. package/lib/goals/collaborate.mjs +131 -17
  22. package/lib/goals/collaborate.test.mjs +16 -4
  23. package/lib/goals/loop.mjs +160 -9
  24. package/lib/goals/loop.test.mjs +129 -3
  25. package/lib/kpi-sensors.mjs +666 -0
  26. package/lib/kpi-sensors.test.mjs +275 -0
  27. package/lib/kpi.mjs +23 -0
  28. package/lib/mandate/audit.mjs +3 -0
  29. package/lib/mandate/contract.mjs +277 -0
  30. package/lib/mandate/contract.test.mjs +185 -0
  31. package/lib/mandate/derive.mjs +49 -5
  32. package/lib/mandate/derive.test.mjs +7 -1
  33. package/lib/mandate/model.mjs +10 -1
  34. package/lib/mandate/model.test.mjs +22 -3
  35. package/lib/mandate/refresh.mjs +53 -5
  36. package/lib/mandate/refresh.test.mjs +83 -1
  37. package/lib/org/doctor.mjs +66 -0
  38. package/lib/org/doctor.test.mjs +73 -1
  39. package/lib/org/inbound/directedness.mjs +119 -1
  40. package/lib/org/inbound/directedness.test.mjs +67 -0
  41. package/lib/org/inbound/facts.mjs +132 -9
  42. package/lib/org/inbound/facts.test.mjs +96 -0
  43. package/lib/org/inbound/hydrate.mjs +40 -0
  44. package/lib/org/inbound/index.test.mjs +83 -0
  45. package/lib/org/inbound/project.mjs +8 -0
  46. package/lib/org/inbound/surfaces.mjs +20 -0
  47. package/lib/org/param-contract.mjs +16 -2
  48. package/lib/org/protocol.checksum +1 -1
  49. package/lib/org/protocol.mjs +214 -2
  50. package/lib/org/protocol.test.mjs +11 -2
  51. package/lib/org/push.mjs +213 -49
  52. package/lib/org/push.test.mjs +112 -10
  53. package/lib/plan/compile.mjs +85 -8
  54. package/lib/plan/compile.test.mjs +82 -0
  55. package/lib/plan/emit.test.mjs +6 -1
  56. package/lib/setup/enroll-from-cohort.mjs +22 -2
  57. package/lib/setup/enroll-from-cohort.test.mjs +25 -0
  58. package/lib/setup/sections/mandate.mjs +43 -1
  59. package/lib/subagents/schema.mjs +14 -2
  60. package/lib/subagents/schema.test.mjs +22 -0
  61. package/package.json +1 -1
  62. package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
  63. package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
  64. package/scripts/ci/check.mjs +3 -0
  65. package/scripts/ci/conformance-org-api.mjs +16 -0
  66. package/scripts/ci/journey-approval-escalation.mjs +341 -0
  67. package/scripts/daemon/agent-daemon.mjs +582 -28
  68. package/scripts/daemon/cadence-handlers.mjs +273 -17
  69. package/scripts/daemon/cadence-handlers.test.mjs +101 -0
  70. package/scripts/daemon/execution-ladder.test.mjs +430 -0
  71. package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
  72. package/scripts/daemon/maestro-daemon.mjs +53 -0
  73. package/scripts/daemon/prompt-builder.mjs +47 -0
  74. package/scripts/daemon/responder.mjs +70 -3
  75. package/scripts/poller/imap-client.mjs +20 -1
  76. package/scripts/poller/inbox-scan-poller.mjs +15 -0
  77. package/scripts/poller/utils.mjs +51 -0
  78. package/scripts/setup/generate-capability.mjs +120 -11
  79. package/scripts/setup/generate-capability.test.mjs +134 -0
  80. package/scripts/setup/generate-plan.mjs +6 -1
  81. package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
@@ -37,6 +37,11 @@ import { screenOutbound } from "../../lib/comms/send-gate.mjs";
37
37
  import { emitEvent, EVENT_TYPES } from "../../lib/diagnostics/events.mjs";
38
38
  import * as counters from "../../lib/diagnostics/counters.mjs";
39
39
  import { getHookBus } from "../../lib/hooks/bus.mjs";
40
+ // The execution ladder's rung vocabulary. This responder IS rungs 0-1
41
+ // (`function_call` / `skill`); `isQuickReply` consults the ladder's routing
42
+ // decision so a reply that needs a session, a plugin, a workflow or a team is
43
+ // not answered here just because the classifier called it simple.
44
+ import { rungById } from "../../lib/execution/route.mjs";
40
45
 
41
46
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
42
47
  const SONNET_MODEL = "claude-sonnet-4-6";
@@ -730,8 +735,23 @@ async function sendCohortReply(item, text) {
730
735
  }
731
736
  }
732
737
 
733
- export async function sendQuickResponse(item, classResult) {
738
+ export async function sendQuickResponse(item, classResult, routed = null) {
734
739
  const startTime = Date.now();
740
+ // The ladder's decision, carried onto every log row this send writes, so
741
+ // `logs/daemon/*-responses.jsonl` and `state/execution/journal.jsonl` can be
742
+ // joined on the event key. Without it the journal says "react_now at rung 1"
743
+ // and the response log says "quick_response" and nothing ties them together.
744
+ const _routed = routed || item.execution || null;
745
+ const ladder = _routed
746
+ ? {
747
+ event_key: _routed.key || null,
748
+ surface: _routed.surface || null,
749
+ disposition: _routed.disposition || null,
750
+ rung: Number.isFinite(_routed.rung) ? _routed.rung : null,
751
+ mechanism: _routed.mechanism || null,
752
+ obligation_key: _routed.obligationKey || null,
753
+ }
754
+ : null;
735
755
 
736
756
  try {
737
757
  const text = await _generateResponse(item, classResult, false);
@@ -807,10 +827,14 @@ export async function sendQuickResponse(item, classResult) {
807
827
  service: item.service,
808
828
  duration_ms: duration,
809
829
  text_length: text.length,
830
+ ...(ladder ? { ladder } : {}),
810
831
  ...sendResult,
811
832
  });
812
833
 
813
- console.log(`[responder] Quick reply to ${item.sender} (${duration}ms) — ${sendResult.via || "not_sent"}`);
834
+ console.log(
835
+ `[responder] Quick reply to ${item.sender} (${duration}ms) — ${sendResult.via || "not_sent"}` +
836
+ (ladder ? ` [rung ${ladder.rung} ${ladder.mechanism}${ladder.obligation_key ? ` · ${ladder.obligation_key}` : ""}]` : ""),
837
+ );
814
838
  return { sent: sendResult.sent, text, ...sendResult };
815
839
 
816
840
  } catch (err) {
@@ -888,7 +912,19 @@ export async function sendHoldingMessage(item, classResult) {
888
912
  * so they get tool access, conversation history, and memory lookup.
889
913
  * Quick replies are only for truly simple, low-stakes messages.
890
914
  */
891
- export function isQuickReply(classResult) {
915
+ export function isQuickReply(classResult, routed = null) {
916
+ // ── the execution ladder's RUNG, applied first and as a VETO only ────────
917
+ // This responder is, mechanically, rungs 0 and 1: a single protocol/API call
918
+ // (`function_call`) or one bounded generation against a prompt (`skill`).
919
+ // Rung 2+ names machinery it does not have — an integration plugin, a Claude
920
+ // Code session, a durable workflow, a sub-agent team. Answering those here
921
+ // would be a wrong answer delivered quickly.
922
+ //
923
+ // A veto, never a promotion: a low rung does not make an `action: research`
924
+ // item quick-repliable. And a null `routed` (an item the ladder could not
925
+ // place, or a caller that has no ladder) leaves the original rules untouched.
926
+ if (!rungPermitsQuickReply(routed)) return false;
927
+
892
928
  // These always need full sessions — never quick reply
893
929
  if (classResult.action === "research") return false;
894
930
  if (classResult.action === "draft") return false;
@@ -908,3 +944,34 @@ export function isQuickReply(classResult) {
908
944
 
909
945
  return false;
910
946
  }
947
+
948
+ /**
949
+ * Is this responder the mechanism the execution ladder routed to?
950
+ *
951
+ * Exported so the decision is testable on its own and so nothing has to
952
+ * re-derive "which rungs is the quick responder". NEVER SILENT: a rung the
953
+ * daemon has no mechanism for at all (4 = durable workflow, 5 = sub-agent team)
954
+ * is reported as the delivery gap it is rather than quietly running as an
955
+ * ordinary session and looking like a success.
956
+ *
957
+ * @param {object|null} routed a `lib/execution/disposition.decide` decision
958
+ * @returns {boolean}
959
+ */
960
+ export function rungPermitsQuickReply(routed) {
961
+ if (!routed || !Number.isFinite(routed.rung)) return true;
962
+ if (routed.rung <= 1) return true;
963
+ const rung = rungById(routed.rung);
964
+ if (routed.rung >= 4) {
965
+ console.warn(
966
+ `[responder] the ladder routed this to rung ${routed.rung} (${rung ? rung.mechanism : "unknown"}), which no ` +
967
+ "mechanism in this daemon implements — it will run as a plain Claude session. The rung is journalled; " +
968
+ "the workflow-runner / subagent-fanout gap is real.",
969
+ );
970
+ return false;
971
+ }
972
+ console.log(
973
+ `[responder] rung ${routed.rung} (${rung ? rung.label : "?"}) needs more than an in-process reply — ` +
974
+ "vetoing the quick path",
975
+ );
976
+ return false;
977
+ }
@@ -6,7 +6,6 @@
6
6
  // connect → search UNSEEN → fetch headers+body → write JSON → disconnect
7
7
  // flow is identical and lives here.
8
8
 
9
- import { ImapFlow } from "imapflow";
10
9
  import { writeFileSync, existsSync, readFileSync, mkdirSync } from "fs";
11
10
  import { join, dirname } from "path";
12
11
  import { createHash } from "crypto";
@@ -162,6 +161,26 @@ export async function pollImapInbox({
162
161
  let lastMid = cursor.last_message_id;
163
162
  let totalProcessed = cursor.messages_processed;
164
163
 
164
+ // `imapflow` is NOT a declared dependency of this package (package.json ships
165
+ // only better-sqlite3 + js-yaml), yet this module used to import it at the top
166
+ // level — and `scripts/daemon/agent-daemon.mjs` imports this module through
167
+ // gmail-poller. On any tree without a stray hoisted copy, importing the DAEMON
168
+ // therefore threw ERR_MODULE_NOT_FOUND before a single line of it ran: no
169
+ // inbox poll, no cadence bus, no execution ladder, nothing. Observed live
170
+ // mid-trace when a concurrent `npm` pruned node_modules.
171
+ //
172
+ // Loading it HERE means the whole daemon survives its absence and only the
173
+ // IMAP lane degrades — and says so.
174
+ let ImapFlow;
175
+ try {
176
+ ({ ImapFlow } = await import("imapflow"));
177
+ } catch (err) {
178
+ const msg = `${logPrefix} imapflow is not installed (${err && err.message ? err.message : err}) — IMAP polling is OFF for this account. Install it, or leave GMAIL_APP_PASSWORD unset to silence this lane.`;
179
+ console.warn(msg);
180
+ errors.push(msg);
181
+ return { newCount: 0, errors };
182
+ }
183
+
165
184
  const client = new ImapFlow({
166
185
  host: "imap.gmail.com",
167
186
  port: 993,
@@ -111,6 +111,21 @@ export function parseInboxItemYaml(body) {
111
111
  if (channelType) item.channel_type = channelType;
112
112
  if (isPrivate !== undefined) item.is_private = isPrivate;
113
113
  if (isDm !== undefined) item.is_dm = isDm;
114
+ // The hq chain kind, when the org projection carried one through the writer.
115
+ // Attached only when present so a non-org item keeps exactly its old shape;
116
+ // `lib/execution/intake.fromInboxItem` prefers it over the surface so an
117
+ // obligation binds identically on the push and poll lanes.
118
+ const eventKind = scalar("event_kind");
119
+ if (eventKind) item.event_kind = eventKind;
120
+ // The directedness verdict's own reason, and the surface's subject id. Same
121
+ // rule as `event_kind`: attached only when the writer knew, so a non-org item
122
+ // keeps exactly its old shape. `intake.fromInboxItem` PREFERS `direct_reason`
123
+ // over its per-surface guess — the guess feeds `tierOf`, so getting it wrong
124
+ // is getting the tier and therefore the decision wrong.
125
+ const directReason = scalar("direct_reason");
126
+ if (directReason) item.direct_reason = directReason;
127
+ const scopeId = scalar("scope_id");
128
+ if (scopeId) item.scope_id = scopeId;
114
129
 
115
130
  return item;
116
131
  }
@@ -177,6 +177,57 @@ priority_signals:
177
177
  raw_ref: "${item.raw_ref || ""}"
178
178
  `;
179
179
 
180
+ // THE SURFACE ROUTING HINT. `lib/channels/contract.mjs EVENT_KINDS` and
181
+ // `lib/org/inbound/surfaces.mjs` agree on thirteen inbound kinds
182
+ // (task_assigned, task_comment, approval, decision, escalation, handoff,
183
+ // calendar, file_comment, mention, thread_reply, …), `eventToInboxItem`
184
+ // stamps the right one, and `inbox-scan-poller.mjs parseInboxItemYaml` reads
185
+ // `kind:` back — but this writer never emitted the line, so EVERY wide-inbound
186
+ // surface came off disk as a plain "message".
187
+ //
188
+ // Traced end-to-end on a real board assignment: the item was delivered with
189
+ // `kind: "task_assigned"`, written here, scanned back as `kind: "message"`,
190
+ // and `lib/execution/intake.mjs` had to RECONSTRUCT the surface out of
191
+ // `raw_ref` — it logged `intake degraded: verdict_reconstructed:task_assigned`
192
+ // and wrote the plan-drift row with `kind: "message"`. It survived only
193
+ // because raw_ref happens to encode the surface; any producer that writes an
194
+ // item without one loses the surface completely and a task assignment is
195
+ // triaged as ordinary chat.
196
+ //
197
+ // Written only when it is not the default, exactly like `channel_type` /
198
+ // `is_private` below: an absent line already means "message" to the reader,
199
+ // so existing items are unchanged.
200
+ if (item.kind && item.kind !== "message") {
201
+ yaml += `kind: "${item.kind}"\n`;
202
+ }
203
+
204
+ // The hq chain kind (`item.blocked`, `item.commented`, …), when the org
205
+ // projection supplied one. See the note in lib/channels/inbox-item.mjs: ten
206
+ // board kinds share the surface `task_comment`, and without this line an
207
+ // obligation scoped to a specific kind binds on the push lane and reads
208
+ // uncovered on the poll lane.
209
+ if (item.event_kind) {
210
+ yaml += `event_kind: "${item.event_kind}"\n`;
211
+ }
212
+
213
+ // THE DIRECTEDNESS REASON and THE SURFACE'S SUBJECT ID, for exactly the reason
214
+ // above: this writer enumerates its fields, so anything not named here dies at
215
+ // the YAML boundary and the poll lane behaves differently from the push lane.
216
+ //
217
+ // `direct_reason` is WHY the wide reader decided this event is mine
218
+ // (`reviewer`, `waiting_on`, `mention`, …). `lib/execution/intake.mjs` used to
219
+ // re-guess it from a per-surface default and get it wrong — and the reason
220
+ // feeds `disposition.tierOf`, so a wrong guess is a wrong TIER and a wrong
221
+ // decision. `scope_id` is the task/decision/file the event is about, which
222
+ // `channel_id` deliberately never carries and which `effects.escalate` needs
223
+ // to satisfy hq's "an escalation must reference a task or a channel".
224
+ if (item.direct_reason) {
225
+ yaml += `direct_reason: "${item.direct_reason}"\n`;
226
+ }
227
+ if (item.scope_id) {
228
+ yaml += `scope_id: "${item.scope_id}"\n`;
229
+ }
230
+
180
231
  // Add thread context if present (conversation history before this message)
181
232
  if (item.thread_context) {
182
233
  const tc = item.thread_context.replace(/\n/g, "\n ");
@@ -27,6 +27,7 @@
27
27
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
28
28
  import { join, dirname, resolve } from "node:path";
29
29
  import { fileURLToPath, pathToFileURL } from "node:url";
30
+ import { bodyTokens, normaliseModel, stringifyFrontmatter } from "../../lib/subagents/schema.mjs";
30
31
 
31
32
  const here = dirname(fileURLToPath(import.meta.url));
32
33
  const MAESTRO_ROOT =
@@ -41,30 +42,136 @@ function write(rel, body) {
41
42
  writeFileSync(p, body, "utf-8");
42
43
  }
43
44
 
44
- function renderAgentMd(a, identity, profile) {
45
- const towers = (a.towers || []).join(", ");
46
- return `---
47
- name: ${a.id}
48
- description: ${a.role} — ${a.mandate}
49
- model: ${a.model || "sonnet"}
50
- ---
45
+ /**
46
+ * Tool grants by capability-pack `tier`, least-privilege first.
47
+ *
48
+ * The packs (archetypes/capabilities/*.yaml) carry `tier` but NOT `tools`, and
49
+ * they are SDK-vendored data — adding a `tools:` key to each of ~400 pack agents
50
+ * would be overwritten on the next upgrade. So the grant policy lives here, in
51
+ * the generator that writes the frontmatter, and a pack MAY still override it
52
+ * per-agent by declaring its own `tools` (see {@link toolsForAgent}).
53
+ *
54
+ * The tiers, and why each gets what:
55
+ * support — reads, writes and searches repo files. No shell: these are the
56
+ * narrow analysts/stewards, and a tier that only ever edits YAML and
57
+ * markdown has no business spawning processes.
58
+ * core — the above plus Bash, because the working agents run the repo's own
59
+ * scripts (queue sweeps, PDF generation, healthchecks).
60
+ * lead — the above plus WebFetch, because leads are the ones doing external
61
+ * research to brief a decision.
62
+ *
63
+ * Deliberately NOT granted at any tier: Task. A sub-agent that can spawn further
64
+ * sub-agents makes the delegation tree unbounded and unobservable, which is the
65
+ * opposite of what a supervised platform needs. Grant it explicitly, per agent,
66
+ * if a case ever justifies it.
67
+ *
68
+ * The vocabulary matches the tools the SDK-shipped agents in agents/ already
69
+ * declare — extend it in lockstep with those, not ad hoc.
70
+ * @type {Readonly<Record<string, readonly string[]>>}
71
+ */
72
+ export const TOOLS_BY_TIER = Object.freeze({
73
+ lead: Object.freeze([
74
+ "Read",
75
+ "Write",
76
+ "Edit",
77
+ "Glob",
78
+ "Grep",
79
+ "Bash",
80
+ "WebFetch",
81
+ ]),
82
+ core: Object.freeze(["Read", "Write", "Edit", "Glob", "Grep", "Bash"]),
83
+ support: Object.freeze(["Read", "Write", "Edit", "Glob", "Grep"]),
84
+ });
51
85
 
52
- You are the **${a.role}** sub-agent for ${identity.fullName} (${identity.title}${identity.company ? ` at ${identity.company}` : ""}), reporting into the ${a.team} team.
86
+ /**
87
+ * The tool grant for one pack agent: an explicit pack-level `tools` wins,
88
+ * otherwise the tier policy, otherwise `core` (the safe middle — an unknown tier
89
+ * is a pack authoring slip, and failing closed to `support` would quietly strip
90
+ * Bash from an agent that needs it).
91
+ * @param {{tier?:string, tools?:string[]}} a
92
+ * @returns {string[]}
93
+ */
94
+ export function toolsForAgent(a) {
95
+ const explicit = Array.isArray(a && a.tools)
96
+ ? a.tools.map((t) => String(t).trim()).filter(Boolean)
97
+ : null;
98
+ if (explicit && explicit.length) return explicit;
99
+ return [
100
+ ...(TOOLS_BY_TIER[String((a && a.tier) || "")] || TOOLS_BY_TIER.core),
101
+ ];
102
+ }
103
+
104
+ /**
105
+ * Flatten a pack string into a frontmatter-safe one-liner.
106
+ *
107
+ * `description` is a `key: scalar` in a CLOSED grammar (lib/subagents/schema.mjs):
108
+ * a newline would end the value mid-sentence and an unquoted " #" would be eaten
109
+ * as a comment. Pack mandates are hand-written prose, so neither is hypothetical.
110
+ * @param {string} s @returns {string}
111
+ */
112
+ function oneLine(s) {
113
+ return String(s == null ? "" : s)
114
+ .replace(/\s+/g, " ")
115
+ .replace(/ #/g, " ")
116
+ .trim();
117
+ }
118
+
119
+ /**
120
+ * Render one `agents/<id>/agent.md`.
121
+ *
122
+ * Two rules this function exists to hold, both learned the hard way:
123
+ *
124
+ * 1. IDENTITY STAYS AS TOKENS. The body carries `{{agent.*}}` and lib/render.mjs
125
+ * resolves them at LOAD time. Interpolating identity HERE bakes whatever
126
+ * config/agent.json happened to hold at generation time into a file that then
127
+ * never updates — which is how 57 agents shipped saying "sub-agent for ( at
128
+ * UNCONFIGURED)". scripts/ci/check-unresolved-tokens.mjs scans agents/ for
129
+ * exactly this reason, and lib/subagents/schema.mjs §3 states the rule.
130
+ *
131
+ * 2. THE FRONTMATTER MUST VALIDATE. Emitting name/description/model but no
132
+ * `tools` produced a fleet that fails validateAgentMd() on every single
133
+ * generated agent. Frontmatter is built as an object and emitted through the
134
+ * registry's own stringifyFrontmatter() so key order and sequence shape match
135
+ * the SDK-shipped files byte for byte.
136
+ *
137
+ * @param {object} a pack agent entry ({id, role, mandate, model, tier, towers, team})
138
+ * @param {{function:string, altitude:string}} profile resolved archetype
139
+ * @returns {string} full agent.md text
140
+ */
141
+ export function renderAgentMd(a, profile) {
142
+ const towers = (a.towers || []).join(", ");
143
+ const body = `You are the **${a.role}** sub-agent for {{agent.fullName}}, {{agent.title}} at {{agent.company}}, reporting into the ${a.team} team.
53
144
 
54
145
  ## Mandate
146
+
55
147
  ${a.mandate}
56
148
 
57
149
  ## Towers served
150
+
58
151
  ${towers ? `- ${towers}` : "- (general)"}
59
152
 
60
153
  ## Operating rules
61
- - Act within ${identity.firstName}'s autonomy model and decision rights — see \`config/operating-charter.md\`.
154
+
155
+ - Act within {{agent.firstName}}'s autonomy model and decision rights — see \`config/operating-charter.md\`.
62
156
  - Follow \`policies/communication-style.md\` for any outbound communication.
63
157
  - Update the backlog item you are working and log significant decisions to the decision log.
64
- - Escalate anything beyond your remit to ${identity.firstName} (and onward per the escalation protocol).
158
+ - Escalate anything beyond your remit to {{agent.firstName}} (and onward per the escalation protocol).
65
159
 
66
160
  _Generated from the ${profile.function} × ${profile.altitude} capability pack — enrich this mandate with company-specific context at init._
67
161
  `;
162
+ // `model` normalises through the registry's alias map so a generated agent and
163
+ // a shipped one are byte-comparable — the packs say `sonnet`, the shipped files
164
+ // say `claude-sonnet-4-6`, and that drift is exactly what MODEL_ALIASES closes.
165
+ const frontmatter = {
166
+ name: a.id,
167
+ description: oneLine(`${a.role} — ${a.mandate}`),
168
+ model: normaliseModel(a.model || "sonnet") || "claude-sonnet-4-6",
169
+ tools: toolsForAgent(a),
170
+ // Derived from the body, never hand-listed: validateAgentMd errors if the
171
+ // body uses a token the list omits, so the two must be generated together.
172
+ tokens: bodyTokens(body),
173
+ };
174
+ return `---\n${stringifyFrontmatter(frontmatter)}---\n\n${body}`;
68
175
  }
69
176
 
70
177
  function renderSkillMd(s, identity) {
@@ -117,7 +224,7 @@ async function main() {
117
224
 
118
225
  // 1. role sub-agent definitions (standard defaults already ship in ~/maestro/agents/)
119
226
  for (const a of surface.roleAgents) {
120
- write(join("agents", a.id, "agent.md"), renderAgentMd(a, identity, profile));
227
+ write(join("agents", a.id, "agent.md"), renderAgentMd(a, profile));
121
228
  }
122
229
 
123
230
  // 2. skills + manifest
@@ -157,6 +264,8 @@ async function main() {
157
264
  return 0;
158
265
  }
159
266
 
267
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
160
268
  main()
161
269
  .then((c) => process.exit(c))
162
270
  .catch((e) => { console.error(`[generate-capability] FAILED: ${e && e.message ? e.message : e}`); process.exit(1); });
271
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Tests for generate-capability.mjs — the sub-agent definition renderer.
3
+ *
4
+ * These exist because the module had NO tests and could not have had any: it
5
+ * called main() at module scope, so importing it regenerated the entire
6
+ * capability surface across the repo. The direct-invocation guard that made this
7
+ * file possible is itself part of the fix.
8
+ *
9
+ * Run: node --test scripts/setup/generate-capability.test.mjs
10
+ * @module scripts/setup/generate-capability.test
11
+ */
12
+
13
+ import { describe, it } from "node:test";
14
+ import assert from "node:assert/strict";
15
+
16
+ import {
17
+ TOOLS_BY_TIER,
18
+ renderAgentMd,
19
+ toolsForAgent,
20
+ } from "./generate-capability.mjs";
21
+ import { checkAgentFile, parseAgentMd } from "../../lib/subagents/schema.mjs";
22
+
23
+ const PROFILE = { function: "executive-operator", altitude: "c-suite" };
24
+ const AGENT = {
25
+ id: "chief-of-staff",
26
+ role: "Chief of Staff",
27
+ mandate: "Run the principal's office so nothing is dropped.",
28
+ model: "opus",
29
+ tier: "lead",
30
+ towers: ["executive-office"],
31
+ team: "executive-office",
32
+ };
33
+
34
+ describe("toolsForAgent", () => {
35
+ it("grants by tier, least privilege at support", () => {
36
+ assert.deepEqual(toolsForAgent({ tier: "support" }), [
37
+ ...TOOLS_BY_TIER.support,
38
+ ]);
39
+ assert.ok(!toolsForAgent({ tier: "support" }).includes("Bash"));
40
+ assert.ok(toolsForAgent({ tier: "core" }).includes("Bash"));
41
+ assert.ok(toolsForAgent({ tier: "lead" }).includes("WebFetch"));
42
+ });
43
+
44
+ it("never grants Task at any tier (delegation must stay observable)", () => {
45
+ for (const tier of Object.keys(TOOLS_BY_TIER)) {
46
+ assert.ok(
47
+ !toolsForAgent({ tier }).includes("Task"),
48
+ `${tier} must not carry Task`,
49
+ );
50
+ }
51
+ });
52
+
53
+ it("falls back to core for an unknown or missing tier", () => {
54
+ assert.deepEqual(toolsForAgent({}), [...TOOLS_BY_TIER.core]);
55
+ assert.deepEqual(toolsForAgent({ tier: "nonsense" }), [
56
+ ...TOOLS_BY_TIER.core,
57
+ ]);
58
+ });
59
+
60
+ it("lets a pack override the tier policy explicitly", () => {
61
+ assert.deepEqual(toolsForAgent({ tier: "support", tools: ["Read"] }), [
62
+ "Read",
63
+ ]);
64
+ });
65
+
66
+ it("returns a fresh array so a caller cannot mutate the policy", () => {
67
+ const got = toolsForAgent({ tier: "core" });
68
+ got.push("Task");
69
+ assert.ok(!TOOLS_BY_TIER.core.includes("Task"));
70
+ });
71
+ });
72
+
73
+ describe("renderAgentMd", () => {
74
+ it("produces a definition the registry validator accepts", () => {
75
+ const res = checkAgentFile(renderAgentMd(AGENT, PROFILE), AGENT.id);
76
+ assert.equal(res.ok, true, res.errors.join("; "));
77
+ });
78
+
79
+ it("emits tools — the key whose absence broke 57 of 70 agents", () => {
80
+ const { frontmatter } = parseAgentMd(renderAgentMd(AGENT, PROFILE));
81
+ assert.ok(Array.isArray(frontmatter.tools));
82
+ assert.ok(frontmatter.tools.length > 0);
83
+ });
84
+
85
+ it("normalises the model alias to the full id the runtime dispatches on", () => {
86
+ const { frontmatter } = parseAgentMd(renderAgentMd(AGENT, PROFILE));
87
+ assert.equal(frontmatter.model, "claude-opus-4-8");
88
+ });
89
+
90
+ it("leaves identity as {{agent.*}} tokens rather than baking it in", () => {
91
+ // The regression this guards: generating before config/agent.json was
92
+ // populated baked the empty values in permanently, shipping 57 agents that
93
+ // said "sub-agent for ( at UNCONFIGURED)".
94
+ const text = renderAgentMd(AGENT, PROFILE);
95
+ assert.match(text, /\{\{agent\.fullName\}\}/);
96
+ assert.match(text, /\{\{agent\.firstName\}\}/);
97
+ assert.doesNotMatch(text, /UNCONFIGURED/);
98
+ });
99
+
100
+ it("declares exactly the tokens the body uses", () => {
101
+ const { frontmatter } = parseAgentMd(renderAgentMd(AGENT, PROFILE));
102
+ assert.deepEqual(frontmatter.tokens, [
103
+ "agent.company",
104
+ "agent.firstName",
105
+ "agent.fullName",
106
+ "agent.title",
107
+ ]);
108
+ });
109
+
110
+ it("keeps description on one line even when the mandate would break the grammar", () => {
111
+ const nasty = {
112
+ ...AGENT,
113
+ mandate: "Own the thing.\nAlso: the # other thing.",
114
+ };
115
+ const res = checkAgentFile(renderAgentMd(nasty, PROFILE), nasty.id);
116
+ assert.equal(res.ok, true, res.errors.join("; "));
117
+ const { frontmatter } = parseAgentMd(renderAgentMd(nasty, PROFILE));
118
+ assert.ok(!frontmatter.description.includes("\n"));
119
+ });
120
+
121
+ it("is deterministic — re-rendering yields identical bytes", () => {
122
+ // Non-determinism here would move hashFile() on every regeneration and make
123
+ // the registry's local-edit detection permanently wrong.
124
+ assert.equal(renderAgentMd(AGENT, PROFILE), renderAgentMd(AGENT, PROFILE));
125
+ });
126
+
127
+ it("handles an agent serving no towers", () => {
128
+ const res = checkAgentFile(
129
+ renderAgentMd({ ...AGENT, towers: [] }, PROFILE),
130
+ AGENT.id,
131
+ );
132
+ assert.equal(res.ok, true, res.errors.join("; "));
133
+ });
134
+ });
@@ -65,12 +65,17 @@ async function main() {
65
65
  mandate: record.body || {}, mandateVersion: record.version || 0,
66
66
  manifest: inputs.manifest || { entries: [] }, profile: inputs.profile, orgContext: inputs.orgContext,
67
67
  standardCadences: inputs.standardCadences, archetypeCadences: inputs.archetypeCadences,
68
+ log: (level, msg) => (level === "error" ? console.error : console.warn)(`[generate-plan] ${msg}`),
68
69
  });
69
70
  for (const ob of plan.obligations) {
70
71
  const when = ob.schedule ? (ob.schedule.interval != null ? `every ${ob.schedule.interval}s` : JSON.stringify(ob.schedule.calendar)) : (ob.trigger ? `on ${ob.trigger.topic}.${ob.trigger.kind || "*"}` : "-");
71
- console.log(` ${ob.kind.padEnd(9)} ${ob.key.padEnd(40)} ${String(when).padEnd(34)} uses=[${(ob.uses || []).join(",")}]`);
72
+ // `budget=—` is a real reading, not a blank: it means hq published no
73
+ // funding for this obligation. It used to read `500` on every single line.
74
+ const budget = Number.isFinite(ob.budget_cents_per_period) ? `${ob.budget_cents_per_period}c` : "—";
75
+ console.log(` ${ob.kind.padEnd(9)} ${ob.key.padEnd(40)} ${String(when).padEnd(34)} budget=${budget.padEnd(8)} uses=[${(ob.uses || []).join(",")}]`);
72
76
  }
73
77
  console.log(`[generate-plan] ${plan.counts.total} obligations · obligationsHash ${plan.obligationsHash.slice(0, 16)} (dry run — nothing written)`);
78
+ console.log(`[generate-plan] budget: ${plan.contract.unmetered ? "UNMETERED (hq published no funding)" : `metered, contract v${plan.contract.version}`}`);
74
79
  return 0;
75
80
  }
76
81