omp-llm-role 1.0.0 → 1.2.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 (50) hide show
  1. package/README.md +202 -12
  2. package/agents/designer.md +4 -3
  3. package/docs/dev/README.md +44 -7
  4. package/docs/dev/agent-authoring.md +59 -1
  5. package/docs/dev/architecture.md +106 -28
  6. package/docs/dev/ci.md +163 -0
  7. package/docs/dev/current-state.md +364 -24
  8. package/docs/dev/data-sources.md +231 -38
  9. package/docs/dev/explorer.md +175 -46
  10. package/docs/dev/quirks.md +77 -4
  11. package/docs/dev/releasing.md +130 -17
  12. package/docs/dev/scoring.md +851 -27
  13. package/docs/dev/testing.md +253 -0
  14. package/docs/dev/writes-and-state.md +232 -28
  15. package/docs/llm-role-rankings.md +149 -148
  16. package/package.json +814 -80
  17. package/skills/omp-llm-role-create-agent/SKILL.md +7 -1
  18. package/src/agent-architect.ts +148 -19
  19. package/src/agent-create.ts +800 -40
  20. package/src/agent-file.ts +2 -5
  21. package/src/architect-provenance.ts +16 -0
  22. package/src/availability.ts +191 -15
  23. package/src/benchmark-author.ts +9 -4
  24. package/src/benchmark-sources.ts +283 -18
  25. package/src/cli/create-role.ts +29 -1
  26. package/src/cli/llm-role-rank.ts +40 -1
  27. package/src/config-edit.ts +207 -80
  28. package/src/engine.ts +519 -75
  29. package/src/error-report.ts +562 -0
  30. package/src/explorer/boot.ts +36 -24
  31. package/src/explorer/explain.ts +128 -13
  32. package/src/explorer/scopes.ts +110 -0
  33. package/src/explorer/server.ts +126 -33
  34. package/src/extension.ts +464 -48
  35. package/src/features.ts +105 -0
  36. package/src/project-registry.ts +65 -0
  37. package/src/project-setup.ts +382 -0
  38. package/src/prompts/agent-creation-architect.md +2 -0
  39. package/src/prompts/project-profile-architect.md +33 -0
  40. package/src/prompts/project-profile-user.md +8 -0
  41. package/src/provider-display.ts +67 -0
  42. package/src/role-archetypes.ts +12 -2
  43. package/src/role-settings.ts +22 -0
  44. package/src/settings.ts +211 -24
  45. package/src/state.ts +1 -1
  46. package/src/updater.ts +234 -37
  47. package/web/app.js +434 -12
  48. package/web/index.html +1 -0
  49. package/web/style.css +44 -1
  50. package/docs/dev/spec.md +0 -633
@@ -1,23 +1,32 @@
1
1
  /**
2
- * Surgical YAML editing for the omp agent config (SPEC §8.1, decision #4).
2
+ * Surgical YAML editing for the omp agent config.
3
3
  *
4
4
  * The document is patched as TEXT, line-oriented: only the value tokens of
5
- * managed roles inside the top-level `modelRoles:` block and the managed keys
6
- * inside `retry:` → `fallbackChains:` change. Comments, blank lines, unknown
7
- * keys and formatting stay byte-identical. Reading back uses the same
8
- * line-oriented rules, so the plugin has no runtime YAML dependency (required
9
- * for omp marketplace installs, which never install package dependencies) and
5
+ * managed roles inside the top-level `modelRoles:` block, the managed keys
6
+ * inside `retry:` → `fallbackChains:` and the `- name` entries of
7
+ * `task.disabledAgents` change. Comments, blank lines, unknown keys and
8
+ * formatting stay byte-identical. Reading back uses the same line-oriented
9
+ * rules, so the plugin has no runtime YAML dependency (required for omp
10
+ * marketplace installs, which never install package dependencies) and
10
11
  * read/write can never disagree. Every patch is self-checked: the patched text
11
12
  * must read back as exactly the intended state or it throws instead of
12
13
  * returning. The real `yaml` parser still validates patch output in tests
13
14
  * (dev-only dependency). Values are always emitted as double-quoted strings.
14
15
  *
16
+ * An empty managed collection may be spelled the way a YAML serializer writes
17
+ * it — a null scalar (`disabledAgents: null`) or a flow collection on its own
18
+ * indented line (`fallbackChains:` then ` {}`) — and reads as empty; the
19
+ * patch path rewrites that placeholder line into the block form. Any other
20
+ * inline value on a managed key line is a structural surprise and throws, so
21
+ * the plugin never silently drops something it cannot re-emit.
22
+ *
15
23
  * Indentation follows the file's own style: existing chain keys keep their
16
24
  * indent; new keys match the first existing sibling (defaulting to the
17
25
  * observed house style: keys at 4, list items at 6).
18
26
  */
19
27
 
20
- import { renameSync, statSync, writeFileSync } from "node:fs";
28
+ import { mkdirSync, renameSync, statSync, writeFileSync } from "node:fs";
29
+ import { dirname } from "node:path";
21
30
 
22
31
  export class ConfigEditError extends Error {}
23
32
 
@@ -45,13 +54,52 @@ function scalarValue(raw: string): string | null {
45
54
  return stripped.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
46
55
  }
47
56
 
57
+ /** A line's value text: trailing ` # comment` stripped, trimmed. */
58
+ function valueText(value: string): string {
59
+ return value.replace(/\s+#.*$/, "").trim();
60
+ }
61
+
62
+ /**
63
+ * A YAML null *token* (`key: null`, `key: ~`) — a spelling a config writer
64
+ * emits for an empty collection value. Not a structural surprise: it means
65
+ * exactly what an empty block means, and the patch path rewrites the line. An
66
+ * empty inline value is a different thing — a block header whose body follows —
67
+ * so it is not a token.
68
+ */
69
+ function isNullToken(value: string): boolean {
70
+ const text = valueText(value);
71
+ return text === "null" || text === "~" || text === "Null" || text === "NULL";
72
+ }
73
+
74
+ /**
75
+ * The flow collection a YAML serializer puts on its own indented line for an
76
+ * empty collection (`key:` followed by ` []` / ` {}` — Bun's
77
+ * `YAML.stringify` does this, and omp's config writer uses it). Recognized so
78
+ * the patch path replaces the placeholder line instead of reading it as content.
79
+ */
80
+ function isEmptyFlow(line: string, flow: "[]" | "{}"): boolean {
81
+ return valueText(line) === flow;
82
+ }
83
+
84
+ /**
85
+ * A `key: value # comment` line rewritten as its block header: the `indent +
86
+ * key + :` head keeps its own indentation and trailing comment, so replacing an
87
+ * inline empty value with a block loses nothing but the empty value token.
88
+ */
89
+ function keyHead(line: string): string {
90
+ return line.slice(0, line.indexOf(":") + 1) + (line.match(/\s+#.*$/)?.[0] ?? "");
91
+ }
92
+
48
93
  /**
49
94
  * Best-effort line-oriented read of the two surfaces the plugin owns: values
50
95
  * inside the top-level `modelRoles:` block and keys inside
51
96
  * `retry:` → `fallbackChains:`. Uses the same structural rules as the patch
52
97
  * path, so read and write cannot disagree on a block-style document; inline
53
- * blocks (`modelRoles: {}`), indented blocks and duplicate top-level blocks
54
- * are structural surprises and throw.
98
+ * flow-style blocks (`modelRoles: {}`), indented blocks and duplicate top-level
99
+ * blocks are structural surprises and throw. The empty-collection spellings a
100
+ * config writer emits — a null scalar (`disabledAgents: null`) or a flow
101
+ * collection on its own indented line (`disabledAgents:` then ` []`, ` {}`
102
+ * for an empty mapping) — are NOT surprises: they read as the empty collection.
55
103
  */
56
104
  export function parseConfig(text: string): { modelRoles: Record<string, string>; chainKeys: string[]; disabledAgents: string[] } {
57
105
  const lines = text.split("\n");
@@ -69,20 +117,18 @@ export function parseConfig(text: string): { modelRoles: Record<string, string>;
69
117
  const chainKeys: string[] = [];
70
118
  const retryIdx = findTopLevel(lines, "retry");
71
119
  if (retryIdx !== -1) {
72
- const fcIdx = findFallbackChains(lines, retryIdx);
73
- if (fcIdx !== -1) {
74
- for (const key of Object.keys(chainEntries(lines, fcIdx, blockEnd(lines, fcIdx)))) chainKeys.push(key);
120
+ const fc = findFallbackChains(lines, retryIdx);
121
+ if (fc.keyLine !== -1) {
122
+ const fcEnd = blockEnd(lines, fc.keyLine, indentOf(lines[fc.keyLine]));
123
+ for (const key of Object.keys(chainEntries(lines, fc.keyLine, fcEnd))) chainKeys.push(key);
75
124
  }
76
125
  }
77
126
  const disabledAgents: string[] = [];
78
127
  const taskIdx = findTopLevel(lines, "task");
79
128
  if (taskIdx !== -1) {
80
- const daIdx = findDisabledAgents(lines, taskIdx);
81
- if (daIdx !== -1) {
82
- for (const i of sequenceItemLines(lines, daIdx)) {
83
- const value = scalarValue(lines[i].trim().replace(/^-\s+/, ""));
84
- if (value !== null) disabledAgents.push(value);
85
- }
129
+ for (const i of disabledAgentsBody(lines, taskIdx).itemLines) {
130
+ const value = scalarValue(lines[i].trim().replace(/^-\s+/, ""));
131
+ if (value !== null) disabledAgents.push(value);
86
132
  }
87
133
  }
88
134
  return { modelRoles, chainKeys, disabledAgents };
@@ -122,10 +168,15 @@ function assertTopLevelBlockStyle(lines: string[]): void {
122
168
  }
123
169
  }
124
170
 
125
- /** First index after `start` whose line is non-empty at indent 0 (the block terminator). */
126
- function blockEnd(lines: string[], start: number): number {
171
+ /**
172
+ * First index after `start` whose line is non-empty at an indent <= `indent` (the
173
+ * block terminator): 0 for a top-level block, or the key's own indent for a nested
174
+ * one, so a sibling key of the key being read ends its block rather than landing
175
+ * inside it.
176
+ */
177
+ function blockEnd(lines: string[], start: number, indent = 0): number {
127
178
  for (let i = start + 1; i < lines.length; i++) {
128
- if (lines[i].length > 0 && indentOf(lines[i]) === 0) return i;
179
+ if (lines[i].length > 0 && indentOf(lines[i]) <= indent) return i;
129
180
  }
130
181
  return lines.length;
131
182
  }
@@ -239,8 +290,13 @@ function patchModelRoles(out: string[], roleSelectors: Record<string, string>, r
239
290
  upserts.push(` ${role}: ${quote(selector)}`);
240
291
  }
241
292
  }
242
- // Upserts go to the end of the block, before its terminator line, in role order.
243
- if (upserts.length > 0) out.splice(end, 0, ...upserts);
293
+ // Upserts go to the end of the block, before its terminator line, in role order —
294
+ // replacing a serializer-emitted empty-mapping placeholder (`modelRoles:` + ` {}`).
295
+ if (upserts.length > 0) {
296
+ const placeholder = emptyFlowLine(out, blockIdx, "{}");
297
+ if (placeholder === -1) out.splice(end, 0, ...upserts);
298
+ else out.splice(placeholder, 1, ...upserts);
299
+ }
244
300
  }
245
301
 
246
302
  type ChainEntry = { keyLine: number; itemLines: number[]; indent: number };
@@ -248,20 +304,50 @@ type ChainEntry = { keyLine: number; itemLines: number[]; indent: number };
248
304
  /** One disjoint edit on the ORIGINAL line array; applied bottom-up so positions stay valid. */
249
305
  type LineEdit = { start: number; deleteCount: number; insert: string[] };
250
306
 
251
- /** Index of the `fallbackChains:` line (indent 2) under the top-level retry: block; -1 when absent. */
252
- function findFallbackChains(lines: string[], retryIdx: number): number {
307
+ /** A managed key's line plus the line carrying an empty-collection placeholder; -1 for the latter when the body is a real block. */
308
+ type KeyBody = { keyLine: number; emptyLine: number };
309
+
310
+ /**
311
+ * The `fallbackChains:` line (indent 2) under the top-level retry: block and the
312
+ * line carrying its empty-collection placeholder when it holds no chain keys —
313
+ * `keyLine` is -1 when the key is absent. An inline flow-style value on the key
314
+ * line stays a structural surprise (the plugin writes the block form); a null
315
+ * scalar reads as an empty mapping.
316
+ */
317
+ function findFallbackChains(lines: string[], retryIdx: number): KeyBody {
253
318
  const retryEnd = blockEnd(lines, retryIdx);
254
- let fcIdx = -1;
319
+ let keyLine = -1;
255
320
  for (let i = retryIdx + 1; i < retryEnd; i++) {
256
321
  const parsed = parseMapLine(lines[i]);
257
- if (parsed && parsed.key === "fallbackChains") {
258
- if (parsed.indent !== 2) throw new ConfigEditError(`fallbackChains: expected 2-space indent under retry:, got ${parsed.indent}`);
259
- if (parsed.value.replace(/\s+#.*$/, "").trim().length > 0) throw new ConfigEditError("fallbackChains: inline value is not supported (block mapping expected)");
260
- if (fcIdx !== -1) throw new ConfigEditError("duplicate fallbackChains: block under retry:");
261
- fcIdx = i;
262
- }
322
+ if (!parsed || parsed.key !== "fallbackChains") continue;
323
+ if (parsed.indent !== 2) throw new ConfigEditError(`fallbackChains: expected 2-space indent under retry:, got ${parsed.indent}`);
324
+ if (keyLine !== -1) throw new ConfigEditError("duplicate fallbackChains: block under retry:");
325
+ keyLine = i;
326
+ }
327
+ if (keyLine === -1) return { keyLine: -1, emptyLine: -1 };
328
+ const inline = valueText(parseMapLine(lines[keyLine])!.value);
329
+ if (inline.length > 0) {
330
+ if (!isNullToken(inline)) throw new ConfigEditError("fallbackChains: inline value is not supported (block mapping expected)");
331
+ return { keyLine, emptyLine: keyLine };
332
+ }
333
+ return { keyLine, emptyLine: emptyFlowLine(lines, keyLine, "{}") };
334
+ }
335
+
336
+ /**
337
+ * Index of the line holding an empty flow collection (`[]`/`{}`) as the first
338
+ * content line of `keyLine`'s body — the spelling a YAML serializer emits for an
339
+ * empty collection — or -1 when the body starts with a real entry (or is empty).
340
+ */
341
+ function emptyFlowLine(lines: string[], keyLine: number, flow: "[]" | "{}"): number {
342
+ const keyIndent = indentOf(lines[keyLine]);
343
+ const end = blockEnd(lines, keyLine, keyIndent);
344
+ for (let i = keyLine + 1; i < end; i++) {
345
+ const line = lines[i];
346
+ if (line.trim().length === 0 || line.trim().startsWith("#")) continue;
347
+ if (indentOf(line) <= keyIndent) return -1;
348
+ return isEmptyFlow(line, flow) ? i : -1;
263
349
  }
264
- return fcIdx;
350
+ return -1;
265
351
  }
266
352
 
267
353
  /**
@@ -275,6 +361,7 @@ function chainEntries(lines: string[], fcIdx: number, fcEnd: number): Record<str
275
361
  for (let i = fcIdx + 1; i < fcEnd; i++) {
276
362
  const line = lines[i];
277
363
  if (line.trim().length === 0 || line.trim().startsWith("#")) continue;
364
+ if (current === null && isEmptyFlow(line, "{}")) continue; // serializer-emitted empty-mapping placeholder, not content
278
365
  const parsed = parseMapLine(line);
279
366
  if (parsed && parsed.indent > 2) {
280
367
  if (parsed.indent % 2 !== 0) throw new ConfigEditError(`odd indent (${parsed.indent}) in fallbackChains block`);
@@ -296,26 +383,36 @@ function patchFallbackChains(out: string[], chainUpserts: Record<string, string[
296
383
  if (upserts.length === 0 && prunes.length === 0) return;
297
384
 
298
385
  const retryIdx = findTopLevel(out, "retry");
299
- if (retryIdx === -1) {
300
- if (upserts.length === 0) return;
301
- const block: string[] = ["retry:", " fallbackChains:"];
302
- for (const [key, values] of upserts) {
386
+ const chainLines = (chainEntriesToWrite: [string, string[]][], indent: number): string[] => {
387
+ const block: string[] = [];
388
+ for (const [key, values] of chainEntriesToWrite) {
303
389
  if (values.length === 0) continue;
304
- block.push(` ${key}:`);
305
- for (const v of values) block.push(` - ${quote(v)}`);
390
+ block.push(`${" ".repeat(indent)}${key}:`);
391
+ for (const v of values) block.push(`${" ".repeat(indent + 2)}- ${quote(v)}`);
306
392
  }
393
+ return block;
394
+ };
395
+ if (retryIdx === -1) {
396
+ const block = chainLines(upserts, 4);
397
+ if (block.length === 0) return;
307
398
  const at = out.length > 0 && out[out.length - 1] === "" ? out.length - 1 : out.length;
308
- out.splice(at, 0, ...block);
399
+ out.splice(at, 0, "retry:", " fallbackChains:", ...block);
309
400
  return;
310
401
  }
311
402
 
312
- let fcIdx = findFallbackChains(out, retryIdx);
313
- if (fcIdx === -1) {
314
- out.splice(retryIdx + 1, 0, " fallbackChains:");
315
- fcIdx = retryIdx + 1;
403
+ const fc = findFallbackChains(out, retryIdx);
404
+ if (fc.keyLine === -1) {
405
+ const block = chainLines(upserts, 4);
406
+ if (block.length === 0) return; // a prune-only patch has nothing to create
407
+ // An empty retry block (`retry:` + ` {}`) becomes the fallbackChains header;
408
+ // otherwise the key is inserted first in the block, ahead of any sibling key.
409
+ const placeholder = emptyFlowLine(out, retryIdx, "{}");
410
+ if (placeholder === -1) out.splice(retryIdx + 1, 0, " fallbackChains:", ...block);
411
+ else out.splice(placeholder, 1, " fallbackChains:", ...block);
412
+ return;
316
413
  }
317
- const fcEnd = blockEnd(out, fcIdx);
318
- const entries = chainEntries(out, fcIdx, fcEnd);
414
+ const fcEnd = blockEnd(out, fc.keyLine, indentOf(out[fc.keyLine]));
415
+ const entries = chainEntries(out, fc.keyLine, fcEnd);
319
416
 
320
417
  // Collect disjoint edits on original coordinates: prunes delete; upserts replace an
321
418
  // existing key's item run (or delete the key when the new chain is empty); new keys
@@ -346,13 +443,16 @@ function patchFallbackChains(out: string[], chainUpserts: Record<string, string[
346
443
  }
347
444
  const appended = upserts.filter(([key, values]) => !(key in entries) && values.length > 0);
348
445
  if (appended.length > 0) {
349
- const siblingIndent = Object.values(entries)[0]?.indent ?? 4;
350
- const block: string[] = [];
351
- for (const [key, values] of appended) {
352
- block.push(`${" ".repeat(siblingIndent)}${key}:`);
353
- for (const v of values) block.push(`${" ".repeat(siblingIndent + 2)}- ${quote(v)}`);
446
+ const block = chainLines(appended, Object.values(entries)[0]?.indent ?? 4);
447
+ if (fc.emptyLine === -1) {
448
+ edits.push({ start: fcEnd, deleteCount: 0, insert: block });
449
+ } else if (fc.emptyLine === fc.keyLine) {
450
+ // An inline empty value becomes the block header; the chain keys follow it.
451
+ edits.push({ start: fc.emptyLine, deleteCount: 1, insert: [keyHead(out[fc.emptyLine]), ...block] });
452
+ } else {
453
+ // The serializer's `{}` placeholder line becomes the first chain key.
454
+ edits.push({ start: fc.emptyLine, deleteCount: 1, insert: block });
354
455
  }
355
- edits.push({ start: fcEnd, deleteCount: 0, insert: block });
356
456
  }
357
457
 
358
458
  for (const edit of edits.sort((a, b) => b.start - a.start)) {
@@ -360,33 +460,44 @@ function patchFallbackChains(out: string[], chainUpserts: Record<string, string[
360
460
  }
361
461
  }
362
462
 
363
- /** Index of the `disabledAgents:` line (indent 2) under the top-level task: block; -1 when absent. */
364
- function findDisabledAgents(lines: string[], taskIdx: number): number {
463
+ /** The `task.disabledAgents` key line, its `- name` item lines, and its empty-collection placeholder line; -1s when absent. */
464
+ type DisabledAgentsBody = { keyLine: number; itemLines: number[]; emptyLine: number };
465
+
466
+ /**
467
+ * The `task.disabledAgents:` line (indent 2) under the top-level task: block, its
468
+ * `- name` item lines in document order, and the line carrying its
469
+ * empty-collection placeholder when it holds no names — `keyLine` is -1 when the
470
+ * key is absent. An inline flow-style value on the key line stays a structural
471
+ * surprise (the plugin writes the block form); a null scalar reads as an empty
472
+ * sequence. Every other non-comment body line must be an item.
473
+ */
474
+ function disabledAgentsBody(lines: string[], taskIdx: number): DisabledAgentsBody {
365
475
  const taskEnd = blockEnd(lines, taskIdx);
366
- let idx = -1;
476
+ let keyLine = -1;
367
477
  for (let i = taskIdx + 1; i < taskEnd; i++) {
368
478
  const parsed = parseMapLine(lines[i]);
369
- if (parsed && parsed.key === "disabledAgents") {
370
- if (parsed.indent !== 2) throw new ConfigEditError(`disabledAgents: expected 2-space indent under task:, got ${parsed.indent}`);
371
- if (parsed.value.replace(/\s+#.*$/, "").trim().length > 0) throw new ConfigEditError("disabledAgents: inline value is not supported (block sequence expected)");
372
- if (idx !== -1) throw new ConfigEditError("duplicate disabledAgents: block under task:");
373
- idx = i;
374
- }
375
- }
376
- return idx;
377
- }
378
-
379
- /** Line indexes of the `- item` entries under a sequence key, in document order. */
380
- function sequenceItemLines(lines: string[], keyIdx: number): number[] {
381
- const items: number[] = [];
382
- for (let i = keyIdx + 1; i < lines.length; i++) {
479
+ if (!parsed || parsed.key !== "disabledAgents") continue;
480
+ if (parsed.indent !== 2) throw new ConfigEditError(`disabledAgents: expected 2-space indent under task:, got ${parsed.indent}`);
481
+ if (keyLine !== -1) throw new ConfigEditError("duplicate disabledAgents: block under task:");
482
+ keyLine = i;
483
+ }
484
+ if (keyLine === -1) return { keyLine: -1, itemLines: [], emptyLine: -1 };
485
+ const inline = valueText(parseMapLine(lines[keyLine])!.value);
486
+ if (inline.length > 0) {
487
+ if (!isNullToken(inline)) throw new ConfigEditError("disabledAgents: inline value is not supported (block sequence expected)");
488
+ return { keyLine, itemLines: [], emptyLine: keyLine };
489
+ }
490
+ const itemLines: number[] = [];
491
+ let emptyLine = -1;
492
+ for (let i = keyLine + 1; i < lines.length; i++) {
383
493
  const line = lines[i];
384
494
  if (line.trim().length === 0 || line.trim().startsWith("#")) continue;
385
495
  if (indentOf(line) <= 2) break;
386
- if (line.trim().startsWith("- ")) items.push(i);
496
+ if (line.trim().startsWith("- ")) itemLines.push(i);
497
+ else if (itemLines.length === 0 && emptyLine === -1 && isEmptyFlow(line, "[]")) emptyLine = i;
387
498
  else throw new ConfigEditError(`unexpected line in disabledAgents sequence: ${JSON.stringify(line)}`);
388
499
  }
389
- return items;
500
+ return { keyLine, itemLines, emptyLine };
390
501
  }
391
502
 
392
503
  /**
@@ -404,18 +515,22 @@ function patchDisabledAgents(out: string[], adds: string[], removes: string[]):
404
515
  out.splice(at, 0, ...block);
405
516
  return;
406
517
  }
407
- const daIdx = findDisabledAgents(out, taskIdx);
408
- if (daIdx === -1) {
518
+ const body = disabledAgentsBody(out, taskIdx);
519
+ if (body.keyLine === -1) {
409
520
  if (adds.length === 0) return;
410
- out.splice(taskIdx + 1, 0, " disabledAgents:", ...adds.map((name) => ` - ${quote(name)}`));
521
+ const block = [" disabledAgents:", ...adds.map((name) => ` - ${quote(name)}`)];
522
+ // An empty task block (`task:` + ` {}`) becomes the disabledAgents header;
523
+ // otherwise the key is inserted first in the block, ahead of any sibling key.
524
+ const placeholder = emptyFlowLine(out, taskIdx, "{}");
525
+ if (placeholder === -1) out.splice(taskIdx + 1, 0, ...block);
526
+ else out.splice(placeholder, 1, ...block);
411
527
  return;
412
528
  }
413
- const itemLines = sequenceItemLines(out, daIdx);
414
529
  const removeSet: Record<string, true> = {};
415
530
  for (const name of removes) removeSet[name] = true;
416
531
  const present: Record<string, true> = {};
417
532
  const removeLines: number[] = [];
418
- for (const i of itemLines) {
533
+ for (const i of body.itemLines) {
419
534
  const value = scalarValue(out[i].trim().replace(/^-\s+/, ""));
420
535
  if (value === null) continue;
421
536
  present[value] = true;
@@ -423,12 +538,21 @@ function patchDisabledAgents(out: string[], adds: string[], removes: string[]):
423
538
  }
424
539
  const toAdd = adds.filter((name) => !(name in present));
425
540
  if (removeLines.length === 0 && toAdd.length === 0) return;
541
+ const items = (indent: number) => toAdd.map((name) => `${" ".repeat(indent)}- ${quote(name)}`);
426
542
  const edits: LineEdit[] = [];
427
543
  for (const i of removeLines) edits.push({ start: i, deleteCount: 1, insert: [] });
428
544
  if (toAdd.length > 0) {
429
- const itemIndent = itemLines.length > 0 ? indentOf(out[itemLines[0]]) : 4;
430
- const insertAt = itemLines.length > 0 ? itemLines[itemLines.length - 1] + 1 : daIdx + 1;
431
- edits.push({ start: insertAt, deleteCount: 0, insert: toAdd.map((name) => `${" ".repeat(itemIndent)}- ${quote(name)}`) });
545
+ if (body.emptyLine === body.keyLine) {
546
+ // An inline empty value becomes the block header; the names follow it.
547
+ edits.push({ start: body.emptyLine, deleteCount: 1, insert: [keyHead(out[body.emptyLine]), ...items(4)] });
548
+ } else if (body.emptyLine !== -1) {
549
+ // The serializer's `[]` placeholder line becomes the item run, at its own indent.
550
+ edits.push({ start: body.emptyLine, deleteCount: 1, insert: items(indentOf(out[body.emptyLine])) });
551
+ } else {
552
+ const itemIndent = body.itemLines.length > 0 ? indentOf(out[body.itemLines[0]]) : 4;
553
+ const insertAt = body.itemLines.length > 0 ? body.itemLines[body.itemLines.length - 1] + 1 : body.keyLine + 1;
554
+ edits.push({ start: insertAt, deleteCount: 0, insert: items(itemIndent) });
555
+ }
432
556
  }
433
557
  for (const edit of edits.sort((a, b) => b.start - a.start)) {
434
558
  out.splice(edit.start, edit.deleteCount, ...edit.insert);
@@ -438,7 +562,9 @@ function patchDisabledAgents(out: string[], adds: string[], removes: string[]):
438
562
  /**
439
563
  * Atomic write: re-stat mtime first — a changed mtime means config.yml moved
440
564
  * underneath us and the caller must re-read and recompute ("conflict"). Writes
441
- * a temp file in the same directory and renames over the target.
565
+ * a temp file in the same directory and renames over the target. Creates the
566
+ * target's directory when missing: the project scope writes into
567
+ * `<cwd>/.omp/plugins/`, which omp does not create until something writes there.
442
568
  */
443
569
  export function writeConfigAtomic(path: string, patchedText: string, mtimeBefore: number): "written" | "conflict" {
444
570
  let mtimeNow: number | null = null;
@@ -450,6 +576,7 @@ export function writeConfigAtomic(path: string, patchedText: string, mtimeBefore
450
576
  const changed = mtimeNow === null ? mtimeBefore !== 0 : mtimeNow !== mtimeBefore;
451
577
  if (changed) return "conflict";
452
578
  const tmp = `${path}.llm-role-tmp`;
579
+ mkdirSync(dirname(path), { recursive: true });
453
580
  writeFileSync(tmp, patchedText);
454
581
  renameSync(tmp, path);
455
582
  return "written";