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.
- package/README.md +202 -12
- package/agents/designer.md +4 -3
- package/docs/dev/README.md +44 -7
- package/docs/dev/agent-authoring.md +59 -1
- package/docs/dev/architecture.md +106 -28
- package/docs/dev/ci.md +163 -0
- package/docs/dev/current-state.md +364 -24
- package/docs/dev/data-sources.md +231 -38
- package/docs/dev/explorer.md +175 -46
- package/docs/dev/quirks.md +77 -4
- package/docs/dev/releasing.md +130 -17
- package/docs/dev/scoring.md +851 -27
- package/docs/dev/testing.md +253 -0
- package/docs/dev/writes-and-state.md +232 -28
- package/docs/llm-role-rankings.md +149 -148
- package/package.json +814 -80
- package/skills/omp-llm-role-create-agent/SKILL.md +7 -1
- package/src/agent-architect.ts +148 -19
- package/src/agent-create.ts +800 -40
- package/src/agent-file.ts +2 -5
- package/src/architect-provenance.ts +16 -0
- package/src/availability.ts +191 -15
- package/src/benchmark-author.ts +9 -4
- package/src/benchmark-sources.ts +283 -18
- package/src/cli/create-role.ts +29 -1
- package/src/cli/llm-role-rank.ts +40 -1
- package/src/config-edit.ts +207 -80
- package/src/engine.ts +519 -75
- package/src/error-report.ts +562 -0
- package/src/explorer/boot.ts +36 -24
- package/src/explorer/explain.ts +128 -13
- package/src/explorer/scopes.ts +110 -0
- package/src/explorer/server.ts +126 -33
- package/src/extension.ts +464 -48
- package/src/features.ts +105 -0
- package/src/project-registry.ts +65 -0
- package/src/project-setup.ts +382 -0
- package/src/prompts/agent-creation-architect.md +2 -0
- package/src/prompts/project-profile-architect.md +33 -0
- package/src/prompts/project-profile-user.md +8 -0
- package/src/provider-display.ts +67 -0
- package/src/role-archetypes.ts +12 -2
- package/src/role-settings.ts +22 -0
- package/src/settings.ts +211 -24
- package/src/state.ts +1 -1
- package/src/updater.ts +234 -37
- package/web/app.js +434 -12
- package/web/index.html +1 -0
- package/web/style.css +44 -1
- package/docs/dev/spec.md +0 -633
package/src/config-edit.ts
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Surgical YAML editing for the omp agent config
|
|
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
|
|
6
|
-
* inside `retry:` → `fallbackChains:`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
|
73
|
-
if (
|
|
74
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
/**
|
|
126
|
-
|
|
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])
|
|
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
|
-
|
|
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
|
-
/**
|
|
252
|
-
|
|
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
|
|
319
|
+
let keyLine = -1;
|
|
255
320
|
for (let i = retryIdx + 1; i < retryEnd; i++) {
|
|
256
321
|
const parsed = parseMapLine(lines[i]);
|
|
257
|
-
if (parsed
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
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(
|
|
305
|
-
for (const v of values) block.push(
|
|
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
|
-
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
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,
|
|
318
|
-
const entries = chainEntries(out,
|
|
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
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
/**
|
|
364
|
-
|
|
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
|
|
476
|
+
let keyLine = -1;
|
|
367
477
|
for (let i = taskIdx + 1; i < taskEnd; i++) {
|
|
368
478
|
const parsed = parseMapLine(lines[i]);
|
|
369
|
-
if (parsed
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
for (let 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("- "))
|
|
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
|
|
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
|
|
408
|
-
if (
|
|
518
|
+
const body = disabledAgentsBody(out, taskIdx);
|
|
519
|
+
if (body.keyLine === -1) {
|
|
409
520
|
if (adds.length === 0) return;
|
|
410
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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";
|