cawdev-cli 0.9.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.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * What each stage of a lifecycle may do — R112.
3
+ *
4
+ * In `lib/` for `run-plugin.mjs`'s and `harness-prompt.mjs`'s reason: the claim
5
+ * this whole entry rests on is a claim about a LIST OF STRINGS, and a list of
6
+ * strings should not need a daemon, a platform and a spawned agent to check.
7
+ *
8
+ * The claim: **PLAN and VERIFY have no tool that can change anything in the
9
+ * CHECKOUT.** Not "are asked not to" — cannot. That is the difference between
10
+ * enforcement and hope, and it is the difference R28's profiles were built on:
11
+ *
12
+ * > a session told not to touch the code but able to is one refusal away from
13
+ * > touching it; a session that cannot has nothing to decide, and nothing to be
14
+ * > talked out of.
15
+ *
16
+ * The one exception, and it is narrow enough to name in a sentence — R150. A
17
+ * plan session started with NO CARD holds exactly one platform write,
18
+ * `roadmap_create`, because a card is what it is planning and it has not got
19
+ * one. Writing the thing it is about is the first step of planning it, not a
20
+ * widening of what a plan stage may do: it still cannot write a file, run a
21
+ * command, or comment on a card. That is why it is {@link CARD_WRITE} — one
22
+ * named string behind one named option — rather than an entry in `CAWDEV_READS`,
23
+ * which would hand a write to every read-only stage of every profile and call it
24
+ * a read.
25
+ */
26
+
27
+ /** Reading git without being able to change it. */
28
+ export const GIT_READS = [
29
+ 'Bash(git diff *)',
30
+ 'Bash(git log *)',
31
+ 'Bash(git show *)',
32
+ 'Bash(git status *)',
33
+ ];
34
+
35
+ /**
36
+ * cawdev's OWN tools that only read — NAMED, because this is the one registry
37
+ * this repository knows the whole of.
38
+ *
39
+ * <p>Everything else under `mcp__cawdev__` is a writer. That inverts how the
40
+ * rest of this file works and it is deliberate: for a registry we do not
41
+ * control, shape is the only signal there is; for ours it is the WORST one
42
+ * available, because we can simply say. `roadmap_comment` is the proof — a
43
+ * write to an append-only, undeletable discussion, whose name ends in a noun,
44
+ * which a shape test reads as a read.
45
+ *
46
+ * <p>The direction of failure is the argument. Forget to add a new READER here
47
+ * and a read-only stage says out loud that it cannot do something. Forget to add
48
+ * a new WRITER to a list of writers and the stage quietly has it. R112's claim
49
+ * survives only while the silence is on the safe side.
50
+ *
51
+ * <p>`report`, `ask_user`, `await_answer` and `approve` are here in spite of
52
+ * writing something: they are the loop every PROFILE runs on, and a session that
53
+ * cannot report has no way to finish. A read-only STAGE is the exception, and
54
+ * {@link STAGE_READS} below is where that is said.
55
+ */
56
+ export const CAWDEV_READS = new Set([
57
+ 'approve',
58
+ 'task_current',
59
+ 'report',
60
+ 'ask_user',
61
+ 'await_answer',
62
+ // R96's rounds are `ask_user` and `await_answer` in the interview's shape:
63
+ // one asks a person something, the rest are waits. The regex this list
64
+ // replaced kept all four, and they belong with the two above rather than
65
+ // with the tools that change a card.
66
+ 'ask_group',
67
+ 'await_group',
68
+ 'interview_rounds',
69
+ 'await_more_rounds',
70
+ 'roadmap_where',
71
+ 'code_map',
72
+ 'file_deps',
73
+ 'roadmap_statuses',
74
+ 'roadmap_list',
75
+ 'roadmap_get',
76
+ 'issue_list',
77
+ 'changelog_list',
78
+ 'changelog_get',
79
+ ]);
80
+
81
+ /**
82
+ * The same list minus `report`, which is what a read-only STAGE may hold.
83
+ *
84
+ * <p>The one name where a profile and a stage genuinely disagree, and it is not
85
+ * a subtlety: `report` kind "done" ends the **run**, not the stage. A profile
86
+ * needs it — that is how an ASK or a REVIEW session finishes at all. A PLAN
87
+ * stage calling it would finish the whole run in the middle of the walk, and
88
+ * every stage behind it would be skipped with "the run was reported finished
89
+ * during the PLAN stage".
90
+ *
91
+ * <p>Derived rather than typed out twice: the two lists differ by exactly one
92
+ * name, and a second literal is a second thing to keep in step.
93
+ */
94
+ const STAGE_READS = new Set([...CAWDEV_READS].filter((name) => name !== 'report'));
95
+
96
+ /** The `mcp__cawdev__` prefix, once. */
97
+ const CAWDEV = 'mcp__cawdev__';
98
+
99
+ /** The one tool no STAGE may hold — see {@link toolsForStage}. */
100
+ const REPORT = `${CAWDEV}report`;
101
+
102
+ /**
103
+ * The one platform write a PLAN stage may hold — R150.
104
+ *
105
+ * <p>Only when the run has no card, and only on PLAN. `canChangeThings` goes on
106
+ * calling it a writer, which is correct and is what makes this an exception
107
+ * rather than a reclassification: the name is let through by the stage that was
108
+ * deliberately given it, and by nobody else.
109
+ */
110
+ export const CARD_WRITE = `${CAWDEV}roadmap_create`;
111
+
112
+ /** The tool a session delegates with, and what an older CLI called it. */
113
+ const DELEGATES = /^(Agent|Task)\b/;
114
+
115
+ /**
116
+ * Tools a subagent may hold and still change nothing — R147.
117
+ *
118
+ * <p>A NAMED list, on `CAWDEV_READS`'s argument rather than `canChangeThings`'s:
119
+ * an expert's `tools:` line is somebody else's frontmatter, out of a marketplace
120
+ * repository, and the question is not "does this look like a writer" but "is
121
+ * every one of these a tool we know only reads". Forget a reader here and an
122
+ * expert is refused from a planning stage, which is loud; a shape test that
123
+ * let `WebFetch` through would have let `Bash` through the day somebody wrote
124
+ * it as `bash`.
125
+ */
126
+ export const SUBAGENT_READS = new Set([
127
+ 'Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch', 'TodoRead',
128
+ ]);
129
+
130
+ /**
131
+ * Whether an expert can change nothing — R147, and the half of R112's claim
132
+ * that delegation used to break.
133
+ *
134
+ * <p>`canChangeThings` calls `Agent` a writer because a subagent has its own
135
+ * tool list, and a PLAN stage holding it could write through a helper. True of
136
+ * an expert that inherits every tool, or asks for `Write` — and false of one
137
+ * whose frontmatter names only reads, which is what `architect`, `planner` and
138
+ * `code-explorer` do. Those are the experts a planning stage exists to consult,
139
+ * and R112 was refusing them the one tool that reaches an expert.
140
+ *
141
+ * <p>Judged from the same fields `run-plugin.mjs` writes into the file the CLI
142
+ * loads, so what is checked is what the subagent is actually spawned with. An
143
+ * expert with NO `tools:` line inherits the parent's — which in a PLAN stage is
144
+ * read-only anyway, but "inherits whatever the CLI decides" is not a claim this
145
+ * file can make, so it is not read-only here.
146
+ */
147
+ export function readOnlyExpert(agent) {
148
+ const declared = String(agent?.tools ?? '').split(',').map((each) => each.trim())
149
+ .filter(Boolean);
150
+ return declared.length > 0 && declared.every((tool) => SUBAGENT_READS.has(tool));
151
+ }
152
+
153
+ /**
154
+ * Anything that can change something, recognised by shape rather than by name.
155
+ *
156
+ * A list of forbidden tool names would go stale the day the CLI ships a new
157
+ * writer — and the failure would be silent, because a stage would simply have a
158
+ * tool nobody thought to exclude. This asks the opposite question: does this
159
+ * permission let the session change anything? Everything that does is refused
160
+ * from a read-only stage, including tools that do not exist yet.
161
+ *
162
+ * <p>cawdev's own tools are the exception and are judged by {@link STAGE_READS}
163
+ * instead — see the note there for why shape is the worst available signal on
164
+ * the one registry we own.
165
+ */
166
+ export function canChangeThings(tool) {
167
+ // Delegation is the loudest writer there is, and it does not look like one.
168
+ // `Agent` spawns a SUBAGENT with its own tool list — so a PLAN stage holding
169
+ // it could write every file it was carefully not given a tool for, through a
170
+ // helper. R112's load-bearing claim is that a planning stage *cannot* write,
171
+ // and it survives only if this says so. IMPLEMENT and TEST keep it, which is
172
+ // where an expert was always meant to be reached from.
173
+ if (/^(Agent|Task)\b/.test(tool)) {
174
+ return true;
175
+ }
176
+ // A PREFIX, deliberately without a word boundary: `WriteMany` is a writer and
177
+ // `\b` would let it through, because `Write` is followed by another word
178
+ // character. Recognising by shape only helps if the shape is the loose one.
179
+ if (/^(Write|Edit|MultiEdit|NotebookEdit|Update)/.test(tool)) {
180
+ return true;
181
+ }
182
+ if (/^Bash\b/.test(tool)) {
183
+ // Bash is the one that has to be judged rather than named: `Bash(git *)`
184
+ // can commit, `Bash(git diff *)` cannot. Anything not on the read list is
185
+ // treated as able to change something, which is the safe direction and the
186
+ // one that stays right when somebody adds a pattern here.
187
+ return !GIT_READS.includes(tool);
188
+ }
189
+ // Our own registry is judged by the list, because we have one. Anything under
190
+ // this prefix that is not a known read is a writer — including a tool added
191
+ // tomorrow and forgotten here, which is the direction that fails loudly.
192
+ if (tool.startsWith(CAWDEV)) {
193
+ return !STAGE_READS.has(tool.slice(CAWDEV.length));
194
+ }
195
+ // Everybody else's MCP tools, where shape is all there is: one that writes
196
+ // usually says so in its name. A guess, and acceptable only because the
197
+ // alternative — treating every third-party MCP tool as a writer — would strip
198
+ // a read-only stage of R76's registry entirely.
199
+ return /(create|update|set_status|decline|add|save|report)$/.test(tool);
200
+ }
201
+
202
+ /**
203
+ * Anything that can RUN something, recognised by shape rather than by name.
204
+ *
205
+ * R129's mirror of `canChangeThings`, and the same question asked the other way
206
+ * round: not "does this let the session change anything" but "does this let the
207
+ * session EXECUTE anything". A TEST stage set to write the testbook may write
208
+ * all it likes — it has a file to write — and must not be able to run the suite
209
+ * it is describing.
210
+ *
211
+ * Deliberately narrower than `canChangeThings`: `Write` and `Edit` are not
212
+ * runners, and a stage refused them could not produce the artefact it exists
213
+ * for.
214
+ */
215
+ export function canRunThings(tool) {
216
+ // Delegation runs anything: a subagent with its own tool list could run the
217
+ // very suite this stage was spawned unable to run. The same argument
218
+ // canChangeThings makes about writing, and it is the same tool making it.
219
+ if (/^(Agent|Task)\b/.test(tool)) {
220
+ return true;
221
+ }
222
+ if (/^Bash\b/.test(tool)) {
223
+ // The git READS are kept, for the read-only stages' reason: `git diff` is
224
+ // how this stage finds out what the run it is describing actually changed.
225
+ // Anything else that reaches a shell is treated as able to run something,
226
+ // which is the safe direction.
227
+ return !GIT_READS.includes(tool);
228
+ }
229
+ return false;
230
+ }
231
+
232
+ /**
233
+ * The tools one stage gets, narrowed from what the PROFILE already allows.
234
+ *
235
+ * <p>Narrowed, never widened: a stage cannot be handed something the profile
236
+ * would have refused, so an interview's IMPLEMENT stage gets an interview's
237
+ * tools rather than a coding run's. That ordering is what keeps R28's decision
238
+ * the outer one.
239
+ *
240
+ * @param stage one of PLAN, VERIFY, IMPLEMENT, TEST, MEMORY
241
+ * @param profileTools what the run's profile allows
242
+ * @param testMode R129, and only TEST reads it: `TESTBOOK` narrows the stage to
243
+ * what cannot run anything. Absent — an older platform that does not send it —
244
+ * means `RUN`, which is what TEST has always been.
245
+ */
246
+ export function toolsForStage(stage, profileTools, testMode, options = {}) {
247
+ // R147. A read-only stage keeps `Agent` when EVERY expert it is handed is
248
+ // read-only — the caller narrows the list to those before spawning, and
249
+ // `readOnlyExpert` is the judge. The claim stays intact: a subagent holding
250
+ // only Read, Grep and Glob can no more write than the stage that called it.
251
+ const delegates = Boolean(options.delegatesReadOnly);
252
+ // `report` kind "done" ends the RUN, not the stage — so NO stage may hold it,
253
+ // whatever else it may do. STAGE_READS says this above and only said it to the
254
+ // read-only stages, because IMPLEMENT and TEST were handed the profile's list
255
+ // untouched. They therefore kept `report`, used it, and every stage behind
256
+ // them was marked SKIPPED with "the run was reported finished during the TEST
257
+ // stage" — which is how MEMORY came to run once in fifty runs.
258
+ //
259
+ // A stage ends by its turn ending; the walk advances on `close` and finishes
260
+ // the run when it runs out of stages. Nothing here needs to announce it. A run
261
+ // with NO lifecycle never comes through this function and keeps `report`,
262
+ // which is the case the tool exists for. It applies to a TESTBOOK stage too:
263
+ // narrowing what a stage may RUN and refusing it the tool that ends the run
264
+ // are two different questions, and this one is asked of every stage.
265
+ const allowed = (Array.isArray(profileTools) ? profileTools : [])
266
+ .filter((tool) => tool !== REPORT);
267
+ // R150. The card write, and ONLY on PLAN, and only when the run said it has
268
+ // no card. VERIFY shares the filter below and must not get it: by the time a
269
+ // VERIFY stage runs, the card exists and writing a second one is not what
270
+ // this permission is for. That is why the arm is split rather than the flag
271
+ // read once for all three.
272
+ const writesItsOwnCard = Boolean(options.writesItsOwnCard);
273
+ const readOnly = (keepsCardWrite) => [
274
+ ...allowed.filter((tool) => !canChangeThings(tool)
275
+ || (delegates && DELEGATES.test(tool))
276
+ || (keepsCardWrite && tool === CARD_WRITE)),
277
+ ...GIT_READS.filter((tool) => allowed.includes(tool) || allowed.includes('Bash(git *)')),
278
+ ];
279
+ switch (stage) {
280
+ case 'PLAN':
281
+ // The load-bearing claim. Note this filters the PROFILE's list rather
282
+ // than naming a list of its own: a stage's tools are always a subset of
283
+ // what the run was already allowed, and a fresh list here would be a
284
+ // second place deciding what a profile may do. That subsetting is what
285
+ // makes the card write safe to allow here — the profile has to have
286
+ // handed it over first, and only a cardless plan run's does.
287
+ return readOnly(writesItsOwnCard);
288
+ case 'VERIFY':
289
+ case 'MEMORY':
290
+ return readOnly(false);
291
+ case 'TEST':
292
+ // R129. The mirror of the claim above, with the other question asked: a
293
+ // stage that may WRITE and cannot RUN. Same filtering of the PROFILE's
294
+ // own list, for the same reason — a fresh list here would be a second
295
+ // place deciding what a profile may do.
296
+ if (testMode === 'TESTBOOK') {
297
+ return [...allowed.filter((tool) => !canRunThings(tool)),
298
+ ...GIT_READS.filter((tool) => allowed.includes(tool) || allowed.includes('Bash(git *)'))];
299
+ }
300
+ return allowed;
301
+ case 'IMPLEMENT':
302
+ return allowed;
303
+ default:
304
+ // A stage this version does not know is given the profile's own tools —
305
+ // the same as no lifecycle at all. Refusing everything would turn a
306
+ // rollback into a run that can do nothing and cannot say why.
307
+ return allowed;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Whether a TOOL line is one of the git reads a narrowed stage legitimately has.
313
+ *
314
+ * A tool line is `<name> <described input>`, and for a shell call the name is
315
+ * the bare `Bash` — the PATTERN it was allowed under is not in the line, only
316
+ * the command. So the first word alone cannot tell `git diff` from `npm test`,
317
+ * and a check that judged by it would fire on the very `git diff` a testbook
318
+ * stage is TOLD to run. A note that appears on every run makes nothing visible,
319
+ * which is the one thing R113's note has to do.
320
+ */
321
+ function readsGit(toolLine) {
322
+ const command = String(toolLine ?? '').split(/\s+/).slice(1).join(' ');
323
+ return GIT_READS.some((read) => {
324
+ const verb = read.slice('Bash('.length, -' *)'.length);
325
+ return command === verb || command.startsWith(`${verb} `);
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Whether a tool call contradicts the stage that claims to be running — R113.
331
+ *
332
+ * <p>Returns a sentence, or null. **It never blocks.** R112 does the blocking,
333
+ * by not handing over the tool in the first place; this is the cross-check that
334
+ * catches the case where it somehow did — a stage list that grew a tool nobody
335
+ * meant, a CLI that ignored `--allowedTools`, a version of this file that is
336
+ * wrong. A second thing that can stop a run is a second thing that can stop it
337
+ * wrongly, and that is a worse failure than a line in a transcript.
338
+ *
339
+ * <p>Said in the transcript rather than only in the daemon's log, because the
340
+ * person who needs to know is the one reading the run.
341
+ */
342
+ export function driftedFrom(stage, toolLine, testMode, readOnlyExperts = [], options = {}) {
343
+ const readOnly = stage === 'PLAN' || stage === 'VERIFY' || stage === 'MEMORY';
344
+ // R129. A TEST stage set to write the testbook has its own thing it should
345
+ // not have been able to do, and the same reason for saying so out loud.
346
+ const testbook = stage === 'TEST' && testMode === 'TESTBOOK';
347
+ if (!readOnly && !testbook) {
348
+ return null;
349
+ }
350
+ // `linesOf` writes a TOOL line as `<name> <described input>`, so the name is
351
+ // the first word and the pattern is what follows it.
352
+ const name = String(toolLine ?? '').split(/\s/, 1)[0];
353
+ if (!name) {
354
+ return null;
355
+ }
356
+ const notice = 'The work was not stopped — this is a note that what ran and what was '
357
+ + 'permitted have disagreed, which should not be possible and is worth looking at.';
358
+ if (testbook) {
359
+ if (!canRunThings(name) || readsGit(toolLine)) {
360
+ return null;
361
+ }
362
+ return `cawdev: the TEST stage is set to write the testbook and called ${name}, which it `
363
+ + `should not have been able to. ${notice}`;
364
+ }
365
+ if (!canChangeThings(name)) {
366
+ return null;
367
+ }
368
+ // R147. A delegation to an expert this stage was allowed is the one call
369
+ // `canChangeThings` names a writer that is not one here. `describeCall`
370
+ // writes it as `Agent(<name>) …`, and the name is the CLI's qualified one.
371
+ const delegated = /^(?:Agent|Task)\(([^)]+)\)/.exec(String(toolLine ?? ''));
372
+ if (delegated && readOnlyExperts.includes(delegated[1])) {
373
+ return null;
374
+ }
375
+ // R150. The other call this stage was deliberately given and `canChangeThings`
376
+ // correctly calls a writer. Same shape as the delegation above, and the same
377
+ // reason: a note that fires on the one thing the stage was told to do is a
378
+ // note that teaches people to ignore it.
379
+ if (stage === 'PLAN' && options.writesItsOwnCard && name === CARD_WRITE) {
380
+ return null;
381
+ }
382
+ return `cawdev: the ${stage} stage called ${name}, which it should not have been able to. `
383
+ + notice;
384
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * How a tool call is written into a transcript — R130.
3
+ *
4
+ * <p>In `lib/` for `stage-tools.mjs`'s reason: this is a claim about a STRING,
5
+ * and a claim about a string should not need a daemon to check. It has no
6
+ * dependencies and it never throws.
7
+ *
8
+ * <p><strong>This file is one half of a parsing contract.</strong> The runner
9
+ * FORMATS here and the platform PARSES in
10
+ * `backend/src/main/java/dev/caw/cawdev/run/CapabilityUse.java`; the console
11
+ * classifies the same three shapes again for the transcript's gutter badge, in
12
+ * `frontend/src/app/runs/capability-tag.ts`. Three implementations of one rule
13
+ * is two too many, and the mitigation is that all three are tiny, pure, and
14
+ * tested against the same example table — `tool-line.test.mjs`,
15
+ * `CapabilityUseTest.java`, `capability-tag.spec.ts`. Change the strings below
16
+ * and change all three.
17
+ *
18
+ * <p>The shapes are deliberately ones an OLD daemon can never accidentally
19
+ * emit. A daemon that predates this writes `Agent {"description":"…`, which
20
+ * does not match `^(Agent|Task)\(` — so it produces no ledger row rather than a
21
+ * row named `{"description":"…`. Degrading to silence is required; garbage in
22
+ * the panel is not.
23
+ *
24
+ * <p>Everything that is not a capability is byte-identical to what was written
25
+ * before this entry, which is what keeps `driftedFrom` in `stage-tools.mjs`
26
+ * working: it reads the tool name off the front of a TOOL body.
27
+ */
28
+
29
+ /** A skill's arguments are context, not the identity of the call. */
30
+ const ARGS_LIMIT = 120;
31
+
32
+ /**
33
+ * A tool call as one line: what was called, and enough to know which one.
34
+ *
35
+ * <p>The three capability shapes are named rather than dumped. Before this, a
36
+ * `Skill` call fell through to `JSON.stringify` and an `Agent` call was mostly
37
+ * the prompt — so which skill ran, and which expert was delegated to, was
38
+ * normally lost off the end of the 200-character cut.
39
+ */
40
+ export function describeCall(name, input) {
41
+ const args = input && typeof input === 'object' ? input : {};
42
+ if (name === 'Skill' && typeof args.skill === 'string' && args.skill) {
43
+ const extra = typeof args.args === 'string' ? args.args.trim() : '';
44
+ return `Skill(${args.skill})${extra ? ` ${extra.slice(0, ARGS_LIMIT)}` : ''}`;
45
+ }
46
+ if (name === 'Agent' || name === 'Task') {
47
+ // `Task` is normalised to `Agent`, matching DELEGATE in runner.mjs: an
48
+ // older CLI's name for the same act should not produce a second vocabulary
49
+ // on the panel, with one expert appearing twice under two spellings.
50
+ const who = typeof args.subagent_type === 'string' && args.subagent_type
51
+ ? args.subagent_type
52
+ : 'unnamed';
53
+ const what = typeof args.description === 'string' ? args.description : '';
54
+ return `Agent(${who}) ${what}`.trim();
55
+ }
56
+ return `${name} ${describeInput(input)}`.trim();
57
+ }
58
+
59
+ /** A tool's input as one short line — the arguments that identify the call. */
60
+ export function describeInput(input) {
61
+ if (!input || typeof input !== 'object') return '';
62
+ const interesting = ['file_path', 'path', 'command', 'pattern', 'url', 'query', 'number', 'kind'];
63
+ for (const key of interesting) {
64
+ if (typeof input[key] === 'string' || typeof input[key] === 'number') {
65
+ return String(input[key]).slice(0, 300);
66
+ }
67
+ }
68
+ const json = JSON.stringify(input);
69
+ return json.length > 200 ? `${json.slice(0, 199)}…` : json;
70
+ }
71
+
72
+ /**
73
+ * Which capability a TOOL line named, if it named one — R161.
74
+ *
75
+ * <p>The reader for the two shapes {@link describeCall} writes: `Skill(<key>)`
76
+ * and `Agent(<key>)`. The daemon asks it "has this stage used the thing the
77
+ * project requires", which is a question about a string, so it belongs beside
78
+ * the code that wrote the string and in `lib/` with it — answering it should
79
+ * not need a daemon, a platform and a spawned agent.
80
+ *
81
+ * <p>Third reader of the contract this file's header describes. `Task(` is not
82
+ * matched because `describeCall` normalises it away before anything is written:
83
+ * what reaches a transcript is always `Agent(`.
84
+ *
85
+ * @returns `{kind, key}`, or null for anything that is not a capability call.
86
+ */
87
+ export function capabilityIn(line) {
88
+ if (typeof line !== 'string') return null;
89
+ const found = /^(Skill|Agent)\(([^)]+)\)/.exec(line);
90
+ if (!found) return null;
91
+ return { kind: found[1] === 'Skill' ? 'SKILL' : 'EXPERT', key: found[2] };
92
+ }