@cohortapp/agent-sdk 2.3.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/framework-features.json +30 -0
  2. package/lib/backlog.mjs +136 -0
  3. package/lib/cadences.mjs +63 -2
  4. package/lib/cadences.test.mjs +105 -0
  5. package/lib/capability/inventory.mjs +542 -0
  6. package/lib/capability/inventory.test.mjs +232 -0
  7. package/lib/capability/probe.mjs +255 -0
  8. package/lib/channels/contract.mjs +37 -1
  9. package/lib/channels/contract.test.mjs +25 -1
  10. package/lib/claude-bin.mjs +37 -3
  11. package/lib/claude-bin.test.mjs +42 -8
  12. package/lib/execution/disposition.mjs +501 -0
  13. package/lib/execution/disposition.test.mjs +482 -0
  14. package/lib/execution/drive.mjs +352 -0
  15. package/lib/execution/drive.test.mjs +270 -0
  16. package/lib/execution/effects.mjs +340 -0
  17. package/lib/execution/effects.test.mjs +193 -0
  18. package/lib/execution/index.mjs +152 -0
  19. package/lib/execution/intake.mjs +581 -0
  20. package/lib/execution/intake.test.mjs +343 -0
  21. package/lib/execution/journal.mjs +374 -0
  22. package/lib/execution/journal.test.mjs +261 -0
  23. package/lib/execution/match.mjs +331 -0
  24. package/lib/execution/match.test.mjs +235 -0
  25. package/lib/execution/pipeline.mjs +341 -0
  26. package/lib/execution/pipeline.test.mjs +389 -0
  27. package/lib/execution/route.mjs +332 -0
  28. package/lib/execution/route.test.mjs +186 -0
  29. package/lib/execution/surface-policy.mjs +446 -0
  30. package/lib/execution/surface-policy.test.mjs +162 -0
  31. package/lib/goals/admission.mjs +209 -0
  32. package/lib/goals/admission.test.mjs +139 -0
  33. package/lib/goals/classify.mjs +206 -0
  34. package/lib/goals/classify.test.mjs +109 -0
  35. package/lib/goals/collaborate.mjs +415 -0
  36. package/lib/goals/collaborate.test.mjs +324 -0
  37. package/lib/goals/gaps.mjs +111 -0
  38. package/lib/goals/gaps.test.mjs +284 -0
  39. package/lib/goals/loop.mjs +537 -0
  40. package/lib/goals/loop.test.mjs +719 -0
  41. package/lib/identity/persona.mjs +247 -0
  42. package/lib/identity/persona.test.mjs +117 -0
  43. package/lib/kpi.mjs +469 -0
  44. package/lib/kpi.test.mjs +244 -0
  45. package/lib/mandate/audit.mjs +168 -0
  46. package/lib/mandate/audit.test.mjs +195 -0
  47. package/lib/mandate/cache.mjs +162 -0
  48. package/lib/mandate/derive.mjs +317 -0
  49. package/lib/mandate/derive.test.mjs +224 -0
  50. package/lib/mandate/model.mjs +352 -0
  51. package/lib/mandate/model.test.mjs +145 -0
  52. package/lib/mandate/refresh.mjs +187 -0
  53. package/lib/mandate/refresh.test.mjs +293 -0
  54. package/lib/mcp/server.test.mjs +4 -4
  55. package/lib/org/approvals.mjs +14 -2
  56. package/lib/org/client.mjs +58 -22
  57. package/lib/org/client.test.mjs +3 -1
  58. package/lib/org/inbound/directedness.mjs +720 -0
  59. package/lib/org/inbound/directedness.test.mjs +543 -0
  60. package/lib/org/inbound/facts.mjs +501 -0
  61. package/lib/org/inbound/facts.test.mjs +375 -0
  62. package/lib/org/inbound/hydrate.mjs +535 -0
  63. package/lib/org/inbound/hydrate.test.mjs +326 -0
  64. package/lib/org/inbound/index.mjs +233 -0
  65. package/lib/org/inbound/index.test.mjs +324 -0
  66. package/lib/org/inbound/io.mjs +141 -0
  67. package/lib/org/inbound/project.mjs +201 -0
  68. package/lib/org/inbound/project.test.mjs +287 -0
  69. package/lib/org/inbound/surfaces.mjs +257 -0
  70. package/lib/org/knowledge.mjs +10 -1
  71. package/lib/org/knowledge.test.mjs +8 -1
  72. package/lib/org/leases.mjs +5 -0
  73. package/lib/org/mesh.mjs +17 -2
  74. package/lib/org/messaging.mjs +40 -4
  75. package/lib/org/messaging.test.mjs +40 -0
  76. package/lib/org/param-contract.mjs +694 -0
  77. package/lib/org/param-contract.test.mjs +451 -0
  78. package/lib/org/protocol.checksum +1 -1
  79. package/lib/org/protocol.mjs +8 -0
  80. package/lib/org/protocol.test.mjs +5 -1
  81. package/lib/org/push.mjs +1025 -0
  82. package/lib/org/push.test.mjs +690 -0
  83. package/lib/org/tool-surface.mjs +138 -38
  84. package/lib/org/tool-surface.test.mjs +13 -8
  85. package/lib/org/typing.mjs +341 -0
  86. package/lib/org/typing.test.mjs +291 -0
  87. package/lib/plan/compile.mjs +510 -0
  88. package/lib/plan/compile.test.mjs +286 -0
  89. package/lib/plan/emit.mjs +256 -0
  90. package/lib/plan/emit.test.mjs +246 -0
  91. package/lib/plan/explain.mjs +226 -0
  92. package/lib/plan/explain.test.mjs +188 -0
  93. package/lib/plan/schema.mjs +140 -0
  94. package/lib/resource-governor.mjs +47 -1
  95. package/lib/resource-governor.test.mjs +21 -1
  96. package/lib/setup/enroll-from-cohort.mjs +84 -16
  97. package/lib/setup/enroll-from-cohort.test.mjs +43 -1
  98. package/lib/setup/sections/identity.mjs +15 -4
  99. package/lib/setup/sections/identity.test.mjs +94 -0
  100. package/lib/setup/sections/inventory.mjs +178 -0
  101. package/lib/setup/sections/inventory.test.mjs +198 -0
  102. package/lib/setup/sections/mandate.mjs +392 -0
  103. package/lib/setup/sections/mandate.test.mjs +373 -0
  104. package/lib/setup/sections/subagents.mjs +427 -0
  105. package/lib/setup/sections/subagents.test.mjs +429 -0
  106. package/lib/setup/sections/verify.mjs +121 -0
  107. package/lib/setup/sections/verify.test.mjs +175 -0
  108. package/lib/setup/sot.mjs +2 -0
  109. package/lib/subagents/cli.mjs +463 -0
  110. package/lib/subagents/cli.test.mjs +389 -0
  111. package/lib/subagents/client.mjs +373 -0
  112. package/lib/subagents/client.test.mjs +309 -0
  113. package/lib/subagents/gap.mjs +268 -0
  114. package/lib/subagents/gap.test.mjs +234 -0
  115. package/lib/subagents/lock.mjs +296 -0
  116. package/lib/subagents/lock.test.mjs +248 -0
  117. package/lib/subagents/manifest.mjs +224 -0
  118. package/lib/subagents/manifest.test.mjs +175 -0
  119. package/lib/subagents/refs.mjs +274 -0
  120. package/lib/subagents/refs.test.mjs +204 -0
  121. package/lib/subagents/resolve.mjs +455 -0
  122. package/lib/subagents/resolve.test.mjs +422 -0
  123. package/lib/subagents/schema.mjs +467 -0
  124. package/lib/subagents/schema.test.mjs +306 -0
  125. package/package.json +8 -3
  126. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  127. package/policies/ai-disclosure.yaml +42 -2
  128. package/scaffold/CLAUDE.md +16 -2
  129. package/schedules/triggers/goal-steward.md +79 -0
  130. package/scripts/ci/conformance-org-api.mjs +792 -0
  131. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  132. package/scripts/daemon/agent-daemon.mjs +36 -4
  133. package/scripts/daemon/cadence-handlers.mjs +145 -1
  134. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  135. package/scripts/daemon/inbox-deferral.mjs +45 -2
  136. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  137. package/scripts/daemon/inbox-wake.mjs +282 -0
  138. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  139. package/scripts/daemon/prompt-builder.mjs +41 -1
  140. package/scripts/daemon/typing-registry.mjs +55 -2
  141. package/scripts/daemon/typing-registry.test.mjs +25 -0
  142. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  143. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  144. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  145. package/scripts/setup/generate-plan.mjs +108 -0
  146. package/scripts/setup/init-capability-manifest.mjs +70 -0
  147. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  148. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -0,0 +1,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
+ });