@cohortapp/agent-sdk 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/maestro.mjs +37 -50
- package/framework-features.json +30 -0
- package/lib/backlog.mjs +136 -0
- package/lib/cadences.mjs +63 -2
- package/lib/cadences.test.mjs +105 -0
- package/lib/capability/inventory.mjs +542 -0
- package/lib/capability/inventory.test.mjs +232 -0
- package/lib/capability/probe.mjs +255 -0
- package/lib/channels/contract.mjs +37 -1
- package/lib/channels/contract.test.mjs +25 -1
- package/lib/channels/inbox-item.mjs +20 -0
- package/lib/claude-bin.mjs +37 -3
- package/lib/claude-bin.test.mjs +42 -8
- package/lib/execution/disposition.mjs +501 -0
- package/lib/execution/disposition.test.mjs +482 -0
- package/lib/execution/drive.mjs +352 -0
- package/lib/execution/drive.test.mjs +270 -0
- package/lib/execution/effects.mjs +340 -0
- package/lib/execution/effects.test.mjs +193 -0
- package/lib/execution/index.mjs +152 -0
- package/lib/execution/intake.mjs +581 -0
- package/lib/execution/intake.test.mjs +343 -0
- package/lib/execution/journal.mjs +374 -0
- package/lib/execution/journal.test.mjs +261 -0
- package/lib/execution/match.mjs +331 -0
- package/lib/execution/match.test.mjs +235 -0
- package/lib/execution/pipeline.mjs +341 -0
- package/lib/execution/pipeline.test.mjs +389 -0
- package/lib/execution/route.mjs +332 -0
- package/lib/execution/route.test.mjs +186 -0
- package/lib/execution/surface-policy.mjs +446 -0
- package/lib/execution/surface-policy.test.mjs +162 -0
- package/lib/goals/admission.mjs +209 -0
- package/lib/goals/admission.test.mjs +139 -0
- package/lib/goals/classify.mjs +206 -0
- package/lib/goals/classify.test.mjs +109 -0
- package/lib/goals/collaborate.mjs +415 -0
- package/lib/goals/collaborate.test.mjs +324 -0
- package/lib/goals/gaps.mjs +111 -0
- package/lib/goals/gaps.test.mjs +284 -0
- package/lib/goals/loop.mjs +537 -0
- package/lib/goals/loop.test.mjs +719 -0
- package/lib/identity/persona.mjs +247 -0
- package/lib/identity/persona.test.mjs +117 -0
- package/lib/kpi.mjs +469 -0
- package/lib/kpi.test.mjs +244 -0
- package/lib/mandate/audit.mjs +168 -0
- package/lib/mandate/audit.test.mjs +195 -0
- package/lib/mandate/cache.mjs +162 -0
- package/lib/mandate/derive.mjs +317 -0
- package/lib/mandate/derive.test.mjs +224 -0
- package/lib/mandate/model.mjs +352 -0
- package/lib/mandate/model.test.mjs +145 -0
- package/lib/mandate/refresh.mjs +187 -0
- package/lib/mandate/refresh.test.mjs +293 -0
- package/lib/mcp/server.test.mjs +4 -4
- package/lib/org/approvals.mjs +14 -2
- package/lib/org/client.mjs +79 -25
- package/lib/org/client.test.mjs +54 -1
- package/lib/org/doctor.mjs +64 -0
- package/lib/org/doctor.test.mjs +31 -2
- package/lib/org/inbound/directedness.mjs +720 -0
- package/lib/org/inbound/directedness.test.mjs +543 -0
- package/lib/org/inbound/facts.mjs +501 -0
- package/lib/org/inbound/facts.test.mjs +375 -0
- package/lib/org/inbound/hydrate.mjs +535 -0
- package/lib/org/inbound/hydrate.test.mjs +326 -0
- package/lib/org/inbound/index.mjs +233 -0
- package/lib/org/inbound/index.test.mjs +324 -0
- package/lib/org/inbound/io.mjs +141 -0
- package/lib/org/inbound/project.mjs +201 -0
- package/lib/org/inbound/project.test.mjs +287 -0
- package/lib/org/inbound/surfaces.mjs +257 -0
- package/lib/org/knowledge.mjs +10 -1
- package/lib/org/knowledge.test.mjs +8 -1
- package/lib/org/leases.mjs +5 -0
- package/lib/org/mesh.mjs +45 -2
- package/lib/org/mesh.test.mjs +55 -0
- package/lib/org/messaging.mjs +180 -15
- package/lib/org/messaging.test.mjs +117 -0
- package/lib/org/param-contract.mjs +694 -0
- package/lib/org/param-contract.test.mjs +451 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +8 -0
- package/lib/org/protocol.test.mjs +5 -1
- package/lib/org/push.mjs +1025 -0
- package/lib/org/push.test.mjs +690 -0
- package/lib/org/tool-surface.mjs +138 -38
- package/lib/org/tool-surface.test.mjs +13 -8
- package/lib/org/typing.mjs +341 -0
- package/lib/org/typing.test.mjs +291 -0
- package/lib/plan/compile.mjs +510 -0
- package/lib/plan/compile.test.mjs +286 -0
- package/lib/plan/emit.mjs +256 -0
- package/lib/plan/emit.test.mjs +246 -0
- package/lib/plan/explain.mjs +226 -0
- package/lib/plan/explain.test.mjs +188 -0
- package/lib/plan/schema.mjs +140 -0
- package/lib/resource-governor.mjs +47 -1
- package/lib/resource-governor.test.mjs +21 -1
- package/lib/setup/enroll-from-cohort.mjs +84 -16
- package/lib/setup/enroll-from-cohort.test.mjs +43 -1
- package/lib/setup/sections/identity.mjs +15 -4
- package/lib/setup/sections/identity.test.mjs +94 -0
- package/lib/setup/sections/inventory.mjs +178 -0
- package/lib/setup/sections/inventory.test.mjs +198 -0
- package/lib/setup/sections/mandate.mjs +392 -0
- package/lib/setup/sections/mandate.test.mjs +373 -0
- package/lib/setup/sections/subagents.mjs +427 -0
- package/lib/setup/sections/subagents.test.mjs +429 -0
- package/lib/setup/sections/verify.mjs +121 -0
- package/lib/setup/sections/verify.test.mjs +175 -0
- package/lib/setup/sot.mjs +2 -0
- package/lib/subagents/cli.mjs +463 -0
- package/lib/subagents/cli.test.mjs +389 -0
- package/lib/subagents/client.mjs +373 -0
- package/lib/subagents/client.test.mjs +309 -0
- package/lib/subagents/gap.mjs +268 -0
- package/lib/subagents/gap.test.mjs +234 -0
- package/lib/subagents/lock.mjs +296 -0
- package/lib/subagents/lock.test.mjs +248 -0
- package/lib/subagents/manifest.mjs +224 -0
- package/lib/subagents/manifest.test.mjs +175 -0
- package/lib/subagents/refs.mjs +274 -0
- package/lib/subagents/refs.test.mjs +204 -0
- package/lib/subagents/resolve.mjs +455 -0
- package/lib/subagents/resolve.test.mjs +422 -0
- package/lib/subagents/schema.mjs +467 -0
- package/lib/subagents/schema.test.mjs +306 -0
- package/package.json +9 -4
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
- package/policies/ai-disclosure.yaml +42 -2
- package/scaffold/CLAUDE.md +16 -2
- package/schedules/triggers/goal-steward.md +79 -0
- package/scripts/ci/conformance-org-api.mjs +792 -0
- package/scripts/ci/conformance-org-api.test.mjs +417 -0
- package/scripts/daemon/agent-daemon.mjs +70 -11
- package/scripts/daemon/cadence-handlers.mjs +187 -5
- package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
- package/scripts/daemon/inbox-deferral.mjs +45 -2
- package/scripts/daemon/inbox-deferral.test.mjs +56 -0
- package/scripts/daemon/inbox-wake.mjs +282 -0
- package/scripts/daemon/inbox-wake.test.mjs +199 -0
- package/scripts/daemon/maestro-daemon.mjs +23 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/responder.mjs +56 -0
- package/scripts/daemon/typing-registry.mjs +55 -2
- package/scripts/daemon/typing-registry.test.mjs +25 -0
- package/scripts/local-triggers/generate-plists.test.mjs +5 -5
- package/scripts/poller/inbox-scan-poller.mjs +26 -1
- package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
- package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
- package/scripts/poller/slack-poller.mjs +32 -0
- package/scripts/poller/slack-socket-mode.mjs +27 -1
- package/scripts/poller/slack-socket-mode.test.mjs +52 -0
- package/scripts/poller/utils.mjs +47 -0
- package/scripts/setup/gen-subagent-manifest.mjs +95 -0
- package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
- package/scripts/setup/generate-plan.mjs +108 -0
- package/scripts/setup/init-capability-manifest.mjs +70 -0
- package/scripts/setup/init-skill-marketplace.mjs +155 -0
- package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client.test.mjs — the hq half of the registry (§2.1, §4). The contract under
|
|
3
|
+
* test is FAIL-OPEN: nothing in this module may throw, because `apply()` throws
|
|
4
|
+
* are fatal to the whole wizard (lib/setup/runner.mjs:220-227).
|
|
5
|
+
* Run: node --test lib/subagents/client.test.mjs
|
|
6
|
+
*/
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdtempSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
TIMEOUT_MS,
|
|
17
|
+
createDefinition,
|
|
18
|
+
fetchResolve,
|
|
19
|
+
fetchRoster,
|
|
20
|
+
flushOutbox,
|
|
21
|
+
forkDefinition,
|
|
22
|
+
getDefinition,
|
|
23
|
+
getRead,
|
|
24
|
+
listDefinitions,
|
|
25
|
+
pinDefinition,
|
|
26
|
+
postRpc,
|
|
27
|
+
publishVersion,
|
|
28
|
+
resolveConfig,
|
|
29
|
+
unpinDefinition,
|
|
30
|
+
writeVerb,
|
|
31
|
+
yankDefinition,
|
|
32
|
+
} from "./client.mjs";
|
|
33
|
+
import { latestCached, listOutbox, queueOutbox } from "./lock.mjs";
|
|
34
|
+
import { hashBody, parseAgentMd } from "./schema.mjs";
|
|
35
|
+
|
|
36
|
+
const CFG = { enabled: true, base: "https://hq.example.com", token: "sk-test", orgId: "org_1", memberId: "mem_1" };
|
|
37
|
+
const root = () => mkdtempSync(join(tmpdir(), "subagent-client-"));
|
|
38
|
+
|
|
39
|
+
/** A recording fake fetch. `reply(url, init)` returns {ok,status,body} or throws. */
|
|
40
|
+
function fakeFetch(reply) {
|
|
41
|
+
const calls = [];
|
|
42
|
+
const fn = async (url, init) => {
|
|
43
|
+
calls.push({ url: String(url), method: init && init.method, headers: (init && init.headers) || {}, body: init && init.body ? JSON.parse(init.body) : null });
|
|
44
|
+
const r = typeof reply === "function" ? await reply(String(url), init, calls.length) : reply;
|
|
45
|
+
if (r instanceof Error) throw r;
|
|
46
|
+
return { ok: r.ok !== false, status: r.status || 200, json: async () => r.body };
|
|
47
|
+
};
|
|
48
|
+
fn.calls = calls;
|
|
49
|
+
return fn;
|
|
50
|
+
}
|
|
51
|
+
const okFrame = (result) => ({ ok: true, status: 200, body: { ok: true, result } });
|
|
52
|
+
const errFrame = (status, code, message) => ({ ok: false, status, body: { ok: false, error: { code, message } } });
|
|
53
|
+
/** A fetch that never resolves — exercises the AbortController timeout. */
|
|
54
|
+
const hangingFetch = () => async (_u, init) => new Promise((_res, rej) => {
|
|
55
|
+
if (init && init.signal) init.signal.addEventListener("abort", () => rej(Object.assign(new Error("aborted"), { name: "AbortError" })));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// config
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
test("resolveConfig: env supplies base/token/org; enabled needs BOTH base and token", () => {
|
|
63
|
+
const c = resolveConfig({ cfg: {}, env: { COHORT_BASE: "https://hq.example.com/", COHORT_API_KEY: "sk", COHORT_ORG_ID: "org_9", COHORT_MEMBER_ID: "mem_9" } });
|
|
64
|
+
assert.equal(c.base, "https://hq.example.com", "trailing slash trimmed");
|
|
65
|
+
assert.equal(c.token, "sk");
|
|
66
|
+
assert.equal(c.orgId, "org_9");
|
|
67
|
+
assert.equal(c.memberId, "mem_9");
|
|
68
|
+
assert.equal(c.enabled, true);
|
|
69
|
+
|
|
70
|
+
assert.equal(resolveConfig({ cfg: {}, env: {} }).enabled, false);
|
|
71
|
+
assert.equal(resolveConfig({ cfg: {}, env: { COHORT_BASE: "https://x" } }).enabled, false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("resolveConfig: not enrolled is a legitimate steady state, not an error", () => {
|
|
75
|
+
// §4 — layer 1 simply does not exist; the section reports status:"skipped".
|
|
76
|
+
const c = resolveConfig({ cfg: {}, env: {} });
|
|
77
|
+
assert.deepEqual(c, { enabled: false, base: "", token: "", orgId: "", memberId: "" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// transport
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
test("postRpc: POSTs /v1/<method> with bearer, protocol and tenant-pin headers", async () => {
|
|
85
|
+
const f = fakeFetch(okFrame({ hello: "world" }));
|
|
86
|
+
const r = await postRpc("subagent.list", { a: 1 }, { cfg: CFG, fetchImpl: f, idempotencyKey: "k1" });
|
|
87
|
+
assert.deepEqual(r, { ok: true, result: { hello: "world" } });
|
|
88
|
+
const call = f.calls[0];
|
|
89
|
+
assert.equal(call.url, "https://hq.example.com/v1/subagent.list");
|
|
90
|
+
assert.equal(call.method, "POST");
|
|
91
|
+
assert.equal(call.headers.authorization, "Bearer sk-test");
|
|
92
|
+
assert.equal(call.headers["x-org-protocol"], "1");
|
|
93
|
+
assert.equal(call.headers["x-org-id"], "org_1");
|
|
94
|
+
assert.equal(call.headers["x-idempotency-key"], "k1");
|
|
95
|
+
assert.deepEqual(call.body, { a: 1 });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("postRpc: a REJECTION (the server said no) is not degraded", async () => {
|
|
99
|
+
const r = await postRpc("subagent.pin", {}, { cfg: CFG, fetchImpl: fakeFetch(errFrame(403, "FORBIDDEN", "nope")) });
|
|
100
|
+
assert.equal(r.ok, false);
|
|
101
|
+
assert.equal(r.degraded, false, "the distinction that decides outbox-queue vs surface-the-error");
|
|
102
|
+
assert.equal(r.error.code, "FORBIDDEN");
|
|
103
|
+
assert.equal(r.status, 403);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("postRpc: a transport failure (no answer) IS degraded, and never throws", async () => {
|
|
107
|
+
const r = await postRpc("subagent.pin", {}, { cfg: CFG, fetchImpl: fakeFetch(new Error("ECONNREFUSED")) });
|
|
108
|
+
assert.equal(r.ok, false);
|
|
109
|
+
assert.equal(r.degraded, true);
|
|
110
|
+
assert.match(r.error.message, /ECONNREFUSED/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("postRpc: a hung server aborts on the timeout and reports it as degraded", async () => {
|
|
114
|
+
const r = await postRpc("subagent.list", {}, { cfg: CFG, fetchImpl: hangingFetch(), timeoutMs: 20 });
|
|
115
|
+
assert.equal(r.degraded, true);
|
|
116
|
+
assert.match(r.error.message, /timeout after 20ms/);
|
|
117
|
+
assert.equal(TIMEOUT_MS, 5000, "5 s — a human waiting on a dead server is the worse outcome");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("postRpc: unparseable / non-frame JSON is an error, not a crash", async () => {
|
|
121
|
+
const r = await postRpc("subagent.list", {}, { cfg: CFG, fetchImpl: fakeFetch({ ok: true, status: 200, body: "not-a-frame" }) });
|
|
122
|
+
assert.equal(r.ok, false);
|
|
123
|
+
assert.equal(r.error.code, "HTTP_200");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("postRpc / getRead: no base or token → UNAUTHORIZED + degraded, with NO request made", async () => {
|
|
127
|
+
const f = fakeFetch(okFrame({}));
|
|
128
|
+
const r = await postRpc("subagent.list", {}, { cfg: { base: "", token: "" }, fetchImpl: f });
|
|
129
|
+
assert.equal(r.degraded, true);
|
|
130
|
+
assert.equal(r.error.code, "UNAUTHORIZED");
|
|
131
|
+
assert.equal((await getRead("subagent.roster", { cfg: {}, fetchImpl: f })).error.code, "UNAUTHORIZED");
|
|
132
|
+
assert.equal(f.calls.length, 0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("getRead: reads return the BARE payload on 200 (not an envelope)", async () => {
|
|
136
|
+
const f = fakeFetch({ ok: true, status: 200, body: { rosterVersion: 4, entries: [] } });
|
|
137
|
+
const r = await getRead("subagent.roster", { cfg: CFG, fetchImpl: f });
|
|
138
|
+
assert.deepEqual(r.payload, { rosterVersion: 4, entries: [] });
|
|
139
|
+
assert.equal(f.calls[0].method, "GET");
|
|
140
|
+
assert.equal("content-type" in f.calls[0].headers, false, "no body, no content-type");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// reads
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
test("fetchRoster: the cheap poll — rosterVersion + bodiless entries", async () => {
|
|
148
|
+
const f = fakeFetch({ ok: true, status: 200, body: { rosterVersion: 11, entries: [{ slug: "alpha", definitionId: "d1", version: 3, contentHash: "h" }] } });
|
|
149
|
+
const r = await fetchRoster({ cfg: CFG, fetchImpl: f });
|
|
150
|
+
assert.equal(r.ok, true);
|
|
151
|
+
assert.equal(r.rosterVersion, 11);
|
|
152
|
+
assert.equal(r.entries[0].slug, "alpha");
|
|
153
|
+
assert.equal(f.calls[0].url, "https://hq.example.com/v1/subagent.roster");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("fetchRoster: a dead server returns an empty, degraded roster — never throws", async () => {
|
|
157
|
+
const r = await fetchRoster({ cfg: CFG, fetchImpl: fakeFetch(new Error("down")) });
|
|
158
|
+
assert.deepEqual([r.ok, r.degraded, r.rosterVersion, r.entries], [false, true, 0, []]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("fetchResolve: sends the selector params and returns bodies + advice", async () => {
|
|
162
|
+
const f = fakeFetch(okFrame({ rosterVersion: 5, entries: [], rewire: [{ ref: "x", suggest: "y" }], unmatched: ["z"] }));
|
|
163
|
+
const r = await fetchResolve({ cfg: CFG, memberId: "mem_1", function: "executive-operator", altitude: "c-suite", refs: ["ghost"], fetchImpl: f });
|
|
164
|
+
assert.deepEqual(f.calls[0].body, { memberId: "mem_1", function: "executive-operator", altitude: "c-suite", refs: ["ghost"] });
|
|
165
|
+
assert.equal(r.rosterVersion, 5);
|
|
166
|
+
assert.deepEqual(r.unmatched, ["z"]);
|
|
167
|
+
assert.equal(r.rewire[0].suggest, "y");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("fetchResolve: every returned body is cached so the NEXT run needs no network", async () => {
|
|
171
|
+
const agentRoot = root();
|
|
172
|
+
const f = fakeFetch(okFrame({
|
|
173
|
+
rosterVersion: 5,
|
|
174
|
+
entries: [{ slug: "alpha", version: 3, contentHash: "h", frontmatter: { name: "alpha", description: "d", model: "sonnet", tools: ["Read"] }, body: "# alpha\n\ncached body\n" }],
|
|
175
|
+
}));
|
|
176
|
+
await fetchResolve({ cfg: CFG, agentRoot, fetchImpl: f });
|
|
177
|
+
const cached = latestCached(agentRoot, "alpha");
|
|
178
|
+
assert.equal(cached.version, 3);
|
|
179
|
+
// Emitted through the same serializer the resolver parses with — lossless.
|
|
180
|
+
const parsed = parseAgentMd(cached.text);
|
|
181
|
+
assert.equal(parsed.frontmatter.name, "alpha");
|
|
182
|
+
assert.equal(parsed.body, "# alpha\n\ncached body\n");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("fetchResolve: a failure yields an empty degraded result (layer 1 just vanishes)", async () => {
|
|
186
|
+
const r = await fetchResolve({ cfg: CFG, fetchImpl: fakeFetch(new Error("down")) });
|
|
187
|
+
assert.deepEqual([r.ok, r.degraded, r.entries.length], [false, true, 0]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("listDefinitions / getDefinition: shaped results, fail-open on error", async () => {
|
|
191
|
+
const l = await listDefinitions({ cfg: CFG, fetchImpl: fakeFetch(okFrame({ items: [{ slug: "alpha" }] })) });
|
|
192
|
+
assert.deepEqual(l.items, [{ slug: "alpha" }]);
|
|
193
|
+
const bad = await listDefinitions({ cfg: CFG, fetchImpl: fakeFetch(new Error("x")) });
|
|
194
|
+
assert.deepEqual(bad.items, []);
|
|
195
|
+
const g = await getDefinition("alpha", { cfg: CFG, fetchImpl: fakeFetch(okFrame({ slug: "alpha" })) });
|
|
196
|
+
assert.deepEqual(g.definition, { slug: "alpha" });
|
|
197
|
+
assert.equal((await getDefinition("alpha", { cfg: CFG, fetchImpl: fakeFetch(errFrame(404, "NOT_FOUND", "x")) })).definition, null);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// writes + the outbox — the distinction that keeps work from being lost
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
test("writeVerb: a DEGRADED failure queues to the outbox for `maestro sync`", async () => {
|
|
205
|
+
const agentRoot = root();
|
|
206
|
+
const r = await writeVerb("subagent.create", { slug: "alpha" }, { cfg: CFG, agentRoot, fetchImpl: fakeFetch(new Error("offline")) });
|
|
207
|
+
assert.equal(r.degraded, true);
|
|
208
|
+
assert.ok(r.queued);
|
|
209
|
+
const queued = listOutbox(agentRoot);
|
|
210
|
+
assert.equal(queued.length, 1);
|
|
211
|
+
assert.deepEqual(queued[0].payload, { slug: "alpha" }, "queued verbatim — never re-derived at replay time");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("writeVerb: a REJECTION is NOT queued (it would be rejected forever, invisibly)", async () => {
|
|
215
|
+
const agentRoot = root();
|
|
216
|
+
const r = await writeVerb("subagent.pin", { slug: "alpha" }, { cfg: CFG, agentRoot, fetchImpl: fakeFetch(errFrame(403, "FORBIDDEN", "nope")) });
|
|
217
|
+
assert.equal(r.ok, false);
|
|
218
|
+
assert.equal(r.degraded, false);
|
|
219
|
+
assert.equal(r.queued, undefined);
|
|
220
|
+
assert.deepEqual(listOutbox(agentRoot), []);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("writeVerb: queue:false opts out (used where a caller wants the raw verdict)", async () => {
|
|
224
|
+
const agentRoot = root();
|
|
225
|
+
await writeVerb("subagent.pin", {}, { cfg: CFG, agentRoot, fetchImpl: fakeFetch(new Error("offline")), queue: false });
|
|
226
|
+
assert.deepEqual(listOutbox(agentRoot), []);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("createDefinition: stamps contentHash, strips provenance, sends an idempotency key", async () => {
|
|
230
|
+
const f = fakeFetch(okFrame({ definitionId: "d1", version: 1 }));
|
|
231
|
+
await createDefinition({
|
|
232
|
+
slug: "alpha", title: "Alpha", summary: "s", tags: ["x"], functions: ["f"], altitudes: ["a"],
|
|
233
|
+
frontmatter: { name: "alpha", layer: "sdk", source: "sdk://1/alpha", contentHash: "old" },
|
|
234
|
+
body: "# alpha\n",
|
|
235
|
+
}, { cfg: CFG, fetchImpl: f });
|
|
236
|
+
const sent = f.calls[0].body;
|
|
237
|
+
assert.equal(sent.contentHash, hashBody("# alpha\n"));
|
|
238
|
+
assert.deepEqual(Object.keys(sent.frontmatter), ["name"], "a definition is prose, not a record of one copy of it");
|
|
239
|
+
assert.equal(sent.baseLayer, "none");
|
|
240
|
+
assert.equal(f.calls[0].headers["x-idempotency-key"], "subagent.create:org_1:alpha");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("publishVersion: the idempotency key is (slug, contentHash) — a replay is a no-op", async () => {
|
|
244
|
+
const f = fakeFetch(okFrame({ version: 4 }));
|
|
245
|
+
await publishVersion({ slug: "alpha", definitionId: "d1", frontmatter: { name: "alpha" }, body: "b\n" }, { cfg: CFG, fetchImpl: f });
|
|
246
|
+
assert.equal(f.calls[0].headers["x-idempotency-key"], `subagent.publishVersion:alpha:${hashBody("b\n")}`);
|
|
247
|
+
assert.equal(f.calls[0].body.definitionId, "d1");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("forkDefinition: records baseLayer/baseSlug and a fork generator", async () => {
|
|
251
|
+
const f = fakeFetch(okFrame({ definitionId: "d2", version: 1 }));
|
|
252
|
+
await forkDefinition({ slug: "alpha-mine", baseSlug: "alpha", frontmatter: { name: "alpha-mine" }, body: "b\n" }, { cfg: CFG, fetchImpl: f });
|
|
253
|
+
const sent = f.calls[0].body;
|
|
254
|
+
assert.equal(sent.baseLayer, "sdk");
|
|
255
|
+
assert.equal(sent.baseSlug, "alpha");
|
|
256
|
+
assert.deepEqual(sent.generator, { source: "fork", forkedFrom: "alpha" });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("pin / unpin / yank: send only the identifiers they were given", async () => {
|
|
260
|
+
const f = fakeFetch(okFrame({}));
|
|
261
|
+
await pinDefinition({ slug: "alpha", definitionId: "d1", version: 3 }, { cfg: CFG, fetchImpl: f });
|
|
262
|
+
await unpinDefinition({ slug: "alpha" }, { cfg: CFG, fetchImpl: f });
|
|
263
|
+
await yankDefinition({ slug: "alpha", reason: "superseded" }, { cfg: CFG, fetchImpl: f });
|
|
264
|
+
assert.deepEqual(f.calls.map((c) => c.url.split("/v1/")[1]), ["subagent.pin", "subagent.unpin", "subagent.yank"]);
|
|
265
|
+
assert.equal(f.calls[0].body.version, 3);
|
|
266
|
+
assert.equal(f.calls[0].body.memberId, undefined, "an absent memberId means 'me' — hq fills it from the actor");
|
|
267
|
+
assert.equal(f.calls[2].body.reason, "superseded");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// replay
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
test("flushOutbox: replays oldest-first and removes what landed", async () => {
|
|
275
|
+
const agentRoot = root();
|
|
276
|
+
queueOutbox(agentRoot, "subagent.create", { slug: "alpha" });
|
|
277
|
+
queueOutbox(agentRoot, "subagent.pin", { slug: "alpha" });
|
|
278
|
+
const f = fakeFetch(okFrame({}));
|
|
279
|
+
const r = await flushOutbox({ agentRoot, cfg: CFG, fetchImpl: f });
|
|
280
|
+
assert.deepEqual([r.replayed, r.remaining, r.stopped], [2, 0, false]);
|
|
281
|
+
assert.deepEqual(listOutbox(agentRoot), []);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("flushOutbox: STOPS at the first degraded failure (the server is down again)", async () => {
|
|
285
|
+
const agentRoot = root();
|
|
286
|
+
queueOutbox(agentRoot, "subagent.create", { slug: "a" });
|
|
287
|
+
queueOutbox(agentRoot, "subagent.pin", { slug: "a" });
|
|
288
|
+
const f = fakeFetch((_u, _i, n) => (n === 1 ? okFrame({}) : new Error("down")));
|
|
289
|
+
const r = await flushOutbox({ agentRoot, cfg: CFG, fetchImpl: f });
|
|
290
|
+
assert.deepEqual([r.replayed, r.stopped, r.remaining], [1, true, 1]);
|
|
291
|
+
assert.equal(listOutbox(agentRoot).length, 1, "the undelivered write is still queued");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("flushOutbox: DROPS a rejected entry and reports it (or the outbox never empties)", async () => {
|
|
295
|
+
const agentRoot = root();
|
|
296
|
+
queueOutbox(agentRoot, "subagent.pin", { slug: "a" });
|
|
297
|
+
queueOutbox(agentRoot, "subagent.pin", { slug: "b" });
|
|
298
|
+
const f = fakeFetch((_u, _i, n) => (n === 1 ? errFrame(409, "CONFLICT", "already there") : okFrame({})));
|
|
299
|
+
const r = await flushOutbox({ agentRoot, cfg: CFG, fetchImpl: f });
|
|
300
|
+
assert.equal(r.replayed, 1);
|
|
301
|
+
assert.equal(r.dropped.length, 1);
|
|
302
|
+
assert.equal(r.dropped[0].error.code, "CONFLICT");
|
|
303
|
+
assert.deepEqual(listOutbox(agentRoot), []);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("flushOutbox: an empty outbox is a clean no-op", async () => {
|
|
307
|
+
const r = await flushOutbox({ agentRoot: root(), cfg: CFG, fetchImpl: fakeFetch(okFrame({})) });
|
|
308
|
+
assert.deepEqual(r, { replayed: 0, dropped: [], remaining: 0, stopped: false });
|
|
309
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/subagents/gap.mjs — the three-way diff between the roster, the resolved
|
|
3
|
+
* layers, and what the repo actually references (§2.3).
|
|
4
|
+
*
|
|
5
|
+
* Three questions, three sets:
|
|
6
|
+
*
|
|
7
|
+
* missing the capability pack says this agent should exist, and no layer
|
|
8
|
+
* resolves it. → the registry's actual job: adopt/fork/create.
|
|
9
|
+
*
|
|
10
|
+
* unrostered a workflow / team / routing entry references a slug the roster
|
|
11
|
+
* does not contain. Today this is the ONLY check `doctor` does, and
|
|
12
|
+
* it reports it as a bare "missing agent", which is almost always
|
|
13
|
+
* the wrong framing: the reference is usually vestigial, copied in
|
|
14
|
+
* by `create` from maestro's own `workflows/**` (which are the CEO
|
|
15
|
+
* agent's workflows, not this agent's). 15 such refs exist in every
|
|
16
|
+
* freshly-created agent.
|
|
17
|
+
*
|
|
18
|
+
* idle a roster agent that NOTHING references. This check does not exist
|
|
19
|
+
* anywhere today, and it is the one that tells you the capability
|
|
20
|
+
* pack generated prose nobody dispatches to.
|
|
21
|
+
*
|
|
22
|
+
* WHY UNROSTERED IS BIASED TO "DROP". The rewire classifier matches a dangling
|
|
23
|
+
* ref against roster agents using the charter pillars, `orgProfile.skills`, and
|
|
24
|
+
* governance themes. In real configs those are frequently EMPTY — `title:""` and
|
|
25
|
+
* `company:"UNCONFIGURED"` are common in the wild — so the semantic signal is
|
|
26
|
+
* often nil. A classifier with no signal that still confidently proposes a rewire
|
|
27
|
+
* would silently re-point a workflow step at the wrong agent. So: rewire is
|
|
28
|
+
* proposed only on real lexical evidence, and the default verdict is
|
|
29
|
+
* `drop-or-create` with `drop` recommended, because the correct fix for a
|
|
30
|
+
* vestigial step is almost always deleting it.
|
|
31
|
+
*
|
|
32
|
+
* And the section NEVER performs the drop: it records the accepted drop in the
|
|
33
|
+
* lock and prints file:line. Rewriting a workflow changes runtime behaviour; a
|
|
34
|
+
* human owns that diff.
|
|
35
|
+
*
|
|
36
|
+
* Pure — no I/O, no network. Everything is injected. ESM.
|
|
37
|
+
*
|
|
38
|
+
* @module lib/subagents/gap
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
"use strict";
|
|
42
|
+
|
|
43
|
+
import { groupRefs } from "./refs.mjs";
|
|
44
|
+
import { isAcceptedDrop } from "./lock.mjs";
|
|
45
|
+
|
|
46
|
+
/** Words too common to carry any signal in a slug/skill match. */
|
|
47
|
+
const STOPWORDS = new Set([
|
|
48
|
+
"agent", "agents", "the", "and", "for", "with", "ops", "op", "team", "lead",
|
|
49
|
+
"sub", "of", "to", "a", "an", "manager", "operator", "assistant", "specialist",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Split an identifier or a phrase into comparable lowercase tokens.
|
|
54
|
+
* Handles `kebab-case`, `snake_case`, `camelCase` and free text alike, because
|
|
55
|
+
* the three signal sources are all different shapes: slugs are kebab, skills are
|
|
56
|
+
* free text, governance themes are sentences.
|
|
57
|
+
* @param {string} s
|
|
58
|
+
* @returns {string[]}
|
|
59
|
+
*/
|
|
60
|
+
export function tokenize(s) {
|
|
61
|
+
return String(s || "")
|
|
62
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.split(/[^a-z0-9]+/)
|
|
65
|
+
.filter((t) => t.length > 2 && !STOPWORDS.has(t));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build the semantic corpus for one roster agent: its slug, role, purpose/mandate
|
|
70
|
+
* and team, plus any charter pillars / skills / governance themes that mention
|
|
71
|
+
* it. The corpus is what a dangling reference is matched against.
|
|
72
|
+
* @param {object} agent @param {object} [context]
|
|
73
|
+
* @returns {Set<string>}
|
|
74
|
+
*/
|
|
75
|
+
export function agentCorpus(agent, context = {}) {
|
|
76
|
+
const parts = [
|
|
77
|
+
agent && agent.id,
|
|
78
|
+
agent && agent.role,
|
|
79
|
+
agent && agent.purpose,
|
|
80
|
+
agent && agent.mandate,
|
|
81
|
+
agent && agent.team,
|
|
82
|
+
...(Array.isArray(agent && agent.towers) ? agent.towers : []),
|
|
83
|
+
];
|
|
84
|
+
const tokens = new Set(parts.flatMap((p) => tokenize(p)));
|
|
85
|
+
// Contextual reinforcement: an org skill or charter pillar that shares tokens
|
|
86
|
+
// with this agent strengthens its corpus, which is how "the org actually does
|
|
87
|
+
// compliance" tips a `regulatory-watch` ref toward the compliance agent.
|
|
88
|
+
for (const extra of [...(context.skills || []), ...(context.pillars || []), ...(context.governance || [])]) {
|
|
89
|
+
const et = tokenize(extra);
|
|
90
|
+
if (et.some((t) => tokens.has(t))) for (const t of et) tokens.add(t);
|
|
91
|
+
}
|
|
92
|
+
return tokens;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Score a dangling reference against a roster agent: the fraction of the
|
|
97
|
+
* REFERENCE's tokens the agent's corpus covers. Reference-normalised (not
|
|
98
|
+
* Jaccard) on purpose — a rich agent corpus should not be penalised for being
|
|
99
|
+
* rich, but a two-token ref must match both tokens to score 1.
|
|
100
|
+
* @param {string} ref @param {Set<string>} corpus
|
|
101
|
+
* @returns {number} 0..1
|
|
102
|
+
*/
|
|
103
|
+
export function scoreRef(ref, corpus) {
|
|
104
|
+
const rt = tokenize(ref);
|
|
105
|
+
if (!rt.length) return 0;
|
|
106
|
+
let hit = 0;
|
|
107
|
+
for (const t of rt) if (corpus.has(t)) hit++;
|
|
108
|
+
return hit / rt.length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The evidence bar a rewire suggestion must clear. Set high (2/3 of the
|
|
113
|
+
* reference's tokens) precisely because the classifier runs against frequently
|
|
114
|
+
* empty configs — see the module header. Below the bar we do not guess.
|
|
115
|
+
*/
|
|
116
|
+
export const REWIRE_THRESHOLD = 0.67;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Classify one unrostered reference.
|
|
120
|
+
* @param {string} ref
|
|
121
|
+
* @param {{roster:Array<object>, context?:object}} o
|
|
122
|
+
* @returns {{ref:string, action:"rewire"|"drop-or-create", suggest:string|null, score:number, reason:string, recommend:"rewire"|"drop"}}
|
|
123
|
+
*/
|
|
124
|
+
export function classifyRef(ref, o = {}) {
|
|
125
|
+
const roster = Array.isArray(o.roster) ? o.roster : [];
|
|
126
|
+
const context = o.context || {};
|
|
127
|
+
let best = { id: null, score: 0 };
|
|
128
|
+
for (const a of roster) {
|
|
129
|
+
const s = scoreRef(ref, agentCorpus(a, context));
|
|
130
|
+
if (s > best.score) best = { id: a.id, score: s };
|
|
131
|
+
}
|
|
132
|
+
if (best.id && best.score >= REWIRE_THRESHOLD) {
|
|
133
|
+
return {
|
|
134
|
+
ref,
|
|
135
|
+
action: "rewire",
|
|
136
|
+
suggest: best.id,
|
|
137
|
+
score: Number(best.score.toFixed(3)),
|
|
138
|
+
recommend: "rewire",
|
|
139
|
+
reason: `"${ref}" overlaps ${Math.round(best.score * 100)}% with the roster agent "${best.id}"`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
ref,
|
|
144
|
+
action: "drop-or-create",
|
|
145
|
+
suggest: best.id,
|
|
146
|
+
score: Number(best.score.toFixed(3)),
|
|
147
|
+
recommend: "drop",
|
|
148
|
+
reason: best.id
|
|
149
|
+
? `no roster agent covers "${ref}" (closest: "${best.id}" at ${Math.round(best.score * 100)}%) — most likely a vestigial step copied by \`create\``
|
|
150
|
+
: `no roster agent resembles "${ref}" — most likely a vestigial step copied by \`create\``,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Normalise a capability surface into the flat roster the diff works over.
|
|
156
|
+
* `standardAgents ∪ roleAgents` per §2.3.
|
|
157
|
+
* @param {object} surface the resolveCapabilitySurface result
|
|
158
|
+
* @returns {Array<{id:string, role:string, purpose:string, team?:string, tier?:string, source:string}>}
|
|
159
|
+
*/
|
|
160
|
+
export function rosterFromSurface(surface) {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const a of (surface && surface.standardAgents) || []) {
|
|
163
|
+
if (a && a.id) out.push({ ...a, source: "standard" });
|
|
164
|
+
}
|
|
165
|
+
for (const a of (surface && surface.roleAgents) || []) {
|
|
166
|
+
if (a && a.id) out.push({ ...a, source: "role" });
|
|
167
|
+
}
|
|
168
|
+
// Dedupe by id, first wins (standard defaults are authoritative for their ids).
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
return out.filter((a) => (seen.has(a.id) ? false : (seen.add(a.id), true)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The three-way diff.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} o
|
|
177
|
+
* @param {Array<object>} o.roster the authoritative roster (rosterFromSurface)
|
|
178
|
+
* @param {object} o.resolved the resolveSubagents result
|
|
179
|
+
* @param {Array<object>} o.refs scanAgentRefs output
|
|
180
|
+
* @param {object} [o.lock] for acceptedDrops
|
|
181
|
+
* @param {object} [o.context] {skills, pillars, governance} semantic signal
|
|
182
|
+
* @returns {{missing:Array, unrostered:Array, idle:Array, localEdits:string[], status:"complete"|"partial"|"unconfigured", summary:string}}
|
|
183
|
+
*/
|
|
184
|
+
export function computeGaps(o = {}) {
|
|
185
|
+
const roster = Array.isArray(o.roster) ? o.roster : [];
|
|
186
|
+
const rosterIds = new Set(roster.map((a) => a.id));
|
|
187
|
+
const resolved = o.resolved || { bySlug: new Map(), entries: [] };
|
|
188
|
+
const bySlug = resolved.bySlug instanceof Map ? resolved.bySlug : new Map((resolved.entries || []).map((e) => [e.slug, e]));
|
|
189
|
+
const refs = Array.isArray(o.refs) ? o.refs : [];
|
|
190
|
+
const lock = o.lock || { acceptedDrops: [] };
|
|
191
|
+
const byRef = groupRefs(refs);
|
|
192
|
+
|
|
193
|
+
// 1. missing = roster ∖ resolved-layers
|
|
194
|
+
const missing = roster
|
|
195
|
+
.filter((a) => !bySlug.has(a.id))
|
|
196
|
+
.map((a) => ({
|
|
197
|
+
slug: a.id,
|
|
198
|
+
role: a.role || "",
|
|
199
|
+
purpose: a.purpose || a.mandate || "",
|
|
200
|
+
team: a.team || "",
|
|
201
|
+
source: a.source,
|
|
202
|
+
// What a human can do about it, in the order the prompt offers them.
|
|
203
|
+
actions: ["adopt-sdk", "fork", "create", "defer"],
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
// 2. unrostered = parsed refs ∖ roster
|
|
207
|
+
const unrostered = [];
|
|
208
|
+
for (const [ref, locations] of byRef) {
|
|
209
|
+
if (rosterIds.has(ref)) continue;
|
|
210
|
+
const cls = classifyRef(ref, { roster, context: o.context });
|
|
211
|
+
unrostered.push({
|
|
212
|
+
...cls,
|
|
213
|
+
locations,
|
|
214
|
+
resolves: bySlug.has(ref), // referenced, off-roster, but a file DOES exist
|
|
215
|
+
accepted: isAcceptedDrop(lock, ref),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
unrostered.sort((a, b) => b.score - a.score || a.ref.localeCompare(b.ref));
|
|
219
|
+
|
|
220
|
+
// 3. idle = roster agents nothing references
|
|
221
|
+
const idle = roster
|
|
222
|
+
.filter((a) => !byRef.has(a.id))
|
|
223
|
+
.map((a) => ({ slug: a.id, role: a.role || "", team: a.team || "", source: a.source, resolves: bySlug.has(a.id) }));
|
|
224
|
+
|
|
225
|
+
// `complete` demands no missing roster agents AND every drop-or-create verdict
|
|
226
|
+
// already accepted by a human. A rewire suggestion alone does NOT block
|
|
227
|
+
// completion — it is advisory (§5.6) and the operator may legitimately ignore it.
|
|
228
|
+
const openDrops = unrostered.filter((u) => u.action === "drop-or-create" && !u.accepted && !u.resolves);
|
|
229
|
+
const status = missing.length === 0 && openDrops.length === 0 ? "complete" : (bySlug.size ? "partial" : "unconfigured");
|
|
230
|
+
|
|
231
|
+
const summary = status === "complete"
|
|
232
|
+
? `${bySlug.size} sub-agent${bySlug.size === 1 ? "" : "s"} resolved, roster complete${idle.length ? `, ${idle.length} idle` : ""}`
|
|
233
|
+
: `${missing.length} missing, ${openDrops.length} unresolved reference${openDrops.length === 1 ? "" : "s"}, ${idle.length} idle`;
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
missing,
|
|
237
|
+
unrostered,
|
|
238
|
+
openDrops,
|
|
239
|
+
idle,
|
|
240
|
+
localEdits: Array.isArray(resolved.localEdits) ? resolved.localEdits : [],
|
|
241
|
+
emptyDirs: Array.isArray(resolved.emptyDirs) ? resolved.emptyDirs : [],
|
|
242
|
+
counts: { roster: roster.length, resolved: bySlug.size, missing: missing.length, unrostered: unrostered.length, idle: idle.length },
|
|
243
|
+
status,
|
|
244
|
+
summary,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Distil the semantic signal the classifier uses from the agent's own config.
|
|
250
|
+
* Tolerant of every field being absent — which, per the module header, is the
|
|
251
|
+
* common case and is exactly why the classifier is biased to drop.
|
|
252
|
+
* @param {{agentConfig?:object, charter?:object}} o
|
|
253
|
+
* @returns {{skills:string[], pillars:string[], governance:string[]}}
|
|
254
|
+
*/
|
|
255
|
+
export function contextFromConfig(o = {}) {
|
|
256
|
+
const cfg = o.agentConfig || {};
|
|
257
|
+
const org = cfg.orgProfile || {};
|
|
258
|
+
const charter = o.charter || cfg.charter || {};
|
|
259
|
+
const skills = Array.isArray(org.skills) ? org.skills.map(String) : [];
|
|
260
|
+
const pillars = [
|
|
261
|
+
...(Array.isArray(cfg.responsibilities) ? cfg.responsibilities.map(String) : []),
|
|
262
|
+
...(Array.isArray(charter.sections) ? charter.sections.map((s) => `${(s && s.title) || ""} ${(s && s.body) || ""}`) : []),
|
|
263
|
+
];
|
|
264
|
+
const governance = Array.isArray(org.governance)
|
|
265
|
+
? org.governance.map((g) => (g && typeof g === "object" ? `${g.theme || ""} ${g.title || ""} ${g.description || ""}` : String(g)))
|
|
266
|
+
: [];
|
|
267
|
+
return { skills, pillars, governance };
|
|
268
|
+
}
|