loreli 0.0.0 → 2.0.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 (104) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +710 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +77 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/prompts/action.md +172 -0
  8. package/packages/action/src/index.js +684 -0
  9. package/packages/agent/README.md +606 -0
  10. package/packages/agent/src/backends/claude.js +387 -0
  11. package/packages/agent/src/backends/codex.js +351 -0
  12. package/packages/agent/src/backends/cursor.js +371 -0
  13. package/packages/agent/src/backends/index.js +486 -0
  14. package/packages/agent/src/base.js +138 -0
  15. package/packages/agent/src/cli.js +275 -0
  16. package/packages/agent/src/discover.js +396 -0
  17. package/packages/agent/src/factory.js +124 -0
  18. package/packages/agent/src/index.js +12 -0
  19. package/packages/agent/src/models.js +159 -0
  20. package/packages/agent/src/output.js +62 -0
  21. package/packages/agent/src/session.js +162 -0
  22. package/packages/agent/src/trace.js +186 -0
  23. package/packages/classify/README.md +136 -0
  24. package/packages/classify/prompts/blocker.md +12 -0
  25. package/packages/classify/prompts/feedback.md +14 -0
  26. package/packages/classify/prompts/pane-state.md +20 -0
  27. package/packages/classify/src/index.js +81 -0
  28. package/packages/config/README.md +898 -0
  29. package/packages/config/src/defaults.js +145 -0
  30. package/packages/config/src/index.js +223 -0
  31. package/packages/config/src/schema.js +291 -0
  32. package/packages/config/src/validate.js +160 -0
  33. package/packages/context/README.md +165 -0
  34. package/packages/context/src/index.js +198 -0
  35. package/packages/hub/README.md +338 -0
  36. package/packages/hub/src/base.js +154 -0
  37. package/packages/hub/src/github.js +1597 -0
  38. package/packages/hub/src/index.js +79 -0
  39. package/packages/hub/src/labels.js +48 -0
  40. package/packages/identity/README.md +288 -0
  41. package/packages/identity/src/index.js +620 -0
  42. package/packages/identity/src/themes/avatar.js +217 -0
  43. package/packages/identity/src/themes/digimon.js +217 -0
  44. package/packages/identity/src/themes/dragonball.js +217 -0
  45. package/packages/identity/src/themes/lotr.js +217 -0
  46. package/packages/identity/src/themes/marvel.js +217 -0
  47. package/packages/identity/src/themes/pokemon.js +217 -0
  48. package/packages/identity/src/themes/starwars.js +217 -0
  49. package/packages/identity/src/themes/transformers.js +217 -0
  50. package/packages/identity/src/themes/zelda.js +217 -0
  51. package/packages/knowledge/README.md +217 -0
  52. package/packages/knowledge/src/index.js +243 -0
  53. package/packages/log/README.md +93 -0
  54. package/packages/log/src/index.js +252 -0
  55. package/packages/marker/README.md +200 -0
  56. package/packages/marker/src/index.js +184 -0
  57. package/packages/mcp/README.md +323 -0
  58. package/packages/mcp/instructions.md +126 -0
  59. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  60. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  61. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  62. package/packages/mcp/scaffolding/loreli.yml +491 -0
  63. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
  64. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
  65. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
  66. package/packages/mcp/scaffolding/pull-request.md +23 -0
  67. package/packages/mcp/src/index.js +600 -0
  68. package/packages/mcp/src/tools/agent-context.js +44 -0
  69. package/packages/mcp/src/tools/agents.js +450 -0
  70. package/packages/mcp/src/tools/context.js +200 -0
  71. package/packages/mcp/src/tools/github.js +1163 -0
  72. package/packages/mcp/src/tools/hitl.js +162 -0
  73. package/packages/mcp/src/tools/index.js +18 -0
  74. package/packages/mcp/src/tools/refactor.js +227 -0
  75. package/packages/mcp/src/tools/repo.js +44 -0
  76. package/packages/mcp/src/tools/start.js +904 -0
  77. package/packages/mcp/src/tools/status.js +149 -0
  78. package/packages/mcp/src/tools/work.js +134 -0
  79. package/packages/orchestrator/README.md +192 -0
  80. package/packages/orchestrator/src/index.js +1492 -0
  81. package/packages/planner/README.md +251 -0
  82. package/packages/planner/prompts/plan-reviewer.md +109 -0
  83. package/packages/planner/prompts/planner.md +191 -0
  84. package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
  85. package/packages/planner/src/index.js +1381 -0
  86. package/packages/review/README.md +129 -0
  87. package/packages/review/prompts/reviewer.md +158 -0
  88. package/packages/review/src/index.js +1403 -0
  89. package/packages/risk/README.md +178 -0
  90. package/packages/risk/prompts/risk.md +272 -0
  91. package/packages/risk/src/index.js +439 -0
  92. package/packages/session/README.md +165 -0
  93. package/packages/session/src/index.js +215 -0
  94. package/packages/test-utils/README.md +96 -0
  95. package/packages/test-utils/src/index.js +354 -0
  96. package/packages/tmux/README.md +261 -0
  97. package/packages/tmux/src/index.js +501 -0
  98. package/packages/workflow/README.md +317 -0
  99. package/packages/workflow/prompts/preamble.md +14 -0
  100. package/packages/workflow/src/index.js +660 -0
  101. package/packages/workflow/src/proof-of-life.js +74 -0
  102. package/packages/workspace/README.md +143 -0
  103. package/packages/workspace/src/index.js +1127 -0
  104. package/index.js +0 -8
@@ -0,0 +1,620 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { mark, has, stripLast } from 'loreli/marker';
5
+ import transformers from './themes/transformers.js';
6
+ import pokemon from './themes/pokemon.js';
7
+ import marvel from './themes/marvel.js';
8
+ import digimon from './themes/digimon.js';
9
+ import starwars from './themes/starwars.js';
10
+ import lotr from './themes/lotr.js';
11
+ import dragonball from './themes/dragonball.js';
12
+ import avatar from './themes/avatar.js';
13
+ import zelda from './themes/zelda.js';
14
+ /**
15
+ * Infer the AI provider from a model identifier string.
16
+ *
17
+ * Uses prefix-based heuristics for known providers. Falls back to
18
+ * 'unknown' for unrecognized models.
19
+ *
20
+ * @param {string} model - Full model identifier.
21
+ * @returns {string} Provider name ('anthropic', 'openai', or 'unknown').
22
+ */
23
+ export function infer(model) {
24
+ if (!model) return 'unknown';
25
+ const lower = model.toLowerCase();
26
+ if (lower.startsWith('claude')) return 'anthropic';
27
+ if (lower.startsWith('gpt') || lower.startsWith('o1') || lower.startsWith('o3') || lower.startsWith('o4')) return 'openai';
28
+ return 'unknown';
29
+ }
30
+
31
+ /**
32
+ * Convert a full API model identifier to a human-readable display name.
33
+ *
34
+ * Strips trailing date suffixes (e.g. `-20250514`) that versioned model
35
+ * IDs carry. Returns the input unchanged when no date suffix is found.
36
+ *
37
+ * @param {string} model - Full model identifier.
38
+ * @returns {string} Human-readable model name.
39
+ */
40
+ export function display(model) {
41
+ return model.replace(/-\d{8}$/, '');
42
+ }
43
+
44
+ /**
45
+ * @typedef {object} FactionData
46
+ * @property {string} faction - Faction name.
47
+ * @property {string[]} names - Character names.
48
+ * @property {string[]} signoffs - Themed sign-off messages.
49
+ * @property {string[]} claims - Themed claim messages (use `{name}` placeholder).
50
+ * @property {string[]} approvals - Themed approval messages (use `{name}` placeholder).
51
+ * @property {string[]} releases - Themed release messages (use `{name}` placeholder).
52
+ * @property {string[]} hitl - Themed Human In The Loop messages (use `{mentions}` placeholder).
53
+ * @property {string[]} promotions - Themed promotion messages (use `{name}` and `{number}` placeholders).
54
+ */
55
+
56
+ /**
57
+ * @typedef {object} CouncilData
58
+ * @property {string} name - The authority's name.
59
+ * @property {string[]} proofOfLife - Themed proof-of-life messages (use `{agent}` placeholder).
60
+ * @property {string[]} [signoffs] - Sign-off messages for council-level comments.
61
+ */
62
+
63
+ /**
64
+ * @typedef {{yang: FactionData, yin: FactionData, council?: CouncilData}} ThemeData
65
+ */
66
+
67
+ /**
68
+ * Read the loreli version from the root package.json.
69
+ * Cached after first read to avoid repeated fs access.
70
+ *
71
+ * @returns {string} The version string.
72
+ */
73
+ let cachedVersion = null;
74
+ function version() {
75
+ if (cachedVersion) return cachedVersion;
76
+ try {
77
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
78
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
79
+ cachedVersion = pkg.version ?? '0.0.0';
80
+ } catch {
81
+ cachedVersion = '0.0.0';
82
+ }
83
+ return cachedVersion;
84
+ }
85
+
86
+ /**
87
+ * All supported themes indexed by name.
88
+ *
89
+ * @type {Record<string, ThemeData>}
90
+ */
91
+ const themes = { transformers, pokemon, marvel, digimon, starwars, lotr, dragonball, avatar, zelda };
92
+
93
+ /**
94
+ * Explicit provider-to-side mapping for known providers.
95
+ *
96
+ * The `cursor-*` virtual providers represent cursor-agent running a
97
+ * specific vendor's models. They inherit the side of their vendor so
98
+ * adversarial pairing works naturally even in cursor-only environments.
99
+ *
100
+ * @type {Record<string, 'yang'|'yin'>}
101
+ */
102
+ const sides = {
103
+ openai: 'yang',
104
+ anthropic: 'yin',
105
+ 'cursor-openai': 'yang',
106
+ 'cursor-anthropic': 'yin'
107
+ };
108
+
109
+ /**
110
+ * Resolve the yin/yang side for a provider. Known providers use explicit
111
+ * mappings; unknown providers get a deterministic side via char-code hash.
112
+ *
113
+ * @param {string} provider - AI provider name.
114
+ * @returns {'yang'|'yin'} The resolved side.
115
+ */
116
+ export function side(provider) {
117
+ if (sides[provider]) return sides[provider];
118
+ const sum = [...provider].reduce(function hash(acc, ch) { return acc + ch.charCodeAt(0); }, 0);
119
+ return sum % 2 === 0 ? 'yang' : 'yin';
120
+ }
121
+
122
+ /**
123
+ * Compute side-capability metadata for a discovered provider list.
124
+ *
125
+ * Loreli's orchestration policies depend on whether both sides are
126
+ * present (dual-side adversarial mode) or only one side is available
127
+ * (single-side fresh-instance mode).
128
+ *
129
+ * @param {string[]} providers - Provider names from backend discovery.
130
+ * @returns {{sides: Array<'yang'|'yin'>, count: number, mode: 'none'|'single'|'dual', hasDual: boolean}}
131
+ */
132
+ export function capability(providers) {
133
+ const seen = new Set();
134
+ for (const provider of providers ?? []) {
135
+ if (!provider) continue;
136
+ seen.add(side(provider));
137
+ }
138
+
139
+ const sides = [...seen];
140
+ const count = sides.length;
141
+ const mode = count >= 2 ? 'dual' : (count === 1 ? 'single' : 'none');
142
+ return { sides, count, mode, hasDual: mode === 'dual' };
143
+ }
144
+
145
+ /**
146
+ * Normalize a provider name to its canonical vendor.
147
+ *
148
+ * Virtual providers like `cursor-anthropic` and `cursor-openai` are
149
+ * wrappers around cursor-agent that carry vendor identity for pairing.
150
+ * This function strips the `cursor-` prefix so model resolution and
151
+ * config lookups use the canonical vendor key (`anthropic`, `openai`).
152
+ *
153
+ * Native providers pass through unchanged.
154
+ *
155
+ * @param {string} provider - Provider name (e.g. 'cursor-openai', 'anthropic').
156
+ * @returns {string} Canonical vendor name (e.g. 'openai', 'anthropic').
157
+ */
158
+ export function vendor(provider) {
159
+ if (provider?.startsWith('cursor-')) return provider.slice(7);
160
+ return provider;
161
+ }
162
+
163
+ /**
164
+ * Select a single theme from a config value that may be a string or array.
165
+ *
166
+ * When given an array, picks one element at random. When given a string,
167
+ * returns it directly. Falls back to `'transformers'` for nullish input.
168
+ *
169
+ * @param {string|string[]} value - Theme config value (string or array of theme names).
170
+ * @returns {string} A single theme name.
171
+ */
172
+ export function pick(value) {
173
+ if (Array.isArray(value)) {
174
+ return value[Math.floor(Math.random() * value.length)];
175
+ }
176
+ return value ?? 'transformers';
177
+ }
178
+
179
+ /**
180
+ * Get the council authority name for a given theme.
181
+ *
182
+ * @param {string} theme - Theme name (e.g. 'transformers').
183
+ * @returns {string} Council name (e.g. 'The Allspark'), or 'Council' as fallback.
184
+ */
185
+ export function council(theme) {
186
+ return themes[theme]?.council?.name ?? 'Council';
187
+ }
188
+
189
+ /**
190
+ * Reverse mapping: given a provider, return the opposing provider.
191
+ *
192
+ * Native CLI providers oppose each other directly. The `cursor-*`
193
+ * virtual providers form their own pair so cursor-only environments
194
+ * still get adversarial review (e.g. cursor-agent on Claude reviews
195
+ * cursor-agent on GPT).
196
+ *
197
+ * @type {Record<string, string>}
198
+ */
199
+ const opposites = {
200
+ openai: 'anthropic',
201
+ anthropic: 'openai',
202
+ 'cursor-openai': 'cursor-anthropic',
203
+ 'cursor-anthropic': 'cursor-openai'
204
+ };
205
+
206
+ /**
207
+ * Represents a unique, themed agent identity.
208
+ *
209
+ * Names follow `{character}-{instance}` format where instance increments
210
+ * per reuse (e.g. optimus-0, optimus-1).
211
+ */
212
+ export class Identity {
213
+ /**
214
+ * @param {object} opts
215
+ * @param {string} opts.name - Character name (e.g. 'optimus').
216
+ * @param {number} [opts.instance] - Instance counter. Omit for singleton identities (e.g. council).
217
+ * @param {string} opts.faction - Faction name (e.g. 'autobots').
218
+ * @param {string} opts.provider - AI provider ('openai' | 'anthropic').
219
+ * @param {string} opts.model - Model identifier.
220
+ * @param {string} opts.theme - Theme name.
221
+ * @param {boolean} [opts.council] - Council identity — signoffs and proofOfLife read from the theme's council section.
222
+ */
223
+ constructor({ name, instance, faction, provider, model, theme, council }) {
224
+ /** @type {string} The raw character name without instance suffix. */
225
+ this.character = name;
226
+
227
+ /** @type {number|undefined} Instance counter for this character. */
228
+ this.instance = instance;
229
+
230
+ /** @type {string} Full identity name: `{character}-{instance}` or just `{character}` for council. */
231
+ this.name = instance != null ? `${name}-${instance}` : name;
232
+
233
+ /** @type {string} Faction name (e.g. 'autobots', 'decepticons', or council name). */
234
+ this.faction = faction;
235
+
236
+ /** @type {boolean} Whether this is a council-level identity. */
237
+ this.council = council ?? false;
238
+
239
+ /** @type {string} AI provider ('openai' | 'anthropic'). */
240
+ this.provider = provider;
241
+
242
+ /** @type {string} Model identifier. */
243
+ this.model = model ?? '';
244
+
245
+ /** @type {string} Theme name. */
246
+ this.theme = theme;
247
+ }
248
+
249
+ /**
250
+ * Serialize for JSON storage.
251
+ *
252
+ * @returns {object} Plain object representation.
253
+ */
254
+ toJSON() {
255
+ const json = {
256
+ name: this.name,
257
+ character: this.character,
258
+ instance: this.instance,
259
+ faction: this.faction,
260
+ provider: this.provider,
261
+ model: this.model,
262
+ theme: this.theme
263
+ };
264
+ if (this.council) json.council = true;
265
+ return json;
266
+ }
267
+
268
+ /**
269
+ * Generate GitHub labels that identify this agent.
270
+ *
271
+ * All labels are namespaced under `loreli:` to avoid collisions
272
+ * with existing repository labels. Model names are converted to
273
+ * human-readable display names (e.g. 'claude-sonnet-4' instead of
274
+ * 'claude-sonnet-4-20250514').
275
+ *
276
+ * @param {string} [role] - Agent role to include as label.
277
+ * @returns {string[]} Label names for GitHub issues/PRs.
278
+ */
279
+ labels(role) {
280
+ const result = ['loreli', `loreli:${this.provider}`];
281
+ if (this.model) result.push(`loreli:${display(this.model)}`);
282
+ if (role) result.push(`loreli:${role}`);
283
+ return result;
284
+ }
285
+
286
+ /**
287
+ * Pick a random sign-off message for this identity's faction.
288
+ *
289
+ * @returns {string} A themed sign-off message.
290
+ */
291
+ signoff() {
292
+ const data = themes[this.theme];
293
+ if (!data) return `— **${this.name}**`;
294
+
295
+ if (this.council) {
296
+ const arr = data.council?.signoffs;
297
+ if (!arr?.length) return `— **${this.name}**`;
298
+ return arr[Math.floor(Math.random() * arr.length)];
299
+ }
300
+
301
+ const faction = data[side(this.provider)];
302
+ if (!faction?.signoffs?.length) return `— **${this.name}**`;
303
+
304
+ const index = Math.floor(Math.random() * faction.signoffs.length);
305
+ return faction.signoffs[index];
306
+ }
307
+
308
+ /**
309
+ * Pick a random themed claim message for this identity's faction.
310
+ * The `{name}` placeholder in the theme data is replaced with the
311
+ * agent's full name.
312
+ *
313
+ * @returns {string} A themed claim message.
314
+ */
315
+ claim() {
316
+ return this._themed('claims', `Claimed by **${this.name}**`);
317
+ }
318
+
319
+ /**
320
+ * Pick a random themed approval message for this identity's faction.
321
+ *
322
+ * @returns {string} A themed approval message.
323
+ */
324
+ approval() {
325
+ return this._themed('approvals', `Approved by **${this.name}**`);
326
+ }
327
+
328
+ /**
329
+ * Pick a random themed release message for this identity's faction.
330
+ *
331
+ * @returns {string} A themed release message.
332
+ */
333
+ release() {
334
+ return this._themed('releases', `**Released** — agent \`${this.name}\` was terminated. This issue is available for re-claim.`);
335
+ }
336
+
337
+ /**
338
+ * Pick a random themed promotion message for this identity's faction.
339
+ * Replaces `{name}` and `{number}` placeholders with the agent's full
340
+ * name and the new issue number. GitHub auto-links `#N` references,
341
+ * so no explicit URL is needed.
342
+ *
343
+ * @param {number} number - The promoted issue number.
344
+ * @returns {string} A themed promotion message.
345
+ */
346
+ promote(number) {
347
+ const data = themes[this.theme];
348
+ const faction = data?.[side(this.provider)];
349
+ const arr = faction?.promotions;
350
+
351
+ if (!arr?.length) return `Promoted to issue #${number}`;
352
+
353
+ const index = Math.floor(Math.random() * arr.length);
354
+ return arr[index]
355
+ .replace('{name}', this.name)
356
+ .replace('{number}', `#${number}`);
357
+ }
358
+
359
+ /**
360
+ * Pick a random themed Human In The Loop message for this identity's faction.
361
+ * The `{mentions}` placeholder is replaced with the provided reviewer
362
+ * mentions string. The `**Human review required**` header is always
363
+ * preserved for human readability.
364
+ *
365
+ * @param {string} mentions - Reviewer mentions (e.g. '@alice, @bob').
366
+ * @returns {string} A themed HITL message with header and mentions.
367
+ */
368
+ hitl(mentions) {
369
+ const data = themes[this.theme];
370
+ const faction = data?.[side(this.provider)];
371
+ const arr = faction?.hitl;
372
+
373
+ if (!arr?.length) {
374
+ return `**Human review required**\n\nAgent review is complete. ${mentions} — please review and merge when satisfied.`;
375
+ }
376
+
377
+ const index = Math.floor(Math.random() * arr.length);
378
+ return arr[index].replace('{mentions}', mentions).replace('{name}', this.name);
379
+ }
380
+
381
+ /**
382
+ * Pick a random themed proof-of-life request message from the
383
+ * council (central authority) section of the theme.
384
+ *
385
+ * Reads from `council.proofOfLife` — not from a faction — because
386
+ * proof-of-life is an orchestrator-level action that transcends
387
+ * the yang/yin split.
388
+ *
389
+ * @param {string} agent - Name of the agent being checked.
390
+ * @returns {string} A themed proof-of-life request message.
391
+ */
392
+ proofOfLife(agent) {
393
+ const data = themes[this.theme];
394
+ const arr = data?.council?.proofOfLife;
395
+ if (!arr?.length) return `Requesting proof of life from **${agent}**`;
396
+ const index = Math.floor(Math.random() * arr.length);
397
+ return arr[index].replace('{agent}', agent);
398
+ }
399
+
400
+ /**
401
+ * Internal helper that picks a random message from a themed array,
402
+ * replacing the `{name}` placeholder with the agent's full name.
403
+ *
404
+ * @param {string} key - Theme data array key ('claims', 'approvals', 'releases').
405
+ * @param {string} fallback - Fallback message when theme data is missing.
406
+ * @returns {string} The resolved themed message.
407
+ */
408
+ _themed(key, fallback) {
409
+ const data = themes[this.theme];
410
+ const faction = data?.[side(this.provider)];
411
+ const arr = faction?.[key];
412
+
413
+ if (!arr?.length) return fallback;
414
+
415
+ const index = Math.floor(Math.random() * arr.length);
416
+ return arr[index].replace('{name}', this.name);
417
+ }
418
+
419
+ /**
420
+ * Remove the trailing Loreli signature block from a body string.
421
+ * Detects the machine-readable `loreli:signature` marker.
422
+ *
423
+ * @param {string} body - Content that may end with a signature.
424
+ * @returns {string} Content with trailing signature removed.
425
+ */
426
+ static strip(body) {
427
+ if (has(body, 'signature')) return stripLast(body, 'signature');
428
+ return body;
429
+ }
430
+
431
+ /**
432
+ * Generate a full GitHub signature block for this agent.
433
+ *
434
+ * Format:
435
+ * ```
436
+ * ***
437
+ *
438
+ * {signoff} — **{name}**
439
+ *
440
+ * | Agent | Model | Provider | Faction | Role | Version |
441
+ * |-------|-------|----------|---------|------|---------|
442
+ * | {name} | {displayModel} | {provider} | {faction} | {role} | `loreli@{version}` |
443
+ * ```
444
+ *
445
+ * @param {string} role - The agent's current role.
446
+ * @returns {string} Markdown signature block.
447
+ */
448
+ signature(role) {
449
+ const model = this.model ? display(this.model) : 'unknown';
450
+ const ver = version();
451
+ const msg = this.signoff();
452
+
453
+ return [
454
+ '',
455
+ mark('signature'),
456
+ '',
457
+ '***',
458
+ '',
459
+ `${msg} — **${this.name}**`,
460
+ '',
461
+ '| Agent | Model | Provider | Faction | Role | Version |',
462
+ '|-------|-------|----------|---------|------|---------|',
463
+ `| ${this.name} | ${model} | ${this.provider} | ${this.faction} | ${role} | \`loreli@${ver}\` |`
464
+ ].join('\n');
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Tracks active/inactive agent names and manages identity assignment.
470
+ *
471
+ * Each Registry instance maintains its own state, making it safe to
472
+ * create per-session registries.
473
+ */
474
+ export class Registry {
475
+ constructor() {
476
+ /**
477
+ * Active identities keyed by their full name.
478
+ * @type {Map<string, Identity>}
479
+ */
480
+ this.identities = new Map();
481
+
482
+ /**
483
+ * Tracks which character names have been used per theme+side,
484
+ * and how many times (for instance numbering).
485
+ * Key: `${theme}:${side}:${character}`, Value: use count.
486
+ * @type {Map<string, number>}
487
+ */
488
+ this.usage = new Map();
489
+
490
+ /**
491
+ * Index of the next character to assign per theme+side.
492
+ * Key: `${theme}:${side}`, Value: index into the names array.
493
+ * @type {Map<string, number>}
494
+ */
495
+ this.cursors = new Map();
496
+ }
497
+
498
+ /**
499
+ * Acquire a new identity for a given theme and provider.
500
+ *
501
+ * Characters are assigned round-robin. When all characters have
502
+ * been used once, the instance counter increments and the cycle
503
+ * starts again (e.g. optimus-0, then optimus-1).
504
+ *
505
+ * When a `taken` set is provided, names already claimed by other
506
+ * participants (discovered from GitHub claim comments and PR
507
+ * branches) are skipped. This prevents identity collisions in a
508
+ * distributed multi-participant setup at zero API cost — the
509
+ * taken set is populated as a side effect of data the reactor
510
+ * already fetches.
511
+ *
512
+ * @param {string} theme - Theme name ('transformers', 'pokemon', etc.).
513
+ * @param {string} provider - AI provider ('openai' | 'anthropic').
514
+ * @param {string} [model] - Model identifier.
515
+ * @param {Set<string>} [taken] - Names already in use by other participants.
516
+ * @returns {Identity} The assigned identity.
517
+ * @throws {Error} If theme or provider is unknown.
518
+ */
519
+ acquire(theme, provider, model, taken) {
520
+ const data = themes[theme];
521
+ if (!data) throw new Error(`Unknown theme: "${theme}". Valid: ${Object.keys(themes).join(', ')}`);
522
+
523
+ const resolved = side(provider);
524
+ const { faction, names } = data[resolved];
525
+ const cursorKey = `${theme}:${resolved}`;
526
+ let cursor = this.cursors.get(cursorKey) ?? 0;
527
+
528
+ // Try characters round-robin, skipping names taken by other
529
+ // participants. Each skip advances the cursor and bumps the
530
+ // character's usage so its next candidate gets a higher instance.
531
+ const maxAttempts = names.length * 10;
532
+ let character, instance;
533
+
534
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
535
+ const charIndex = cursor % names.length;
536
+ character = names[charIndex];
537
+
538
+ const usageKey = `${theme}:${resolved}:${character}`;
539
+ instance = this.usage.get(usageKey) ?? 0;
540
+ const candidate = `${character}-${instance}`;
541
+
542
+ if (!taken?.has(candidate)) {
543
+ this.usage.set(usageKey, instance + 1);
544
+ this.cursors.set(cursorKey, cursor + 1);
545
+ break;
546
+ }
547
+
548
+ // Name taken externally — bump usage so next attempt at this
549
+ // character gets a higher instance, and advance cursor to try
550
+ // a different character first.
551
+ this.usage.set(usageKey, instance + 1);
552
+ cursor++;
553
+ }
554
+
555
+ const identity = new Identity({
556
+ name: character,
557
+ instance,
558
+ faction,
559
+ provider,
560
+ model,
561
+ theme
562
+ });
563
+
564
+ this.identities.set(identity.name, identity);
565
+ return identity;
566
+ }
567
+
568
+ /**
569
+ * Release an identity back to the pool.
570
+ *
571
+ * @param {Identity} identity - The identity to release.
572
+ */
573
+ release(identity) {
574
+ this.identities.delete(identity.name);
575
+ }
576
+
577
+ /**
578
+ * Get all currently active identities.
579
+ *
580
+ * @returns {Identity[]} Array of active identities.
581
+ */
582
+ active() {
583
+ return [...this.identities.values()];
584
+ }
585
+
586
+ /**
587
+ * Resolve the opposing provider and faction for yin/yang pairing.
588
+ *
589
+ * @param {Identity} identity - The identity to find the opposite for.
590
+ * @returns {{provider: string, faction: string}} Opposing provider and faction.
591
+ */
592
+ opposite(identity) {
593
+ const oppProvider = opposites[identity.provider];
594
+ const oppSide = side(oppProvider);
595
+ const data = themes[identity.theme];
596
+ const { faction } = data[oppSide];
597
+ return { provider: oppProvider, faction };
598
+ }
599
+
600
+ /**
601
+ * Find an opposing-side match from a list of candidates.
602
+ *
603
+ * The core design requires cross-provider adversarial review — yin
604
+ * agents review yang work and vice versa. Matching by side instead
605
+ * of exact provider means `cursor-anthropic` naturally pairs with
606
+ * `openai` or `cursor-openai` — any agent on the opposite side works.
607
+ *
608
+ * @param {Identity} identity - The identity to find an opposite for.
609
+ * @param {Array<{identity: Identity}>} candidates - Objects with an identity property.
610
+ * @returns {object|null} The matching candidate, or null if none found.
611
+ */
612
+ pair(identity, candidates) {
613
+ if (!candidates.length) return null;
614
+
615
+ const mySide = side(identity.provider);
616
+ return candidates.find(function match(c) {
617
+ return side(c.identity?.provider) !== mySide;
618
+ }) ?? null;
619
+ }
620
+ }