@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.
- package/bin/maestro.mjs +9 -0
- package/lib/backlog.mjs +35 -0
- package/lib/backlog.test.mjs +36 -0
- package/lib/channels/contract.mjs +1 -0
- package/lib/channels/contract.test.mjs +2 -1
- package/lib/channels/inbox-item.mjs +54 -0
- package/lib/comms/send-gate.mjs +56 -1
- package/lib/comms/send-gate.test.mjs +56 -0
- package/lib/execution/disposition.mjs +62 -2
- package/lib/execution/disposition.test.mjs +54 -0
- package/lib/execution/drive.mjs +1 -1
- package/lib/execution/effects.mjs +282 -24
- package/lib/execution/effects.test.mjs +112 -0
- package/lib/execution/index.mjs +1 -0
- package/lib/execution/intake.mjs +43 -9
- package/lib/execution/intake.test.mjs +46 -0
- package/lib/execution/pipeline.mjs +5 -0
- package/lib/execution/surface-policy.mjs +80 -30
- package/lib/goals/classify.mjs +49 -5
- package/lib/goals/classify.test.mjs +58 -0
- package/lib/goals/collaborate.mjs +131 -17
- package/lib/goals/collaborate.test.mjs +16 -4
- package/lib/goals/loop.mjs +160 -9
- package/lib/goals/loop.test.mjs +129 -3
- package/lib/kpi-sensors.mjs +666 -0
- package/lib/kpi-sensors.test.mjs +275 -0
- package/lib/kpi.mjs +23 -0
- package/lib/mandate/audit.mjs +3 -0
- package/lib/mandate/contract.mjs +277 -0
- package/lib/mandate/contract.test.mjs +185 -0
- package/lib/mandate/derive.mjs +49 -5
- package/lib/mandate/derive.test.mjs +7 -1
- package/lib/mandate/model.mjs +10 -1
- package/lib/mandate/model.test.mjs +22 -3
- package/lib/mandate/refresh.mjs +53 -5
- package/lib/mandate/refresh.test.mjs +83 -1
- package/lib/org/doctor.mjs +66 -0
- package/lib/org/doctor.test.mjs +73 -1
- package/lib/org/inbound/directedness.mjs +119 -1
- package/lib/org/inbound/directedness.test.mjs +67 -0
- package/lib/org/inbound/facts.mjs +132 -9
- package/lib/org/inbound/facts.test.mjs +96 -0
- package/lib/org/inbound/hydrate.mjs +40 -0
- package/lib/org/inbound/index.test.mjs +83 -0
- package/lib/org/inbound/project.mjs +8 -0
- package/lib/org/inbound/surfaces.mjs +20 -0
- package/lib/org/param-contract.mjs +16 -2
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +214 -2
- package/lib/org/protocol.test.mjs +11 -2
- package/lib/org/push.mjs +213 -49
- package/lib/org/push.test.mjs +112 -10
- package/lib/plan/compile.mjs +85 -8
- package/lib/plan/compile.test.mjs +82 -0
- package/lib/plan/emit.test.mjs +6 -1
- package/lib/setup/enroll-from-cohort.mjs +22 -2
- package/lib/setup/enroll-from-cohort.test.mjs +25 -0
- package/lib/setup/sections/mandate.mjs +43 -1
- package/lib/subagents/schema.mjs +14 -2
- package/lib/subagents/schema.test.mjs +22 -0
- package/package.json +1 -1
- package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
- package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/conformance-org-api.mjs +16 -0
- package/scripts/ci/journey-approval-escalation.mjs +341 -0
- package/scripts/daemon/agent-daemon.mjs +582 -28
- package/scripts/daemon/cadence-handlers.mjs +273 -17
- package/scripts/daemon/cadence-handlers.test.mjs +101 -0
- package/scripts/daemon/execution-ladder.test.mjs +430 -0
- package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
- package/scripts/daemon/maestro-daemon.mjs +53 -0
- package/scripts/daemon/prompt-builder.mjs +47 -0
- package/scripts/daemon/responder.mjs +70 -3
- package/scripts/poller/imap-client.mjs +20 -1
- package/scripts/poller/inbox-scan-poller.mjs +15 -0
- package/scripts/poller/utils.mjs +51 -0
- package/scripts/setup/generate-capability.mjs +120 -11
- package/scripts/setup/generate-capability.test.mjs +134 -0
- package/scripts/setup/generate-plan.mjs +6 -1
- 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(
|
|
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
|
}
|
package/scripts/poller/utils.mjs
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|