loreli 1.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 (63) hide show
  1. package/README.md +66 -26
  2. package/package.json +17 -14
  3. package/packages/action/prompts/action.md +172 -0
  4. package/packages/action/src/index.js +33 -5
  5. package/packages/agent/README.md +107 -18
  6. package/packages/agent/src/backends/claude.js +111 -11
  7. package/packages/agent/src/backends/codex.js +78 -5
  8. package/packages/agent/src/backends/cursor.js +104 -27
  9. package/packages/agent/src/backends/index.js +162 -5
  10. package/packages/agent/src/cli.js +80 -3
  11. package/packages/agent/src/discover.js +396 -0
  12. package/packages/agent/src/factory.js +39 -34
  13. package/packages/agent/src/models.js +24 -6
  14. package/packages/classify/README.md +136 -0
  15. package/packages/classify/prompts/blocker.md +12 -0
  16. package/packages/classify/prompts/feedback.md +14 -0
  17. package/packages/classify/prompts/pane-state.md +20 -0
  18. package/packages/classify/src/index.js +81 -0
  19. package/packages/config/README.md +156 -91
  20. package/packages/config/src/defaults.js +32 -21
  21. package/packages/config/src/index.js +33 -2
  22. package/packages/config/src/schema.js +57 -39
  23. package/packages/hub/src/github.js +59 -20
  24. package/packages/identity/README.md +1 -1
  25. package/packages/identity/src/index.js +2 -2
  26. package/packages/knowledge/README.md +86 -106
  27. package/packages/knowledge/src/index.js +56 -225
  28. package/packages/mcp/README.md +51 -7
  29. package/packages/mcp/instructions.md +6 -1
  30. package/packages/mcp/scaffolding/loreli.yml +115 -77
  31. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +1 -0
  32. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +4 -1
  33. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +4 -1
  34. package/packages/mcp/src/index.js +45 -16
  35. package/packages/mcp/src/tools/agent-context.js +44 -0
  36. package/packages/mcp/src/tools/agents.js +34 -13
  37. package/packages/mcp/src/tools/context.js +3 -2
  38. package/packages/mcp/src/tools/github.js +11 -47
  39. package/packages/mcp/src/tools/hitl.js +19 -6
  40. package/packages/mcp/src/tools/index.js +2 -1
  41. package/packages/mcp/src/tools/refactor.js +227 -0
  42. package/packages/mcp/src/tools/repo.js +44 -0
  43. package/packages/mcp/src/tools/start.js +159 -90
  44. package/packages/mcp/src/tools/status.js +5 -2
  45. package/packages/mcp/src/tools/work.js +18 -8
  46. package/packages/orchestrator/src/index.js +345 -79
  47. package/packages/planner/README.md +84 -1
  48. package/packages/planner/prompts/plan-reviewer.md +109 -0
  49. package/packages/planner/prompts/planner.md +191 -0
  50. package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
  51. package/packages/planner/src/index.js +326 -111
  52. package/packages/review/README.md +2 -2
  53. package/packages/review/prompts/reviewer.md +158 -0
  54. package/packages/review/src/index.js +196 -76
  55. package/packages/risk/README.md +81 -22
  56. package/packages/risk/prompts/risk.md +272 -0
  57. package/packages/risk/src/index.js +44 -33
  58. package/packages/tmux/src/index.js +61 -12
  59. package/packages/workflow/README.md +18 -14
  60. package/packages/workflow/prompts/preamble.md +14 -0
  61. package/packages/workflow/src/index.js +191 -12
  62. package/packages/workspace/README.md +2 -2
  63. package/packages/workspace/src/index.js +69 -18
@@ -9,6 +9,7 @@ import { ENTRY, CODEX_TOML, prune as pruneWorkspaces } from 'loreli/workspace';
9
9
  import { Tmux } from 'loreli/tmux';
10
10
  import { responder } from 'loreli/workflow';
11
11
  import { capability } from 'loreli/identity';
12
+ import { select } from './repo.js';
12
13
 
13
14
  const log = logger('start');
14
15
 
@@ -53,8 +54,7 @@ export default {
53
54
  type: 'array', items: { type: 'string' },
54
55
  description: 'GitHub usernames for Human In The Loop. Empty = auto-merge.'
55
56
  }
56
- },
57
- required: ['repo']
57
+ }
58
58
  },
59
59
  /**
60
60
  * @param {object} args - Tool arguments.
@@ -62,9 +62,14 @@ export default {
62
62
  * @returns {Promise<object>} Start result with environment info and templates.
63
63
  */
64
64
  async exec(args, ctx) {
65
- check.repo(args.repo);
66
65
  if (args.theme) check.theme(args.theme);
67
- const { repo } = args;
66
+ const repo = select(args.repo, ctx);
67
+ if (!repo) {
68
+ return {
69
+ content: [{ type: 'text', text: 'No repository configured. Pass --repo, set LORELI_REPO, or set repo in loreli.yml.' }],
70
+ isError: true
71
+ };
72
+ }
68
73
 
69
74
  // Agent MCP servers (spawned from .mcp.json by Claude Code, Codex, etc.)
70
75
  // are hydrated from env vars during _hydrate(). If a session ID is
@@ -92,21 +97,9 @@ export default {
92
97
  ctx.hub = createHub({ config: ctx.config });
93
98
  }
94
99
 
95
- // 1. Discover environment
100
+ // 1. Discover environment + existing files in parallel
96
101
  const backendRegistry = ctx.backendRegistry;
97
- await backendRegistry.discover();
98
- const backends = backendRegistry.available();
99
- const providers = backendRegistry.providers();
100
- const sideInfo = capability(providers);
101
102
 
102
- // Determine review strategy
103
- let strategy = 'error';
104
- if (sideInfo.mode === 'dual') strategy = 'yin-yang';
105
- else if (sideInfo.mode === 'single') strategy = 'fresh-instance';
106
-
107
- log.info(`backends: ${backends.map(function name(b) { return b.name; }).join(', ') || 'none'}, strategy: ${strategy}`);
108
-
109
- // 2. Discover existing files (inlined from hub.discover)
110
103
  const templates = { pr: null, issue: null, codeowners: null, contributing: null, config: null };
111
104
  const checks = [
112
105
  ['.github/pull_request_template.md', 'pr'],
@@ -118,13 +111,27 @@ export default {
118
111
  ['.agents/skills/loreli-context/SKILL.md', 'contextSkill']
119
112
  ];
120
113
 
121
- for (const [path, key] of checks) {
122
- try {
123
- await ctx.hub.read(repo, path);
124
- templates[key] = path;
125
- } catch { /* not found */ }
114
+ const [/* discover result */, ...fileResults] = await Promise.all([
115
+ backendRegistry.discover(),
116
+ ...checks.map(function probe([path]) {
117
+ return ctx.hub.read(repo, path).then(function found() { return path; }, function miss() { return null; });
118
+ })
119
+ ]);
120
+
121
+ for (let i = 0; i < checks.length; i += 1) {
122
+ if (fileResults[i]) templates[checks[i][1]] = fileResults[i];
126
123
  }
127
124
 
125
+ const backends = backendRegistry.available();
126
+ const providers = backendRegistry.providers();
127
+ const sideInfo = capability(providers);
128
+
129
+ let strategy = 'error';
130
+ if (sideInfo.mode === 'dual') strategy = 'yin-yang';
131
+ else if (sideInfo.mode === 'single') strategy = 'fresh-instance';
132
+
133
+ log.info(`backends: ${backends.map(function name(b) { return b.name; }).join(', ') || 'none'}, strategy: ${strategy}`);
134
+
128
135
  // 3. Scaffold any missing required files
129
136
  const scaffolded = [];
130
137
  const toScaffold = [];
@@ -158,27 +165,15 @@ export default {
158
165
  }
159
166
  }
160
167
 
161
- // Ensure loreli is in devDependencies so agents can resolve
162
- // `npx loreli mcp` from the cloned repo after npm install.
163
- // Handles both cases: no package.json, and existing package.json
164
- // that doesn't list loreli yet. Reads the existing file (if any),
165
- // merges the devDependency, and writes back.
166
- await ensureDependency(ctx.hub, repo, scaffolded);
167
-
168
- // Ensure MCP configs exist with the loreli server entry for each
169
- // supported agent CLI (Claude, Cursor, Codex). Config paths come
170
- // from the backend registry so adding a backend doesn't require
171
- // touching this tool.
172
- await ensureMcpConfigs(ctx.hub, repo, scaffolded, backendRegistry.configPaths());
173
-
174
- // Ensure .gitignore contains Loreli artifact entries so agent-
175
- // generated files (.loreli/, .claude/, .cursor/hooks.json) never
176
- // leak into PRs. Creates the file if absent, appends if present.
177
- await ensureGitignore(ctx.hub, repo, scaffolded);
178
-
179
- // 4. Load config from loreli.yml (guaranteed to exist after scaffolding)
168
+ // Run ensure* operations and config load in parallel — each
169
+ // targets different files so there are no ordering dependencies.
180
170
  const config = new Config();
181
- await config.load(ctx.hub, repo);
171
+ await Promise.all([
172
+ ensureDependency(ctx.hub, repo, scaffolded),
173
+ ensureMcpConfigs(ctx.hub, repo, scaffolded, backendRegistry.configPaths()),
174
+ ensureGitignore(ctx.hub, repo, scaffolded),
175
+ config.load(ctx.hub, repo)
176
+ ]);
182
177
 
183
178
  // 5. Merge start param overrides
184
179
  const overrides = {};
@@ -188,11 +183,12 @@ export default {
188
183
 
189
184
  const theme = config.get('theme');
190
185
  const mergeBase = config.get('merge.base') ?? 'main';
191
- const baseProvision = await ensureMergeBase(ctx.hub, repo, mergeBase);
192
186
 
193
- // 6. Ensure agent labels exist in the repo
187
+ // 6. Ensure merge base and labels in parallel — both depend on
188
+ // config but are independent of each other.
194
189
  let ensuredLabels = [];
195
- if (config.get('labels.track') !== false) {
190
+ const labelPromise = (async function ensureLabels() {
191
+ if (config.get('labels.track') === false) return [];
196
192
  const labelNames = [
197
193
  'loreli',
198
194
  'loreli:planner', 'loreli:action', 'loreli:reviewer',
@@ -203,13 +199,18 @@ export default {
203
199
  labelNames.push(`loreli:${backend.provider}`);
204
200
  }
205
201
  }
206
- // Include user-configured extra labels
207
202
  const extra = config.get('labels.extra') ?? [];
208
203
  for (const name of extra) labelNames.push(name);
209
204
 
210
205
  const unique = [...new Set(labelNames)];
211
- ensuredLabels = await ctx.hub.ensure(repo, definitions(unique));
212
- }
206
+ return ctx.hub.ensure(repo, definitions(unique));
207
+ })();
208
+
209
+ const [baseProvision, labels] = await Promise.all([
210
+ ensureMergeBase(ctx.hub, repo, mergeBase),
211
+ labelPromise
212
+ ]);
213
+ ensuredLabels = labels;
213
214
 
214
215
  // 7. Initialize session storage
215
216
  const sessionId = `${repo.replace('/', '-')}-${Date.now()}`;
@@ -314,56 +315,34 @@ export default {
314
315
  }
315
316
 
316
317
  // Knowledge reactor — detect recurring feedback patterns and
317
- // propose promotions via discussions when threshold is met.
318
+ // dispatch planning via planner when threshold is met.
318
319
  if (ctx.orchestrator && ctx.hub) {
319
320
  ctx.orchestrator.register('knowledge', async function knowledge(repo) {
320
321
  const cfg = ctx.orchestrator.cfg;
321
- const enabled = cfg?.get?.('feedback.enabled') ?? true;
322
- if (!enabled) return;
322
+ if (!(cfg?.get?.('feedback.enabled') ?? true)) return;
323
323
 
324
324
  const threshold = cfg?.get?.('feedback.threshold') ?? 5;
325
- const categories = cfg?.get?.('feedback.categories');
326
- const { patterns, propose } = await import('loreli/knowledge');
327
- const found = await patterns(ctx.hub, repo, { threshold, category: categories?.[0] });
325
+ const { patterns, objective } = await import('loreli/knowledge');
328
326
 
329
- for (const pattern of found) {
330
- const existing = await ctx.hub.searchIssues(repo, `Promotion: ${pattern.category}`);
331
- const duplicate = existing.some(function match(i) { return i.title.includes(pattern.fingerprint); });
332
- if (duplicate) continue;
327
+ const found = await patterns(ctx.hub, repo, { threshold });
333
328
 
334
- await propose(ctx.hub, repo, pattern);
335
- }
336
- });
337
- }
329
+ const cat = await ctx.hub.category(repo, 'Loreli');
330
+ const discussions = await ctx.hub.discussions(repo, cat.id);
331
+ const openTitles = new Set(
332
+ discussions.filter(function open(d) { return !d.closed; })
333
+ .map(function title(d) { return d.title; })
334
+ );
338
335
 
339
- // Promotion approval scan closed promotion discussions for
340
- // human-approved patterns and create action issues.
341
- if (ctx.orchestrator && ctx.hub) {
342
- ctx.orchestrator.register('promotion-apply', async function promotionApply(repo) {
343
- const cfg = ctx.orchestrator.cfg;
344
- if (!(cfg?.get?.('feedback.enabled') ?? true)) return;
336
+ for (const pattern of found) {
337
+ const title = `${pattern.category} feedback pattern`;
338
+ if (openTitles.has(title) || ctx._feedbackDispatched?.has(pattern.category)) continue;
345
339
 
346
- const { parse } = await import('loreli/marker');
347
- const { apply } = await import('loreli/knowledge');
348
- const discussions = await ctx.hub.searchIssues(repo, 'loreli:promotion status:closed');
349
-
350
- for (const disc of discussions) {
351
- if (disc.type !== 'issue') continue;
352
- try {
353
- const full = await ctx.hub.read(repo, disc.number);
354
- const data = parse(full.body, 'promotion');
355
- if (!data || data.status === 'applied') continue;
356
-
357
- const approved = /- \[x\]\s*Approve/i.test(full.body);
358
- const rejected = /- \[x\]\s*Reject/i.test(full.body);
359
- if (!approved || rejected) continue;
360
-
361
- await apply(ctx.hub, repo, {
362
- summary: `${data.category} standard`,
363
- discussion: disc.number,
364
- body: full.body
365
- });
366
- } catch { /* non-fatal */ }
340
+ await ctx.planner.plan(repo, objective(pattern), {
341
+ feedbackCategory: pattern.category
342
+ });
343
+
344
+ ctx._feedbackDispatched ??= new Set();
345
+ ctx._feedbackDispatched.add(pattern.category);
367
346
  }
368
347
  });
369
348
  }
@@ -605,7 +584,7 @@ async function pause(ms, wait) {
605
584
  });
606
585
  }
607
586
 
608
- export { ensureMergeBase };
587
+ export { ensureMergeBase, normalizeCodexEnvVars };
609
588
 
610
589
  /**
611
590
  * Ensure `loreli` exists in the target repo's devDependencies.
@@ -623,8 +602,85 @@ export { ensureMergeBase };
623
602
  */
624
603
  // MCP config constants (ENTRY, CODEX_TOML) imported from @loreli/workspace
625
604
  // LORELI_TOML needs a leading newline for appending to existing TOML files
605
+ const CODEX_ENV_VARS = 'env_vars = ["GITHUB_TOKEN"]';
626
606
  const LORELI_TOML = `\n${CODEX_TOML}`;
627
607
 
608
+ /**
609
+ * Find the next TOML table header index at or after `start`.
610
+ *
611
+ * @param {string[]} lines - TOML content split by line.
612
+ * @param {number} start - Start index.
613
+ * @returns {number} Index of next table header or lines.length.
614
+ */
615
+ function nextTomlTable(lines, start) {
616
+ for (let index = start; index < lines.length; index += 1) {
617
+ if (/^\[[^\]]+\]\s*$/.test(lines[index].trim())) return index;
618
+ }
619
+ return lines.length;
620
+ }
621
+
622
+ /**
623
+ * Find a TOML table header line index.
624
+ *
625
+ * @param {string[]} lines - TOML content split by line.
626
+ * @param {number} start - Start index.
627
+ * @param {string} header - Exact table header text.
628
+ * @returns {number} Header index or -1 when missing.
629
+ */
630
+ function findTomlHeader(lines, start, header) {
631
+ for (let index = start; index < lines.length; index += 1) {
632
+ if (lines[index].trim() === header) return index;
633
+ }
634
+ return -1;
635
+ }
636
+
637
+ /**
638
+ * Normalize `.codex/config.toml` loreli token forwarding.
639
+ *
640
+ * Ensures `env_vars = ["GITHUB_TOKEN"]` is defined in the
641
+ * `[mcp_servers.loreli]` table and not inside the nested
642
+ * `[mcp_servers.loreli.env]` table.
643
+ *
644
+ * @param {string} content - Existing TOML content.
645
+ * @returns {string|null} Updated content when changed, else null.
646
+ */
647
+ function normalizeCodexEnvVars(content) {
648
+ if (/^\s*mcp_servers\.loreli\.env_vars\s*=.*$/m.test(content)) return null;
649
+
650
+ const lines = content.split('\n');
651
+ const loreli = findTomlHeader(lines, 0, '[mcp_servers.loreli]');
652
+ if (loreli === -1) return null;
653
+
654
+ let changed = false;
655
+ let env = findTomlHeader(lines, loreli + 1, '[mcp_servers.loreli.env]');
656
+
657
+ if (env !== -1) {
658
+ const envEnd = nextTomlTable(lines, env + 1);
659
+ const envLines = lines.slice(env + 1, envEnd);
660
+ const kept = envLines.filter(function keepLine(line) {
661
+ return !/^\s*env_vars\s*=/.test(line);
662
+ });
663
+ if (kept.length !== envLines.length) {
664
+ lines.splice(env + 1, envEnd - env - 1, ...kept);
665
+ changed = true;
666
+ env = findTomlHeader(lines, loreli + 1, '[mcp_servers.loreli.env]');
667
+ }
668
+ }
669
+
670
+ const loreliEnd = env === -1 ? nextTomlTable(lines, loreli + 1) : env;
671
+ const hasEnvVars = lines.slice(loreli + 1, loreliEnd).some(function hasLine(line) {
672
+ return /^\s*env_vars\s*=/.test(line);
673
+ });
674
+
675
+ if (!hasEnvVars) {
676
+ lines.splice(loreliEnd, 0, CODEX_ENV_VARS);
677
+ changed = true;
678
+ }
679
+
680
+ if (!changed) return null;
681
+ return lines.join('\n');
682
+ }
683
+
628
684
  /**
629
685
  * Ensure the loreli MCP server is configured in all supported agent CLI
630
686
  * config files (.mcp.json, .cursor/mcp.json, .codex/config.toml).
@@ -665,6 +721,19 @@ async function ensureMcpConfigs(hub, repo, scaffolded, configs) {
665
721
 
666
722
  // File exists — check if loreli is already configured
667
723
  if (existing.content.includes(entry.marker)) {
724
+ if (entry.path === '.codex/config.toml') {
725
+ const updated = normalizeCodexEnvVars(existing.content);
726
+ if (updated) {
727
+ await hub.write(repo, entry.path, {
728
+ content: updated,
729
+ message: 'chore: add codex env_vars forwarding for loreli MCP server'
730
+ });
731
+ scaffolded.push(`${entry.path} (added env_vars forwarding)`);
732
+ log.info(`normalized env_vars forwarding in existing ${entry.path}`);
733
+ continue;
734
+ }
735
+ }
736
+
668
737
  log.info(`loreli already in ${entry.path} — skipping`);
669
738
  continue;
670
739
  }
@@ -1,5 +1,6 @@
1
- import { check } from 'loreli/config';
2
1
  import { has } from 'loreli/marker';
2
+ import { select } from './repo.js';
3
+ import { check } from 'loreli/config';
3
4
 
4
5
  export default {
5
6
  team_status: {
@@ -18,6 +19,8 @@ export default {
18
19
  * @returns {Promise<object>} Status dashboard.
19
20
  */
20
21
  async exec(args, ctx) {
22
+ // Validate repo format eagerly before any other guard so callers always
23
+ // get a thrown error on malformed input regardless of hub state.
21
24
  if (args.repo) check.repo(args.repo);
22
25
 
23
26
  // When the user explicitly requests a repo but hub is not initialized,
@@ -29,7 +32,7 @@ export default {
29
32
  return { content: [{ type: 'text', text: 'Run start first to initialize the hub.' }], isError: true };
30
33
  }
31
34
 
32
- const repo = args.repo ?? ctx.repo;
35
+ const repo = select(args.repo, ctx);
33
36
  const lines = [`Team Status for ${repo ?? 'unknown repo'}`];
34
37
 
35
38
  // --- Agent States ---
@@ -1,5 +1,5 @@
1
1
  import { logger } from 'loreli/log';
2
- import { check } from 'loreli/config';
2
+ import { select } from './repo.js';
3
3
 
4
4
  const log = logger('work');
5
5
 
@@ -21,7 +21,7 @@ export default {
21
21
  repo: { type: 'string', description: 'Target repository (owner/name).' },
22
22
  objective: { type: 'string', description: 'What should the planner analyze and plan for?' }
23
23
  },
24
- required: ['repo', 'objective']
24
+ required: ['objective']
25
25
  },
26
26
 
27
27
  /**
@@ -30,8 +30,14 @@ export default {
30
30
  * @returns {Promise<object>} Planning initiation result.
31
31
  */
32
32
  async exec(args, ctx) {
33
- check.repo(args.repo);
34
- const { repo, objective } = args;
33
+ const repo = select(args.repo, ctx);
34
+ const objective = args.objective;
35
+ if (!repo) {
36
+ return {
37
+ content: [{ type: 'text', text: 'No repository configured. Pass --repo, run start, set LORELI_REPO, or set repo in loreli.yml.' }],
38
+ isError: true
39
+ };
40
+ }
35
41
 
36
42
  if (!ctx.hub) {
37
43
  return { content: [{ type: 'text', text: 'Run start first to initialize the hub.' }], isError: true };
@@ -72,8 +78,7 @@ export default {
72
78
  type: 'object',
73
79
  properties: {
74
80
  repo: { type: 'string', description: 'Target repository (owner/name).' }
75
- },
76
- required: ['repo']
81
+ }
77
82
  },
78
83
 
79
84
  /**
@@ -82,8 +87,13 @@ export default {
82
87
  * @returns {Promise<object>} Work cycle initiation result.
83
88
  */
84
89
  async exec(args, ctx) {
85
- check.repo(args.repo);
86
- const { repo } = args;
90
+ const repo = select(args.repo, ctx);
91
+ if (!repo) {
92
+ return {
93
+ content: [{ type: 'text', text: 'No repository configured. Pass --repo, run start, set LORELI_REPO, or set repo in loreli.yml.' }],
94
+ isError: true
95
+ };
96
+ }
87
97
 
88
98
  if (!ctx.hub) {
89
99
  return { content: [{ type: 'text', text: 'Run start first to initialize the hub.' }], isError: true };