@cohortapp/agent-sdk 2.3.2 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +58 -22
- package/lib/org/client.test.mjs +3 -1
- 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 +17 -2
- package/lib/org/messaging.mjs +40 -4
- package/lib/org/messaging.test.mjs +40 -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 +105 -17
- package/lib/setup/enroll-from-cohort.test.mjs +68 -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 +8 -3
- 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 +36 -4
- package/scripts/daemon/cadence-handlers.mjs +145 -1
- 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/prompt-builder.mjs +41 -1
- 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/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,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/subagents/refs.mjs — THE single sub-agent reference parser (§2.1).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Two regexes for the same job shipped in this repo and they
|
|
5
|
+
* disagreed:
|
|
6
|
+
*
|
|
7
|
+
* bin/maestro.mjs:2261 /^\s+agent:\s*([\w-]+)\b/gm
|
|
8
|
+
* scripts/ci/check-docs-accuracy /^\s*-?\s*agent:\s*([A-Za-z0-9][A-Za-z0-9_-]*)\s*(#.*)?$/gm
|
|
9
|
+
*
|
|
10
|
+
* The first REQUIRES leading whitespace (so a top-level `agent: foo` is invisible
|
|
11
|
+
* to `doctor`), uses `\b` (so `agent: foo bar` matches `foo` and hides the
|
|
12
|
+
* malformed value), and matches inside a trailing comment. The second is stricter
|
|
13
|
+
* but only ever ran in CI over `workflows/`. The result: `doctor` and CI reported
|
|
14
|
+
* DIFFERENT dangling-reference sets for the same repo, and neither looked at
|
|
15
|
+
* `teams/*.yaml` `members:` or `config/event-routing.yaml` handlers at all — the
|
|
16
|
+
* two places that actually decide whether a sub-agent is idle.
|
|
17
|
+
*
|
|
18
|
+
* One parser, one grammar, four keys (`agent`/`delegate`/`subagent`/`handler`).
|
|
19
|
+
* Both callers are rewired to this module; the gap detector uses the same output.
|
|
20
|
+
*
|
|
21
|
+
* WHY event-routing gets its own walker. `config/event-routing.yaml` routes carry
|
|
22
|
+
* `{event, handler, via}` where `via` is one of `standard-agent`/`workflow`/`skill`.
|
|
23
|
+
* A bare `handler:` regex therefore reports every WORKFLOW id as a dangling agent
|
|
24
|
+
* — which is precisely the false-positive class that made the existing doctor
|
|
25
|
+
* warning unactionable. We read `via` and only treat agent-backed handlers as
|
|
26
|
+
* agent references; the rest are returned tagged so a caller can still see them.
|
|
27
|
+
*
|
|
28
|
+
* Node builtins only. Pure over strings; the directory walk is fail-open I/O.
|
|
29
|
+
* ESM.
|
|
30
|
+
*
|
|
31
|
+
* @module lib/subagents/refs
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
"use strict";
|
|
35
|
+
|
|
36
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
37
|
+
import { join, relative } from "node:path";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The reference grammar, as a source string so both the CLI and CI can quote it
|
|
41
|
+
* in an error message rather than re-deriving it.
|
|
42
|
+
*
|
|
43
|
+
* `^\s*-?\s*` — a mapping key at ANY indent, optionally the first key of a list
|
|
44
|
+
* item (`- agent: foo`). `[A-Za-z0-9][\w-]*` — a slug: starts alphanumeric, then
|
|
45
|
+
* word chars or hyphens. `\s*(#.*)?$` — anchored to end-of-line so `agent: foo bar`
|
|
46
|
+
* does NOT match (a malformed value must be invisible, not silently truncated to
|
|
47
|
+
* `foo`, which is what `\b` did).
|
|
48
|
+
* @type {string}
|
|
49
|
+
*/
|
|
50
|
+
export const AGENT_REF_PATTERN = "^\\s*-?\\s*(agent|delegate|subagent|handler):\\s*([A-Za-z0-9][\\w-]*)\\s*(#.*)?$";
|
|
51
|
+
|
|
52
|
+
/** Build a FRESH global regex (a shared /g regex carries lastIndex between calls). */
|
|
53
|
+
export function agentRefRegex() {
|
|
54
|
+
return new RegExp(AGENT_REF_PATTERN, "gm");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sentinels that mean "the executing agent itself", not a roster slug. Excluding
|
|
59
|
+
* them is not cosmetic: `agent-self` appears in most generated workflows and
|
|
60
|
+
* would otherwise be reported as the single most-dangling reference in the fleet.
|
|
61
|
+
* @type {ReadonlySet<string>}
|
|
62
|
+
*/
|
|
63
|
+
export const SELF_SENTINELS = Object.freeze(new Set(["agent-self", "agent_self", "self"]));
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `via:` values in event-routing that denote a SUB-AGENT handler. Anything else
|
|
67
|
+
* (`workflow`, `skill`) names a different artifact and must not be reported as a
|
|
68
|
+
* dangling agent.
|
|
69
|
+
* @type {ReadonlySet<string>}
|
|
70
|
+
*/
|
|
71
|
+
export const AGENT_VIA = Object.freeze(new Set(["standard-agent", "role-agent", "agent", "sub-agent", "subagent"]));
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse every sub-agent reference out of a text blob.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} text
|
|
77
|
+
* @param {{kind?:string, file?:string}} [opts] `kind` labels the source
|
|
78
|
+
* ("workflow"|"team"|"event-routing"|…) and is carried through to the caller.
|
|
79
|
+
* @returns {Array<{ref:string, key:string, line:number, kind:string, file?:string}>}
|
|
80
|
+
*/
|
|
81
|
+
export function parseAgentRefs(text, opts = {}) {
|
|
82
|
+
if (typeof text !== "string" || !text) return [];
|
|
83
|
+
const kind = opts.kind || "unknown";
|
|
84
|
+
const re = agentRefRegex();
|
|
85
|
+
const out = [];
|
|
86
|
+
let m;
|
|
87
|
+
while ((m = re.exec(text)) !== null) {
|
|
88
|
+
const ref = m[2];
|
|
89
|
+
if (SELF_SENTINELS.has(ref)) continue;
|
|
90
|
+
out.push({ ref, key: m[1], line: lineOf(text, m.index), kind, ...(opts.file ? { file: opts.file } : {}) });
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 1-indexed line number of a character offset. */
|
|
96
|
+
function lineOf(text, index) {
|
|
97
|
+
let line = 1;
|
|
98
|
+
for (let i = 0; i < index && i < text.length; i++) if (text[i] === "\n") line++;
|
|
99
|
+
return line;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse a `teams/*.yaml` `members:` block into agent references.
|
|
104
|
+
*
|
|
105
|
+
* The team files use a bare block sequence under `members:` (`- desktop-operator
|
|
106
|
+
* # comment`) — there is no `agent:` key, so {@link parseAgentRefs} cannot see
|
|
107
|
+
* them, which is why team membership was never checked. We walk the block
|
|
108
|
+
* explicitly and stop at the first line that is not a deeper-indented list item,
|
|
109
|
+
* so a sibling key (`responsibilities:`) ends it.
|
|
110
|
+
*
|
|
111
|
+
* `lead:` is also a reference (a team's lead is a sub-agent slug).
|
|
112
|
+
*
|
|
113
|
+
* @param {string} yamlText
|
|
114
|
+
* @param {{file?:string}} [opts]
|
|
115
|
+
* @returns {Array<{ref:string, key:string, line:number, kind:string, file?:string}>}
|
|
116
|
+
*/
|
|
117
|
+
export function parseTeamRefs(yamlText, opts = {}) {
|
|
118
|
+
if (typeof yamlText !== "string" || !yamlText) return [];
|
|
119
|
+
const out = [];
|
|
120
|
+
const lines = yamlText.split("\n");
|
|
121
|
+
let membersIndent = -1;
|
|
122
|
+
for (let i = 0; i < lines.length; i++) {
|
|
123
|
+
const raw = lines[i];
|
|
124
|
+
if (!raw.trim() || /^\s*#/.test(raw)) continue;
|
|
125
|
+
|
|
126
|
+
const lead = raw.match(/^(\s*)lead:\s*([A-Za-z0-9][\w-]*)\s*(#.*)?$/);
|
|
127
|
+
if (lead && !SELF_SENTINELS.has(lead[2])) {
|
|
128
|
+
out.push({ ref: lead[2], key: "lead", line: i + 1, kind: "team", ...(opts.file ? { file: opts.file } : {}) });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const open = raw.match(/^(\s*)members:\s*(#.*)?$/);
|
|
133
|
+
if (open) { membersIndent = open[1].length; continue; }
|
|
134
|
+
|
|
135
|
+
if (membersIndent >= 0) {
|
|
136
|
+
const item = raw.match(/^(\s*)-\s*([A-Za-z0-9][\w-]*)\s*(#.*)?$/);
|
|
137
|
+
if (item && item[1].length > membersIndent) {
|
|
138
|
+
if (!SELF_SENTINELS.has(item[2])) {
|
|
139
|
+
out.push({ ref: item[2], key: "members", line: i + 1, kind: "team", ...(opts.file ? { file: opts.file } : {}) });
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// Any non-deeper line ends the members block.
|
|
144
|
+
membersIndent = -1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse `config/event-routing.yaml` routes into agent references, honouring
|
|
152
|
+
* `via:` so workflow/skill handlers are NOT reported as agents.
|
|
153
|
+
*
|
|
154
|
+
* The generated file is a flat list of `- event: … / handler: … / via: …` maps
|
|
155
|
+
* (see lib/capability.buildEventRouting), so a small line walker is both
|
|
156
|
+
* sufficient and immune to the ordering of the three keys within a route.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} yamlText
|
|
159
|
+
* @param {{file?:string}} [opts]
|
|
160
|
+
* @returns {Array<{ref:string, key:string, line:number, kind:string, via:string, isAgent:boolean, file?:string}>}
|
|
161
|
+
*/
|
|
162
|
+
export function parseEventRoutingRefs(yamlText, opts = {}) {
|
|
163
|
+
if (typeof yamlText !== "string" || !yamlText) return [];
|
|
164
|
+
const lines = yamlText.split("\n");
|
|
165
|
+
const routes = [];
|
|
166
|
+
let cur = null;
|
|
167
|
+
const flush = () => { if (cur && cur.handler) routes.push(cur); cur = null; };
|
|
168
|
+
|
|
169
|
+
for (let i = 0; i < lines.length; i++) {
|
|
170
|
+
const raw = lines[i];
|
|
171
|
+
if (!raw.trim() || /^\s*#/.test(raw)) continue;
|
|
172
|
+
// A list-item dash starts a new route regardless of which key follows it.
|
|
173
|
+
if (/^\s*-\s/.test(raw)) {
|
|
174
|
+
flush();
|
|
175
|
+
cur = { handler: "", via: "", line: i + 1 };
|
|
176
|
+
}
|
|
177
|
+
const h = raw.match(/^\s*-?\s*handler:\s*([A-Za-z0-9][\w-]*)\s*(#.*)?$/);
|
|
178
|
+
if (h) { if (!cur) cur = { handler: "", via: "", line: i + 1 }; cur.handler = h[1]; cur.line = i + 1; continue; }
|
|
179
|
+
const v = raw.match(/^\s*-?\s*via:\s*([A-Za-z0-9][\w-]*)\s*(#.*)?$/);
|
|
180
|
+
if (v) { if (!cur) cur = { handler: "", via: "", line: i + 1 }; cur.via = v[1]; continue; }
|
|
181
|
+
}
|
|
182
|
+
flush();
|
|
183
|
+
|
|
184
|
+
const file = opts.file;
|
|
185
|
+
return routes
|
|
186
|
+
.filter((r) => !SELF_SENTINELS.has(r.handler))
|
|
187
|
+
.map((r) => ({
|
|
188
|
+
ref: r.handler,
|
|
189
|
+
key: "handler",
|
|
190
|
+
line: r.line,
|
|
191
|
+
kind: "event-routing",
|
|
192
|
+
via: r.via || "",
|
|
193
|
+
// An absent `via` is treated as an agent handler: the four seed routes
|
|
194
|
+
// buildEventRouting emits always carry `via: standard-agent`, so a route
|
|
195
|
+
// WITHOUT one was hand-added and an agent is the likeliest intent.
|
|
196
|
+
isAgent: !r.via || AGENT_VIA.has(r.via),
|
|
197
|
+
...(file ? { file } : {}),
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Recursively list files under `dir` whose name matches `test`. Fail-open. */
|
|
202
|
+
function walk(dir, test, out = []) {
|
|
203
|
+
if (!existsSync(dir)) return out;
|
|
204
|
+
let names;
|
|
205
|
+
try { names = readdirSync(dir); } catch { return out; }
|
|
206
|
+
for (const name of names) {
|
|
207
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
208
|
+
const full = join(dir, name);
|
|
209
|
+
let st;
|
|
210
|
+
try { st = statSync(full); } catch { continue; }
|
|
211
|
+
if (st.isDirectory()) walk(full, test, out);
|
|
212
|
+
else if (st.isFile() && test(name)) out.push(full);
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const isYaml = (n) => /\.ya?ml$/.test(n);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Scan an agent repo for every sub-agent reference: `workflows/**` (+ any extra
|
|
221
|
+
* dirs), `teams/*.yaml`, and `config/event-routing.yaml`.
|
|
222
|
+
*
|
|
223
|
+
* This is the input to the gap detector's "unrostered" and "idle" sets, so it
|
|
224
|
+
* must cover every place a reference can hide — the whole reason the previous
|
|
225
|
+
* workflow-only scan under-reported.
|
|
226
|
+
*
|
|
227
|
+
* Fail-open: an unreadable file is skipped, never fatal.
|
|
228
|
+
*
|
|
229
|
+
* @param {{agentRoot:string, dirs?:string[], includeNonAgentRouting?:boolean}} o
|
|
230
|
+
* @returns {Array<{ref:string, key:string, line:number, kind:string, file:string, via?:string}>}
|
|
231
|
+
*/
|
|
232
|
+
export function scanAgentRefs(o = {}) {
|
|
233
|
+
const agentRoot = o.agentRoot;
|
|
234
|
+
if (!agentRoot) return [];
|
|
235
|
+
const out = [];
|
|
236
|
+
const rel = (p) => relative(agentRoot, p) || p;
|
|
237
|
+
const readSafe = (p) => { try { return readFileSync(p, "utf8"); } catch { return ""; } };
|
|
238
|
+
|
|
239
|
+
for (const d of o.dirs || ["workflows"]) {
|
|
240
|
+
for (const f of walk(join(agentRoot, d), isYaml)) {
|
|
241
|
+
out.push(...parseAgentRefs(readSafe(f), { kind: "workflow", file: rel(f) }));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const f of walk(join(agentRoot, "teams"), isYaml)) {
|
|
246
|
+
out.push(...parseTeamRefs(readSafe(f), { file: rel(f) }));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const routing = join(agentRoot, "config", "event-routing.yaml");
|
|
250
|
+
if (existsSync(routing)) {
|
|
251
|
+
for (const r of parseEventRoutingRefs(readSafe(routing), { file: rel(routing) })) {
|
|
252
|
+
if (r.isAgent || o.includeNonAgentRouting) out.push(r);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Collapse a reference list into `slug → [{file,line,kind}]`. The gap report
|
|
261
|
+
* prints file:line for every accepted drop (§2.3: the section NEVER edits a
|
|
262
|
+
* workflow, it tells the operator exactly what to delete).
|
|
263
|
+
* @param {Array<object>} refs
|
|
264
|
+
* @returns {Map<string, Array<{file:string, line:number, kind:string}>>}
|
|
265
|
+
*/
|
|
266
|
+
export function groupRefs(refs) {
|
|
267
|
+
const m = new Map();
|
|
268
|
+
for (const r of refs || []) {
|
|
269
|
+
if (!r || !r.ref) continue;
|
|
270
|
+
if (!m.has(r.ref)) m.set(r.ref, []);
|
|
271
|
+
m.get(r.ref).push({ file: r.file || "", line: r.line || 0, kind: r.kind || "unknown" });
|
|
272
|
+
}
|
|
273
|
+
return m;
|
|
274
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* refs.test.mjs — THE single sub-agent reference parser (§2.1).
|
|
3
|
+
* Run: node --test lib/subagents/refs.test.mjs
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
AGENT_REF_PATTERN,
|
|
15
|
+
AGENT_VIA,
|
|
16
|
+
SELF_SENTINELS,
|
|
17
|
+
agentRefRegex,
|
|
18
|
+
groupRefs,
|
|
19
|
+
parseAgentRefs,
|
|
20
|
+
parseEventRoutingRefs,
|
|
21
|
+
parseTeamRefs,
|
|
22
|
+
scanAgentRefs,
|
|
23
|
+
} from "./refs.mjs";
|
|
24
|
+
|
|
25
|
+
/** The two regexes this module replaces, verbatim, so the fixes are provable. */
|
|
26
|
+
const OLD_DOCTOR = () => /^\s+agent:\s*([\w-]+)\b/gm;
|
|
27
|
+
|
|
28
|
+
function repo() {
|
|
29
|
+
const root = mkdtempSync(join(tmpdir(), "subagent-refs-"));
|
|
30
|
+
mkdirSync(join(root, "workflows"), { recursive: true });
|
|
31
|
+
mkdirSync(join(root, "teams"), { recursive: true });
|
|
32
|
+
mkdirSync(join(root, "config"), { recursive: true });
|
|
33
|
+
return root;
|
|
34
|
+
}
|
|
35
|
+
const refsOf = (list) => list.map((r) => r.ref);
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// parseAgentRefs — the grammar and the two bugs it fixes
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
test("parseAgentRefs: matches all four keys at any indent, including a list item", () => {
|
|
42
|
+
const text = [
|
|
43
|
+
"agent: top-level",
|
|
44
|
+
" agent: indented",
|
|
45
|
+
" - agent: in-a-list",
|
|
46
|
+
" delegate: delegated",
|
|
47
|
+
" subagent: subbed",
|
|
48
|
+
" handler: handled",
|
|
49
|
+
].join("\n");
|
|
50
|
+
assert.deepEqual(refsOf(parseAgentRefs(text, { kind: "workflow" })),
|
|
51
|
+
["top-level", "indented", "in-a-list", "delegated", "subbed", "handled"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("parseAgentRefs: a TOP-LEVEL `agent:` is visible (the old doctor regex missed it)", () => {
|
|
55
|
+
const text = "agent: top-level\n";
|
|
56
|
+
assert.equal(OLD_DOCTOR().exec(text), null, "sanity: the old regex really did miss it");
|
|
57
|
+
assert.deepEqual(refsOf(parseAgentRefs(text)), ["top-level"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("parseAgentRefs: a malformed value is INVISIBLE, not silently truncated", () => {
|
|
61
|
+
// The old `\b` regex matched `agent: foo bar` and reported `foo`, hiding the
|
|
62
|
+
// malformation. Anchoring to end-of-line makes a bad value a non-match, which
|
|
63
|
+
// is what lets a caller trust the set it gets.
|
|
64
|
+
const text = " agent: foo bar\n";
|
|
65
|
+
assert.equal(OLD_DOCTOR().exec(text)[1], "foo", "sanity: the old regex truncated");
|
|
66
|
+
assert.deepEqual(parseAgentRefs(text), []);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("parseAgentRefs: a trailing comment is allowed and stripped", () => {
|
|
70
|
+
assert.deepEqual(refsOf(parseAgentRefs(" agent: alpha # why\n")), ["alpha"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("parseAgentRefs: self sentinels are skipped", () => {
|
|
74
|
+
const text = [" agent: agent-self", " agent: agent_self", " agent: self", " agent: real"].join("\n");
|
|
75
|
+
assert.deepEqual(refsOf(parseAgentRefs(text)), ["real"]);
|
|
76
|
+
for (const s of ["agent-self", "agent_self", "self"]) assert.equal(SELF_SENTINELS.has(s), true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("parseAgentRefs: reports 1-indexed line numbers and carries kind/file", () => {
|
|
80
|
+
const out = parseAgentRefs("x: 1\ny: 2\n agent: alpha\n", { kind: "workflow", file: "workflows/a.yaml" });
|
|
81
|
+
assert.deepEqual(out, [{ ref: "alpha", key: "agent", line: 3, kind: "workflow", file: "workflows/a.yaml" }]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("agentRefRegex: a FRESH regex per call (a shared /g carries lastIndex)", () => {
|
|
85
|
+
const text = " agent: alpha\n agent: beta\n";
|
|
86
|
+
assert.deepEqual(refsOf(parseAgentRefs(text)), ["alpha", "beta"]);
|
|
87
|
+
assert.deepEqual(refsOf(parseAgentRefs(text)), ["alpha", "beta"], "second call must not resume mid-string");
|
|
88
|
+
assert.equal(agentRefRegex().source, AGENT_REF_PATTERN);
|
|
89
|
+
assert.equal(agentRefRegex().lastIndex, 0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("parseAgentRefs: non-string / empty input returns []", () => {
|
|
93
|
+
assert.deepEqual(parseAgentRefs(null), []);
|
|
94
|
+
assert.deepEqual(parseAgentRefs(""), []);
|
|
95
|
+
assert.deepEqual(parseAgentRefs(42), []);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// teams/*.yaml — membership was never checked before
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
test("parseTeamRefs: reads `lead:` and the `members:` block sequence", () => {
|
|
103
|
+
const y = [
|
|
104
|
+
"id: growth",
|
|
105
|
+
"lead: growth-lead",
|
|
106
|
+
"members:",
|
|
107
|
+
" - desktop-operator # comment",
|
|
108
|
+
" - research-analyst",
|
|
109
|
+
"responsibilities:",
|
|
110
|
+
" - not-an-agent",
|
|
111
|
+
].join("\n");
|
|
112
|
+
const out = parseTeamRefs(y, { file: "teams/growth.yaml" });
|
|
113
|
+
assert.deepEqual(refsOf(out), ["growth-lead", "desktop-operator", "research-analyst"]);
|
|
114
|
+
assert.equal(out.every((r) => r.kind === "team" && r.file === "teams/growth.yaml"), true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("parseTeamRefs: a sibling key ENDS the members block", () => {
|
|
118
|
+
const y = ["members:", " - alpha", "other:", " - beta"].join("\n");
|
|
119
|
+
assert.deepEqual(refsOf(parseTeamRefs(y)), ["alpha"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("parseTeamRefs: skips self sentinels", () => {
|
|
123
|
+
assert.deepEqual(refsOf(parseTeamRefs("members:\n - agent-self\n - alpha\n")), ["alpha"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// event-routing — `via:` is what stops every workflow id being reported
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
test("parseEventRoutingRefs: `via` decides whether a handler is an AGENT", () => {
|
|
131
|
+
const y = [
|
|
132
|
+
"routes:",
|
|
133
|
+
" - event: inbox.received",
|
|
134
|
+
" handler: triage-agent",
|
|
135
|
+
" via: standard-agent",
|
|
136
|
+
" - event: cadence.tick",
|
|
137
|
+
" handler: daily-brief",
|
|
138
|
+
" via: workflow",
|
|
139
|
+
" - event: x.y",
|
|
140
|
+
" handler: some-skill",
|
|
141
|
+
" via: skill",
|
|
142
|
+
].join("\n");
|
|
143
|
+
const out = parseEventRoutingRefs(y, { file: "config/event-routing.yaml" });
|
|
144
|
+
assert.deepEqual(out.map((r) => [r.ref, r.isAgent]), [
|
|
145
|
+
["triage-agent", true],
|
|
146
|
+
["daily-brief", false],
|
|
147
|
+
["some-skill", false],
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("parseEventRoutingRefs: a route with NO `via` is treated as an agent handler", () => {
|
|
152
|
+
const out = parseEventRoutingRefs(" - event: e\n handler: mystery\n");
|
|
153
|
+
assert.deepEqual(out.map((r) => [r.ref, r.isAgent, r.via]), [["mystery", true, ""]]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("parseEventRoutingRefs: key order within a route does not matter", () => {
|
|
157
|
+
const y = [" - via: standard-agent", " handler: alpha", " - handler: beta", " via: workflow"].join("\n");
|
|
158
|
+
assert.deepEqual(parseEventRoutingRefs(y).map((r) => [r.ref, r.isAgent]), [["alpha", true], ["beta", false]]);
|
|
159
|
+
for (const v of ["standard-agent", "role-agent", "agent"]) assert.equal(AGENT_VIA.has(v), true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// scanAgentRefs — all three sources in one pass
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
test("scanAgentRefs: covers workflows + teams + event-routing, and filters non-agent routes", () => {
|
|
167
|
+
const root = repo();
|
|
168
|
+
mkdirSync(join(root, "workflows", "nested"), { recursive: true });
|
|
169
|
+
writeFileSync(join(root, "workflows", "a.yaml"), "steps:\n - agent: alpha\n - agent: agent-self\n");
|
|
170
|
+
writeFileSync(join(root, "workflows", "nested", "b.yml"), " agent: nested-one\n");
|
|
171
|
+
writeFileSync(join(root, "workflows", "notes.md"), " agent: ignored-non-yaml\n");
|
|
172
|
+
writeFileSync(join(root, "teams", "t.yaml"), "members:\n - team-member\n");
|
|
173
|
+
writeFileSync(join(root, "config", "event-routing.yaml"),
|
|
174
|
+
" - event: e\n handler: routed-agent\n via: standard-agent\n - event: f\n handler: some-workflow\n via: workflow\n");
|
|
175
|
+
|
|
176
|
+
const refs = scanAgentRefs({ agentRoot: root });
|
|
177
|
+
assert.deepEqual(refsOf(refs).sort(), ["alpha", "nested-one", "routed-agent", "team-member"]);
|
|
178
|
+
assert.deepEqual([...new Set(refs.map((r) => r.kind))].sort(), ["event-routing", "team", "workflow"]);
|
|
179
|
+
assert.equal(refs.every((r) => r.file && !r.file.startsWith("/")), true, "paths are agentRoot-relative");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("scanAgentRefs: includeNonAgentRouting surfaces workflow/skill handlers too", () => {
|
|
183
|
+
const root = repo();
|
|
184
|
+
writeFileSync(join(root, "config", "event-routing.yaml"), " - event: f\n handler: some-workflow\n via: workflow\n");
|
|
185
|
+
assert.deepEqual(refsOf(scanAgentRefs({ agentRoot: root })), []);
|
|
186
|
+
assert.deepEqual(refsOf(scanAgentRefs({ agentRoot: root, includeNonAgentRouting: true })), ["some-workflow"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("scanAgentRefs: fail-open on a missing root / missing dirs", () => {
|
|
190
|
+
assert.deepEqual(scanAgentRefs({}), []);
|
|
191
|
+
assert.deepEqual(scanAgentRefs({ agentRoot: join(tmpdir(), "definitely-not-here-12345") }), []);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("groupRefs: collapses to slug → locations", () => {
|
|
195
|
+
const m = groupRefs([
|
|
196
|
+
{ ref: "alpha", file: "w/a.yaml", line: 2, kind: "workflow" },
|
|
197
|
+
{ ref: "alpha", file: "w/b.yaml", line: 9, kind: "workflow" },
|
|
198
|
+
{ ref: "beta", file: "t/t.yaml", line: 1, kind: "team" },
|
|
199
|
+
null,
|
|
200
|
+
]);
|
|
201
|
+
assert.equal(m.get("alpha").length, 2);
|
|
202
|
+
assert.deepEqual(m.get("beta"), [{ file: "t/t.yaml", line: 1, kind: "team" }]);
|
|
203
|
+
assert.equal(m.size, 2);
|
|
204
|
+
});
|