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,1127 @@
1
+ import { readFile, writeFile, mkdir, rm, access, readdir } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+
7
+ const exec = promisify(execFile);
8
+
9
+ /**
10
+ * Timeout for git network operations (fetch, clone, push) in ms.
11
+ * Prevents hung network connections from blocking the orchestrator.
12
+ * @type {number}
13
+ */
14
+ const GIT_TIMEOUT = 30_000;
15
+
16
+ /**
17
+ * Run a child process with an optional timeout.
18
+ *
19
+ * @param {string} cmd - Command to execute.
20
+ * @param {string[]} args - Arguments.
21
+ * @param {object} [opts] - execFile options.
22
+ * @returns {Promise<{stdout: string, stderr: string}>}
23
+ */
24
+ function run(cmd, args, opts = {}) {
25
+ return exec(cmd, args, opts);
26
+ }
27
+
28
+ /**
29
+ * Pattern for safe path segments — alphanumeric, hyphens, and underscores only.
30
+ * Prevents path traversal, tmux injection, and shell metacharacter issues.
31
+ * @type {RegExp}
32
+ */
33
+ const SAFE_NAME = /^[a-zA-Z0-9_-]+$/;
34
+
35
+ /**
36
+ * Validate that a name is safe for use in file paths and tmux session IDs.
37
+ *
38
+ * @param {string} value - The name to validate.
39
+ * @param {string} label - Human-readable label for error messages.
40
+ * @throws {Error} When the value contains unsafe characters.
41
+ */
42
+ function assertSafe(value, label) {
43
+ if (!SAFE_NAME.test(value))
44
+ throw new Error(`Invalid ${label} "${value}": must match ${SAFE_NAME}`);
45
+ }
46
+
47
+ /**
48
+ * Default root directory for agent workspaces.
49
+ * Lives under the loreli home so all state is colocated.
50
+ * @type {string}
51
+ */
52
+ const DEFAULT_ROOT = join(process.env.LORELI_HOME ?? join(homedir(), '.loreli'), 'workspaces');
53
+
54
+ /**
55
+ * Loreli MCP server entry for JSON-based CLI tools.
56
+ *
57
+ * @type {{command: string, args: string[]}}
58
+ */
59
+ export const ENTRY = { command: 'npx', args: ['loreli', 'mcp'] };
60
+
61
+ /**
62
+ * MCP config for JSON-based CLI tools (Claude, Cursor).
63
+ *
64
+ * Points the loreli MCP server entry at `npx loreli mcp`, which is
65
+ * the same content start writes to the target GitHub repo. By
66
+ * scaffolding it into the agent's cwd before spawn, the CLI backend
67
+ * discovers Loreli tools immediately on startup.
68
+ *
69
+ * @type {string}
70
+ */
71
+ export const MCP_JSON = JSON.stringify({
72
+ mcpServers: { loreli: ENTRY }
73
+ }, null, 2) + '\n';
74
+
75
+ /**
76
+ * MCP config for TOML-based CLI tools (Codex).
77
+ * @type {string}
78
+ */
79
+ export const CODEX_TOML = [
80
+ '[mcp_servers.loreli]',
81
+ 'command = "npx"',
82
+ 'args = ["loreli", "mcp"]',
83
+ 'env_vars = ["GITHUB_TOKEN"]',
84
+ ''
85
+ ].join('\n');
86
+
87
+ /**
88
+ * Build Codex TOML config, optionally injecting agent context env vars.
89
+ *
90
+ * @param {object} [context] - Agent context for env injection.
91
+ * @param {string} [context.session] - Session ID.
92
+ * @param {string} [context.agent] - Agent identity name.
93
+ * @param {string} [context.repo] - Target repository (owner/name).
94
+ * @returns {string} TOML string for .codex/config.toml.
95
+ */
96
+ export function codexToml(context) {
97
+ let toml = CODEX_TOML;
98
+ if (context?.session) {
99
+ toml += `\n[mcp_servers.loreli.env]\nLORELI_SESSION = "${context.session}"\nLORELI_AGENT = "${context.agent}"\nLORELI_REPO = "${context.repo}"\n`;
100
+ if (context.home) toml += `LORELI_HOME = "${context.home}"\n`;
101
+ }
102
+ return toml;
103
+ }
104
+
105
+
106
+ /**
107
+ * Generate the common deny shell script content.
108
+ *
109
+ * Produces a bash script that reads JSON from stdin, extracts the
110
+ * command to be executed (supporting both Claude Code `tool_input.command`
111
+ * and Cursor Agent `command` formats), and exits with code 2 (block)
112
+ * if the first token matches a denied command.
113
+ *
114
+ * Exit code 2 is the universal block signal recognized by both
115
+ * Claude Code PreToolUse hooks and Cursor Agent beforeShellExecution hooks.
116
+ *
117
+ * @param {string[]} denied - List of command names to block.
118
+ * @returns {string} Shell script content.
119
+ */
120
+ export function denyScript(denied) {
121
+ const pattern = denied.join('|');
122
+ const lines = [
123
+ '#!/bin/bash',
124
+ '# .loreli/deny.sh — Loreli agent command deny hook',
125
+ '# Exit 2 = block (universal signal for Claude Code and Cursor Agent).',
126
+ '',
127
+ 'INPUT=$(cat)',
128
+ 'CMD=$(echo "$INPUT" | jq -r \'.tool_input.command // .command // empty\' 2>/dev/null)',
129
+ 'FIRST=$(echo "$CMD" | awk \'{print $1}\')',
130
+ '',
131
+ '# Protected-path whitelist: block any command referencing scaffolding',
132
+ '# or security files unless the command is clearly read-only.',
133
+ 'PROTECTED=\'\\.(gitignore|mcp\\.json|secretlintrc)|\\.(git/hooks|cursor/|codex/|claude/|loreli/)\'',
134
+ 'if echo "$CMD" | grep -qE "$PROTECTED"; then',
135
+ ' case "$FIRST" in',
136
+ ' cat|less|head|tail|grep|rg|wc|file|ls|stat|find|diff|loreli) exit 0 ;;',
137
+ ' esac',
138
+ ' echo "$CMD" | grep -qE \'(^|[[:space:]])git[[:space:]]+(diff|log|show|status|branch|remote)\' && exit 0',
139
+ ' exit 2',
140
+ 'fi',
141
+ '',
142
+ '# Commit-family --no-verify blocking: prevent agents from bypassing',
143
+ '# pre-commit hooks regardless of how they invoke git.',
144
+ 'if echo "$CMD" | grep -qE \'(commit|merge|cherry-pick|revert|rebase)\'; then',
145
+ ' echo "$CMD" | grep -qE \'(--no-verify|[[:space:]]-n[[:space:]]|[[:space:]]-n$)\' && exit 2',
146
+ 'fi',
147
+ '',
148
+ '# Scaffolding-file staging check: block commits that include loreli',
149
+ '# scaffolding files in the staging area.',
150
+ 'if echo "$CMD" | grep -qE \'(^|[[:space:]])git[[:space:]]+(commit|merge|cherry-pick|revert)\'; then',
151
+ ' SCAFFOLDING=\'^\\.mcp\\.json$|^\\.codex/|^\\.cursor/|^\\.claude/|^\\.loreli/|^\\.gitignore$|^\\.secretlintrc\'',
152
+ ' git diff --cached --name-only 2>/dev/null | grep -qE "$SCAFFOLDING" && exit 2',
153
+ '',
154
+ ' # Secretlint check: scan staged files for secrets before commit.',
155
+ ' # Conditional on secretlint being installed in the workspace.',
156
+ ' FILES=$(git diff --cached --name-only 2>/dev/null)',
157
+ ' if [ -n "$FILES" ] && [ -x ./node_modules/.bin/secretlint ]; then',
158
+ ' echo "$FILES" | xargs ./node_modules/.bin/secretlint 2>/dev/null',
159
+ ' [ $? -ne 0 ] && exit 2',
160
+ ' fi',
161
+ 'fi',
162
+ '',
163
+ '# First-token deny list',
164
+ 'case "$FIRST" in',
165
+ ` ${pattern}) exit 2 ;;`,
166
+ ' *) exit 0 ;;',
167
+ 'esac',
168
+ ''
169
+ ];
170
+ return lines.join('\n');
171
+ }
172
+
173
+ /**
174
+ * Generate the protect shell script for Claude file-write interception.
175
+ *
176
+ * Reads JSON from stdin (Claude PreToolUse hook format), extracts the
177
+ * file path from `tool_input.file_path` or `tool_input.path`, normalizes
178
+ * it (removes `./` prefix, resolves `../`), and blocks writes to
179
+ * protected scaffolding and security paths.
180
+ *
181
+ * Exit code 2 = block (universal signal for Claude Code hooks).
182
+ *
183
+ * @returns {string} Shell script content.
184
+ */
185
+ export function protectScript() {
186
+ return [
187
+ '#!/bin/bash',
188
+ '# .loreli/protect.sh — Loreli agent file-write protection hook',
189
+ '# Blocks writes to scaffolding and security files.',
190
+ '',
191
+ 'INPUT=$(cat)',
192
+ 'FILE=$(echo "$INPUT" | jq -r \'.tool_input.file_path // .tool_input.path // empty\' 2>/dev/null)',
193
+ '',
194
+ '# Normalize path: strip leading ./, collapse /./, resolve ../',
195
+ 'FILE=$(echo "$FILE" | sed \'s|^\\./||; s|/\\./|/|g\')',
196
+ 'while echo "$FILE" | grep -q \'[^/][^/]*/\\.\\./\'; do',
197
+ ' FILE=$(echo "$FILE" | sed \'s|[^/][^/]*/\\.\\./||\')',
198
+ 'done',
199
+ '',
200
+ 'echo "$FILE" | grep -qE \'(^|/)\\.(gitignore)$\' && exit 2',
201
+ 'echo "$FILE" | grep -qE \'(^|/)\\.mcp\\.json$\' && exit 2',
202
+ 'echo "$FILE" | grep -qE \'(^|/)\\.secretlintrc\' && exit 2',
203
+ 'echo "$FILE" | grep -qE \'(^|/)\\.git/\' && exit 2',
204
+ 'echo "$FILE" | grep -qE \'(^|/)\\.cursor/\' && exit 2',
205
+ 'echo "$FILE" | grep -qE \'(^|/)\\.codex/\' && exit 2',
206
+ 'echo "$FILE" | grep -qE \'(^|/)\\.claude/\' && exit 2',
207
+ 'echo "$FILE" | grep -qE \'(^|/)\\.loreli/\' && exit 2',
208
+ 'exit 0',
209
+ ''
210
+ ].join('\n');
211
+ }
212
+
213
+ /**
214
+ * Generate Claude Code project hook config content.
215
+ *
216
+ * Produces `.claude/settings.local.json` with a PreToolUse hook that
217
+ * fires for Bash tool invocations, delegating to the common deny script.
218
+ *
219
+ * @returns {string} JSON string for `.claude/settings.local.json`.
220
+ */
221
+ export function claudeHooks() {
222
+ return JSON.stringify({
223
+ hooks: {
224
+ PreToolUse: [{
225
+ matcher: 'Bash',
226
+ hooks: [{
227
+ type: 'command',
228
+ command: 'bash "$CLAUDE_PROJECT_DIR/.loreli/deny.sh"'
229
+ }]
230
+ }]
231
+ }
232
+ }, null, 2) + '\n';
233
+ }
234
+
235
+ /**
236
+ * Build a regex matcher string for Cursor's beforeShellExecution hook.
237
+ *
238
+ * Matches commands whose first token is any of the denied binaries.
239
+ * Handles both "cmd args..." (with trailing space) and bare "cmd"
240
+ * (end of string) patterns.
241
+ *
242
+ * @param {string[]} denied - List of command names to match.
243
+ * @returns {string} Regex pattern string.
244
+ */
245
+ export function cursorMatcher(denied) {
246
+ const parts = denied.map(function toPattern(cmd) {
247
+ return `^${cmd} |^${cmd}$`;
248
+ });
249
+ return parts.join('|');
250
+ }
251
+
252
+ /**
253
+ * Generate Cursor Agent hook config content.
254
+ *
255
+ * Produces `.cursor/hooks.json` with a beforeShellExecution hook that
256
+ * uses a regex matcher to pre-filter commands, then delegates to the
257
+ * common deny script for the actual block decision.
258
+ *
259
+ * @param {string[]} denied - List of command names to block.
260
+ * @returns {string} JSON string for `.cursor/hooks.json`.
261
+ */
262
+ export function cursorHooks(denied) {
263
+ return JSON.stringify({
264
+ version: 1,
265
+ hooks: {
266
+ beforeShellExecution: [{
267
+ command: '.loreli/deny.sh',
268
+ matcher: cursorMatcher(denied)
269
+ }]
270
+ }
271
+ }, null, 2) + '\n';
272
+ }
273
+
274
+ /**
275
+ * Read-merge-write a hook entry into an existing JSON hooks file.
276
+ *
277
+ * Preserves all existing content (hooks, permissions, other keys).
278
+ * Identifies the Loreli entry by searching for `marker` in the
279
+ * command string. If found, updates in place; otherwise appends.
280
+ *
281
+ * If the file does not exist or contains invalid JSON, starts fresh.
282
+ *
283
+ * @param {string} path - Absolute path to the hooks JSON file.
284
+ * @param {string} key - Hook event name (e.g. 'PreToolUse', 'beforeShellExecution').
285
+ * @param {object} entry - The hook entry object to merge.
286
+ * @param {string} marker - String to search for in existing entries to detect Loreli's hook.
287
+ * @param {object} [rootDefaults] - Default root-level keys to set if missing (e.g. `{ version: 1 }`).
288
+ * @returns {Promise<void>}
289
+ */
290
+ async function mergeHook(path, key, entry, marker, rootDefaults) {
291
+ let config = {};
292
+ try {
293
+ config = JSON.parse(await readFile(path, 'utf8'));
294
+ } catch {
295
+ // File doesn't exist or isn't valid JSON — start fresh
296
+ }
297
+
298
+ if (rootDefaults) {
299
+ for (const [k, v] of Object.entries(rootDefaults)) {
300
+ if (config[k] === undefined) config[k] = v;
301
+ }
302
+ }
303
+
304
+ if (!config.hooks) config.hooks = {};
305
+ if (!Array.isArray(config.hooks[key])) config.hooks[key] = [];
306
+
307
+ // Find existing Loreli entry by marker in command string.
308
+ // Claude hooks nest command inside `.hooks[0].command`;
309
+ // Cursor hooks use `.command` directly.
310
+ const idx = config.hooks[key].findIndex(
311
+ function isLoreli(h) {
312
+ return h.command?.includes(marker) || h.hooks?.[0]?.command?.includes(marker);
313
+ }
314
+ );
315
+
316
+ if (idx >= 0) config.hooks[key][idx] = entry;
317
+ else config.hooks[key].push(entry);
318
+
319
+ await writeFile(path, JSON.stringify(config, null, 2) + '\n');
320
+ }
321
+
322
+ /**
323
+ * Ensure a bare mirror of a remote repository exists locally.
324
+ *
325
+ * On first call, clones the repository as a bare repo under
326
+ * `<home>/repos/`. On subsequent calls, fetches to update refs.
327
+ * The bare mirror is shared across all agents and used as the
328
+ * source for `git worktree add` — eliminating per-agent clones.
329
+ *
330
+ * @param {string} url - Repository URL (https or file://).
331
+ * @param {object} [opts] - Options.
332
+ * @param {string} [opts.home] - Loreli home directory (default: ~/.loreli).
333
+ * @param {string} [opts.token] - GitHub token for authenticated clone.
334
+ * @returns {Promise<string>} Absolute path to the bare mirror.
335
+ */
336
+ export async function mirror(url, opts = {}) {
337
+ const home = opts.home ?? process.env.LORELI_HOME ?? join(homedir(), '.loreli');
338
+ const reposDir = join(home, 'repos');
339
+
340
+ // Derive a stable directory name from the URL
341
+ const slug = url
342
+ .replace(/^file:\/\//, '')
343
+ .replace(/^https?:\/\/[^/]+\//, '')
344
+ .replace(/\.git$/, '')
345
+ .replace(/\//g, '-');
346
+
347
+ const dir = join(reposDir, `${slug}.git`);
348
+
349
+ // Inject token into HTTPS URLs for authenticated clone/fetch
350
+ let authUrl = url;
351
+ if (opts.token && url.startsWith('https://')) {
352
+ const parsed = new URL(url);
353
+ parsed.username = 'x-access-token';
354
+ parsed.password = opts.token;
355
+ authUrl = parsed.toString();
356
+ }
357
+
358
+ const gitEnv = { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, timeout: GIT_TIMEOUT };
359
+
360
+ let exists = false;
361
+ try {
362
+ await access(dir);
363
+ exists = true;
364
+ } catch { /* directory doesn't exist */ }
365
+
366
+ if (exists) {
367
+ await run('git', ['-C', dir, 'fetch', '--prune'], gitEnv);
368
+ } else {
369
+ await mkdir(reposDir, { recursive: true });
370
+ await run('git', ['clone', '--bare', authUrl, dir], gitEnv);
371
+ }
372
+
373
+ return dir;
374
+ }
375
+
376
+ /**
377
+ * Compute the deterministic workspace path for an agent.
378
+ *
379
+ * All agents get a predictable `loreli-{name}` directory under the
380
+ * workspace root. This convention is shared across factory, orchestrator,
381
+ * and cleanup code to avoid path coordination bugs.
382
+ *
383
+ * @param {string} name - Agent identity name (e.g. 'optimus-0').
384
+ * @param {string} [root] - Base directory for workspaces (default: ~/.loreli/workspaces).
385
+ * @returns {string} Absolute path to the agent's workspace.
386
+ */
387
+ export function pathFor(name, root = DEFAULT_ROOT) {
388
+ assertSafe(name, 'agent name');
389
+ return join(root, `loreli-${name}`);
390
+ }
391
+
392
+ /**
393
+ * Build MCP JSON config content, optionally injecting agent context env vars.
394
+ *
395
+ * When context is provided, the env block enables the agent's MCP server
396
+ * to hydrate session state on startup without the agent knowing its own
397
+ * identifiers — eliminating hallucination vectors.
398
+ *
399
+ * @param {object} [context] - Agent context for env injection.
400
+ * @param {string} [context.session] - Session ID.
401
+ * @param {string} [context.agent] - Agent identity name.
402
+ * @param {string} [context.repo] - Target repository (owner/name).
403
+ * @param {object} [opts] - Generation options.
404
+ * @param {string} [opts.tokenRef] - Host-specific interpolation
405
+ * reference for GITHUB_TOKEN. Each MCP host resolves env
406
+ * references with its own syntax:
407
+ * - Claude Code: `${GITHUB_TOKEN}`
408
+ * - Cursor IDE: `${env:GITHUB_TOKEN}`
409
+ * The file only contains a reference — never the literal secret.
410
+ * @param {string} [opts.envFile] - Path to a `.env` file that the
411
+ * MCP host loads at spawn time. Used as a fallback for Cursor
412
+ * startup paths where interpolation may not be available. The
413
+ * secret lives in the referenced file (inside `.git/`,
414
+ * unstageable), not in the JSON config itself.
415
+ * @returns {string} JSON string for .mcp.json files.
416
+ */
417
+ export function mcpJson(context, { tokenRef, envFile } = {}) {
418
+ const entry = { ...ENTRY };
419
+ if (context?.session) {
420
+ entry.env = {
421
+ LORELI_SESSION: context.session,
422
+ LORELI_AGENT: context.agent,
423
+ LORELI_REPO: context.repo
424
+ };
425
+ if (context.home) entry.env.LORELI_HOME = context.home;
426
+ if (tokenRef) entry.env.GITHUB_TOKEN = tokenRef;
427
+ }
428
+ if (envFile) entry.envFile = envFile;
429
+ return JSON.stringify({ mcpServers: { loreli: entry } }, null, 2) + '\n';
430
+ }
431
+
432
+ /**
433
+ * Build legacy scaffold descriptors when no backends provide them.
434
+ *
435
+ * Replicates the hardcoded Claude + Cursor + Codex scaffolding that
436
+ * prepare() previously contained inline. Used as a backward-compat
437
+ * fallback when callers don't pass explicit descriptors.
438
+ *
439
+ * @param {object} [context] - Agent context for env injection.
440
+ * @returns {object[]} Array of scaffold descriptors.
441
+ */
442
+ function legacyDescriptors(context) {
443
+ const denied = context?.denied ?? [];
444
+ const dangerousCommands = [
445
+ ...denied,
446
+ 'git', 'rm', 'mv', 'chmod', 'sed', 'perl', 'tee', 'truncate',
447
+ 'node', 'python', 'python3', 'ruby'
448
+ ];
449
+ const unique = [...new Set(dangerousCommands)];
450
+
451
+ return [
452
+ {
453
+ configs: [{
454
+ path: '.mcp.json',
455
+ content: mcpJson(context, context ? { tokenRef: '${GITHUB_TOKEN}' } : {}),
456
+ marker: 'loreli',
457
+ format: 'json'
458
+ }],
459
+ hooks: [
460
+ {
461
+ path: '.claude/settings.local.json',
462
+ key: 'PreToolUse',
463
+ entry: {
464
+ matcher: 'Bash',
465
+ hooks: [{ type: 'command', command: 'bash "$CLAUDE_PROJECT_DIR/.loreli/deny.sh"' }]
466
+ },
467
+ marker: 'deny.sh'
468
+ },
469
+ {
470
+ path: '.claude/settings.local.json',
471
+ key: 'PreToolUse',
472
+ entry: {
473
+ matcher: 'Write|Edit|MultiEdit',
474
+ hooks: [{ type: 'command', command: 'bash "$CLAUDE_PROJECT_DIR/.loreli/protect.sh"' }]
475
+ },
476
+ marker: 'protect.sh'
477
+ }
478
+ ],
479
+ files: []
480
+ },
481
+ {
482
+ configs: [{
483
+ path: '.cursor/mcp.json',
484
+ content: mcpJson(context, context ? {
485
+ envFile: '.git/loreli.env'
486
+ } : {}),
487
+ marker: 'loreli',
488
+ format: 'json'
489
+ }],
490
+ hooks: [{
491
+ path: '.cursor/hooks.json',
492
+ key: 'beforeShellExecution',
493
+ entry: { command: '.loreli/deny.sh', matcher: cursorMatcher(unique) },
494
+ marker: 'deny.sh',
495
+ defaults: { version: 1 }
496
+ }],
497
+ files: context?.token
498
+ ? [{ path: '.git/loreli.env', content: `GITHUB_TOKEN=${context.token}\n`, mode: 0o600 }]
499
+ : []
500
+ },
501
+ {
502
+ configs: [{
503
+ path: '.codex/config.toml',
504
+ content: codexToml(context),
505
+ marker: 'mcp_servers.loreli',
506
+ format: 'toml'
507
+ }],
508
+ hooks: [],
509
+ files: []
510
+ }
511
+ ];
512
+ }
513
+
514
+ /**
515
+ * Prepare an agent's working directory with MCP configs, hooks, and
516
+ * security scaffolding.
517
+ *
518
+ * When `descriptors` are provided (from `BackendRegistry.scaffoldAll()`),
519
+ * uses them to determine which config files and hooks to write — no
520
+ * backend-specific knowledge needed. When omitted, falls back to
521
+ * built-in legacy descriptors for backward compatibility.
522
+ *
523
+ * Shared security scaffolding (deny.sh, protect.sh, secretlintrc,
524
+ * .gitignore) is always written regardless of descriptors.
525
+ *
526
+ * Uses `{ flag: 'wx' }` (exclusive create) when no context is
527
+ * provided so existing files are never overwritten. When context
528
+ * is present, overwrites with `'w'` because session identity
529
+ * changes between runs.
530
+ *
531
+ * @param {string} cwd - Absolute path to the agent workspace.
532
+ * @param {object} [context] - Agent context for env injection.
533
+ * @param {string} [context.session] - Session ID.
534
+ * @param {string} [context.agent] - Agent identity name.
535
+ * @param {string} [context.repo] - Target repository (owner/name).
536
+ * @param {object[]} [descriptors] - Scaffold descriptors from backends.
537
+ * @returns {Promise<void>}
538
+ */
539
+ export async function prepare(cwd, context, descriptors) {
540
+ await mkdir(cwd, { recursive: true });
541
+
542
+ const descs = descriptors ?? legacyDescriptors(context);
543
+
544
+ // When context is provided, always overwrite config files because
545
+ // the session/agent identity changes between runs. Without context,
546
+ // use wx (exclusive create) to avoid clobbering user-modified configs.
547
+ const flag = context ? 'w' : 'wx';
548
+
549
+ // Read-then-merge .gitignore: preserve existing rules, append loreli
550
+ // rules only if missing. Prevents destroying the repo's gitignore.
551
+ const LORELI_MARKER = '.loreli/';
552
+ const loreligitignore = [
553
+ '.mcp.json', '.codex/', '.cursor/', '.claude/', '.loreli/',
554
+ 'node_modules/', '.secretlintrc.json'
555
+ ];
556
+
557
+ let existingIgnore = '';
558
+ try {
559
+ existingIgnore = await readFile(join(cwd, '.gitignore'), 'utf8');
560
+ } catch { /* file doesn't exist — will create */ }
561
+
562
+ if (!existingIgnore.includes(LORELI_MARKER)) {
563
+ const block = '\n# loreli scaffolding\n' + loreligitignore.join('\n') + '\n';
564
+ await writeFile(join(cwd, '.gitignore'), existingIgnore + block);
565
+ }
566
+
567
+ // Generic config write loop — writes each descriptor's configs
568
+ const writes = [];
569
+ for (const desc of descs) {
570
+ for (const cfg of desc.configs ?? []) {
571
+ const target = join(cwd, cfg.path);
572
+ const dir = dirname(target);
573
+ if (dir !== cwd) {
574
+ writes.push(
575
+ mkdir(dir, { recursive: true })
576
+ .then(() => writeFile(target, cfg.content, { flag }))
577
+ );
578
+ } else {
579
+ writes.push(writeFile(target, cfg.content, { flag }));
580
+ }
581
+ }
582
+ }
583
+
584
+ // Security scripts and secretlint — always scaffolded
585
+ const denied = context?.denied ?? [];
586
+ const secretlintrc = JSON.stringify({
587
+ rules: [{ id: '@secretlint/secretlint-rule-preset-recommend' }]
588
+ }, null, 2) + '\n';
589
+
590
+ writes.push(
591
+ writeFile(join(cwd, '.secretlintrc.json'), secretlintrc),
592
+ mkdir(join(cwd, '.loreli'), { recursive: true })
593
+ .then(() => Promise.all([
594
+ writeFile(join(cwd, '.loreli', 'deny.sh'), denyScript(denied), { mode: 0o755 }),
595
+ writeFile(join(cwd, '.loreli', 'protect.sh'), protectScript(), { mode: 0o755 })
596
+ ]))
597
+ );
598
+
599
+ // Generic hook merge loop — groups hooks by target file to avoid
600
+ // race conditions when multiple descriptors write the same file.
601
+ // Hooks for the same file are chained sequentially; different
602
+ // files run in parallel.
603
+ const hooksByPath = new Map();
604
+ for (const desc of descs) {
605
+ for (const hook of desc.hooks ?? []) {
606
+ const target = join(cwd, hook.path);
607
+ if (!hooksByPath.has(target)) hooksByPath.set(target, []);
608
+ hooksByPath.get(target).push(hook);
609
+ }
610
+ }
611
+
612
+ for (const [target, hooks] of hooksByPath) {
613
+ const dir = dirname(target);
614
+ let chain = mkdir(dir, { recursive: true });
615
+ for (const hook of hooks) {
616
+ chain = chain.then(() => mergeHook(target, hook.key, hook.entry, hook.marker, hook.defaults));
617
+ }
618
+ writes.push(chain);
619
+ }
620
+
621
+ const results = await Promise.allSettled(writes);
622
+
623
+ // wx throws EEXIST when the file already exists — that's expected.
624
+ // Any other error is a real problem.
625
+ for (const result of results) {
626
+ if (result.status === 'rejected' && result.reason?.code !== 'EEXIST') {
627
+ throw result.reason;
628
+ }
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Scaffolding paths that must stay invisible to `git add -A`.
634
+ *
635
+ * When a previous run committed these to main, they become tracked.
636
+ * `.gitignore` cannot prevent `git add -A` from staging modifications
637
+ * to tracked files. `skip-worktree` tells git to ignore local changes,
638
+ * so even a raw `git add -A && git commit` never captures config diffs.
639
+ *
640
+ * @type {string[]}
641
+ */
642
+ const SKIP_PATHS = [
643
+ '.mcp.json', '.cursor/mcp.json', '.codex/config.toml',
644
+ '.gitignore', '.secretlintrc.json'
645
+ ];
646
+
647
+ /**
648
+ * Mark scaffolding files as skip-worktree so `git add -A` ignores them.
649
+ *
650
+ * Only operates on files that are actually tracked in the index —
651
+ * untracked files are already covered by `.gitignore`. Silently skips
652
+ * non-git directories and untracked paths.
653
+ *
654
+ * @param {string} cwd - Workspace root (must contain `.git/`).
655
+ * @returns {Promise<void>}
656
+ */
657
+ async function shield(cwd) {
658
+ for (const path of SKIP_PATHS) {
659
+ try {
660
+ await run('git', ['-C', cwd, 'update-index', '--skip-worktree', path]);
661
+ } catch {
662
+ // File not tracked or not in index — nothing to shield
663
+ }
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Create a git worktree for an agent.
669
+ *
670
+ * Adds a new worktree at the deterministic path for the agent name,
671
+ * checking out the specified branch. After worktree creation, scaffolds
672
+ * MCP configs so CLI backends discover Loreli tools on startup.
673
+ *
674
+ * When context is provided, MCP config files include agent-specific
675
+ * env vars (session, identity, repo) so the agent's MCP server can
676
+ * hydrate state on startup.
677
+ *
678
+ * @param {string} repo - Path to the git repository (local bare or checkout).
679
+ * @param {string} branch - Branch to checkout in the worktree.
680
+ * @param {string} name - Agent identity name.
681
+ * @param {string} [root] - Base directory for workspaces.
682
+ * @param {object} [context] - Agent context for MCP config env injection.
683
+ * @param {object[]} [descriptors] - Scaffold descriptors from backends.
684
+ * @returns {Promise<string>} Absolute path to the created worktree.
685
+ */
686
+ export async function create(repo, branch, name, root = DEFAULT_ROOT, context, descriptors) {
687
+ const cwd = pathFor(name, root);
688
+ const marker = join(cwd, '.loreli', 'session');
689
+
690
+ // Guard: reuse existing workspace only when it belongs to the current
691
+ // session. Stale workspaces from previous sessions carry old branches,
692
+ // commits, and working-tree state that confuse newly spawned agents.
693
+ try {
694
+ await access(join(cwd, '.git'));
695
+ const stored = context?.session
696
+ ? (await readFile(marker, 'utf8').catch(() => '')).trim()
697
+ : null;
698
+
699
+ if (!context?.session || stored === context.session) {
700
+ await prepare(cwd, context, descriptors);
701
+ await shield(cwd);
702
+ return cwd;
703
+ }
704
+ // Session mismatch — stale workspace, fall through to fresh clone
705
+ } catch {
706
+ // .git missing or inaccessible — proceed with fresh clone
707
+ }
708
+
709
+ // Remove stale workspace from a previous run. Also attempt worktree
710
+ // unregistration in case an older version created this as a worktree.
711
+ try { await run('git', ['-C', repo, 'worktree', 'remove', '--force', cwd]); } catch { /* ok */ }
712
+ await rm(cwd, { recursive: true, force: true });
713
+
714
+ // Clone from the bare mirror instead of creating a worktree.
715
+ // Worktrees store metadata (refs, HEAD, index) in the parent bare
716
+ // repo directory. Sandboxed agents (Codex) can only write to their
717
+ // workspace — git operations that touch refs fail silently, forcing
718
+ // agents to waste tokens building .git2/.git-local workarounds.
719
+ // A clone puts .git/ inside the workspace so all git state is local.
720
+ const cloneArgs = ['clone', '--single-branch'];
721
+ if (branch && branch !== 'HEAD') cloneArgs.push('--branch', branch);
722
+ cloneArgs.push(repo, cwd);
723
+ await run('git', cloneArgs, { timeout: GIT_TIMEOUT });
724
+
725
+ // Create a named branch for the agent. A named branch lets agents
726
+ // commit and push without extra setup — no "HEAD (no branch)" state.
727
+ const agentBranch = `${name}/work`;
728
+ await run('git', ['-C', cwd, 'checkout', '-b', agentBranch]);
729
+
730
+ // Configure authenticated push URL when context provides credentials.
731
+ // The clone's origin points to the local bare mirror. Agents need to
732
+ // push to GitHub, so override origin with the authenticated URL.
733
+ if (context?.repo && context?.token) {
734
+ const url = `https://x-access-token:${context.token}@github.com/${context.repo}.git`;
735
+ await run('git', ['-C', cwd, 'remote', 'set-url', 'origin', url]);
736
+
737
+ // Configure a credential helper that reads GITHUB_TOKEN from the
738
+ // environment. Defense-in-depth: if an agent creates an alternate
739
+ // git directory (e.g. /tmp/git-dir) without our pre-configured
740
+ // push URL, git push will still authenticate via this helper.
741
+ const helper = [
742
+ '#!/bin/sh',
743
+ 'echo "protocol=https"',
744
+ 'echo "host=github.com"',
745
+ 'echo "username=x-access-token"',
746
+ 'echo "password=$GITHUB_TOKEN"'
747
+ ].join('\n') + '\n';
748
+ await mkdir(join(cwd, '.loreli'), { recursive: true });
749
+ await writeFile(join(cwd, '.loreli', 'git-credential.sh'), helper, { mode: 0o755 });
750
+ await run('git', ['-C', cwd, 'config', 'credential.helper',
751
+ `!${join(cwd, '.loreli', 'git-credential.sh')}`]);
752
+ }
753
+
754
+ // Write descriptor files that require .git/ to exist (e.g.
755
+ // .git/loreli.env for cursor-agent envFile token propagation).
756
+ // When no descriptors provided, fall back to legacy behavior.
757
+ const descs = descriptors ?? legacyDescriptors(context);
758
+ for (const desc of descs) {
759
+ for (const file of desc.files ?? []) {
760
+ const target = join(cwd, file.path);
761
+ const dir = dirname(target);
762
+ await mkdir(dir, { recursive: true });
763
+ const opts = file.mode ? { mode: file.mode } : {};
764
+ await writeFile(target, file.content, opts);
765
+ }
766
+ }
767
+
768
+ // Persist session marker so subsequent spawns in the same session
769
+ // reuse this workspace, but a new session triggers a fresh clone.
770
+ if (context?.session) {
771
+ await mkdir(join(cwd, '.loreli'), { recursive: true });
772
+ await writeFile(marker, context.session, 'utf8');
773
+ }
774
+
775
+ await prepare(cwd, context, descriptors);
776
+ await shield(cwd);
777
+ return cwd;
778
+ }
779
+
780
+ /**
781
+ * Remove an agent's workspace directory and git worktree registration.
782
+ *
783
+ * Attempts to remove the git worktree entry first (if the directory
784
+ * was created via `create()`), then force-removes the directory.
785
+ * Silently ignores missing directories and non-worktree paths.
786
+ *
787
+ * @param {string} name - Agent identity name.
788
+ * @param {string} [root] - Base directory for workspaces (default: ~/.loreli/workspaces).
789
+ * @returns {Promise<void>}
790
+ */
791
+ export async function clean(name, root = DEFAULT_ROOT) {
792
+ const cwd = pathFor(name, root);
793
+
794
+ // Attempt git worktree remove — silently ignore if not a worktree
795
+ try {
796
+ await run('git', ['worktree', 'remove', '--force', cwd]);
797
+ } catch {
798
+ // Not a worktree or already removed — fall through to rm
799
+ }
800
+
801
+ await rm(cwd, { recursive: true, force: true });
802
+ }
803
+
804
+ /**
805
+ * Prune workspaces that do not belong to the current session.
806
+ *
807
+ * Reads the `.loreli/session` marker inside each `loreli-*` directory
808
+ * under the workspace root. Workspaces whose marker does not match
809
+ * `sessionId` — or that have no marker — are removed via `clean()`.
810
+ *
811
+ * Non-loreli directories are left untouched.
812
+ *
813
+ * @param {string} sessionId - Current session ID to keep.
814
+ * @param {string} [root] - Base directory for workspaces.
815
+ * @returns {Promise<string[]>} Agent names whose workspaces were removed.
816
+ */
817
+ export async function prune(sessionId, root = DEFAULT_ROOT) {
818
+ let entries;
819
+ try {
820
+ entries = await readdir(root);
821
+ } catch {
822
+ return [];
823
+ }
824
+
825
+ const PREFIX = 'loreli-';
826
+ const pruned = [];
827
+
828
+ for (const entry of entries) {
829
+ if (!entry.startsWith(PREFIX)) continue;
830
+
831
+ const name = entry.slice(PREFIX.length);
832
+ const marker = join(root, entry, '.loreli', 'session');
833
+
834
+ let owner = '';
835
+ try {
836
+ owner = (await readFile(marker, 'utf8')).trim();
837
+ } catch {
838
+ // No marker — legacy or broken workspace
839
+ }
840
+
841
+ if (owner !== sessionId) {
842
+ await rm(join(root, entry), { recursive: true, force: true });
843
+ pruned.push(name);
844
+ }
845
+ }
846
+
847
+ return pruned;
848
+ }
849
+
850
+ /**
851
+ * Stage all changes, commit, and push to the remote.
852
+ *
853
+ * Designed for MCP tool use — the PR tool calls this before creating
854
+ * a pull request so agents never need to run shell git commands.
855
+ * This sidesteps macOS `com.apple.provenance` xattr issues that cause
856
+ * Codex models to hallucinate permission errors when they see `@` flags
857
+ * in `ls -la` output and refuse to run `git add`.
858
+ *
859
+ * @param {string} cwd - Workspace directory path.
860
+ * @param {string} message - Commit message.
861
+ * @returns {Promise<{pushed: boolean, sha: string}>} Push result.
862
+ */
863
+ /**
864
+ * Reset an agent's workspace branch to the latest remote main.
865
+ *
866
+ * After an agent completes work on an issue and its PR is
867
+ * created, the workspace branch still has the previous issue's
868
+ * changes. This function fetches the latest remote state and
869
+ * force-recreates the agent's branch from the configured base, giving the
870
+ * agent a clean workspace for the next issue.
871
+ *
872
+ * When an issue number is provided, the branch is named per-issue
873
+ * (`name/issue-N`) so each issue gets its own PR branch. This
874
+ * prevents branch collisions when an agent works on multiple issues
875
+ * sequentially.
876
+ *
877
+ * @param {string} cwd - Workspace directory path.
878
+ * @param {string} name - Agent identity name (used for branch naming).
879
+ * @param {number} [issue] - Optional issue number for per-issue branch naming.
880
+ * @returns {Promise<void>}
881
+ */
882
+ /**
883
+ * Remove ignored scaffolding files that would block branch checkout.
884
+ *
885
+ * `git clean -fd` only removes untracked non-ignored files. Scaffolding
886
+ * files (written by prepare() and listed in .gitignore) survive the clean.
887
+ * If a previous agent merged these files into main, `git checkout -B`
888
+ * fails because the untracked-but-ignored local copies conflict with
889
+ * the tracked versions on the target branch.
890
+ *
891
+ * @param {string} cwd - Workspace directory path.
892
+ * @returns {Promise<void>}
893
+ */
894
+ async function cleanScaffolding(cwd) {
895
+ const targets = [
896
+ '.secretlintrc.json', '.secretlintrc', '.mcp.json', '.gitignore'
897
+ ];
898
+ for (const file of targets) {
899
+ await rm(join(cwd, file), { force: true }).catch(() => {});
900
+ }
901
+ const dirs = ['.codex', '.cursor', '.claude', '.loreli'];
902
+ for (const dir of dirs) {
903
+ await rm(join(cwd, dir), { recursive: true, force: true }).catch(() => {});
904
+ }
905
+ }
906
+
907
+ /**
908
+ * Reset a workspace to a clean state branched from the configured base.
909
+ *
910
+ * @param {string} cwd - Workspace directory path.
911
+ * @param {string} name - Agent identity name.
912
+ * @param {number} [issue] - Issue number for per-issue branches.
913
+ * @param {string} [base='main'] - Base branch to reset onto (from merge.base config).
914
+ * @param {object} [opts] - Optional re-scaffolding options.
915
+ * @param {object} [opts.context] - Agent context for config regeneration.
916
+ * @param {object[]} [opts.descriptors] - Scaffold descriptors from backends.
917
+ * @returns {Promise<void>}
918
+ */
919
+ export async function reset(cwd, name, issue, base = 'main', opts = {}) {
920
+ // Explicit base ref fetch required: agent workspaces are cloned with
921
+ // --single-branch, so a bare `fetch origin` only downloads the clone's
922
+ // tracked branch. When merge.base differs (e.g. 'loreli'), the ref
923
+ // won't exist locally without an explicit refspec.
924
+ await run('git', ['-C', cwd, 'fetch', 'origin',
925
+ `+refs/heads/${base}:refs/remotes/origin/${base}`], { timeout: GIT_TIMEOUT });
926
+
927
+ // Discard any uncommitted changes from the previous run
928
+ await run('git', ['-C', cwd, 'checkout', '--', '.']);
929
+ try { await run('git', ['-C', cwd, 'clean', '-fd']); } catch { /* ok */ }
930
+ await cleanScaffolding(cwd);
931
+
932
+ // Per-issue branches avoid collisions when an agent handles
933
+ // multiple issues sequentially — each issue gets its own PR branch.
934
+ const branch = issue ? `${name}/issue-${issue}` : `${name}/work`;
935
+ await run('git', ['-C', cwd, 'checkout', '-B', branch, `origin/${base}`]);
936
+
937
+ // Keep local base in sync so reviewer agents running
938
+ // `git diff <base>...` see correct diffs, not stale refs.
939
+ await run('git', ['-C', cwd, 'branch', '-f', base, `origin/${base}`]);
940
+
941
+ await restoreScaffolding(cwd, opts);
942
+ }
943
+
944
+ /**
945
+ * Checkout a remote branch in a workspace.
946
+ *
947
+ * Fetches the specific branch from origin, discards local changes, and
948
+ * checks out the given remote branch. Used by the review workflow to
949
+ * place reviewer agents on the PR's head branch so they can browse
950
+ * the actual code.
951
+ *
952
+ * The explicit branch fetch is required because agent workspaces are
953
+ * cloned with `--single-branch`, which restricts the default fetch
954
+ * refspec to only the initial branch. A bare `fetch origin`
955
+ * would never download PR branches — `git fetch origin <branch>`
956
+ * bypasses that restriction.
957
+ *
958
+ * @param {string} cwd - Workspace directory path.
959
+ * @param {string} branch - Remote branch name (e.g. 'megatron-0/issue-42').
960
+ * @param {string} [base='main'] - Base branch to keep in sync (from merge.base config).
961
+ * @param {object} [opts] - Optional re-scaffolding options.
962
+ * @param {object} [opts.context] - Agent context for config regeneration.
963
+ * @param {object[]} [opts.descriptors] - Scaffold descriptors from backends.
964
+ * @returns {Promise<void>}
965
+ */
966
+ export async function checkout(cwd, branch, base = 'main', opts = {}) {
967
+ await run('git', ['-C', cwd, 'fetch', 'origin',
968
+ `+refs/heads/${branch}:refs/remotes/origin/${branch}`,
969
+ `+refs/heads/${base}:refs/remotes/origin/${base}`], { timeout: GIT_TIMEOUT });
970
+ await run('git', ['-C', cwd, 'checkout', '--', '.']);
971
+ try { await run('git', ['-C', cwd, 'clean', '-fd']); } catch { /* ok */ }
972
+ await cleanScaffolding(cwd);
973
+ await run('git', ['-C', cwd, 'checkout', '-B', branch, `origin/${branch}`]);
974
+ await run('git', ['-C', cwd, 'branch', '-f', base, `origin/${base}`]);
975
+
976
+ await restoreScaffolding(cwd, opts);
977
+ }
978
+
979
+ /**
980
+ * Re-create workspace scaffolding after branch transitions when context is available.
981
+ *
982
+ * reset()/checkout() delete scaffolding first to avoid untracked-vs-tracked
983
+ * path conflicts on branch switches. When caller provides agent context,
984
+ * re-prepare the workspace immediately so CLI-based skills continue to work.
985
+ *
986
+ * @param {string} cwd - Workspace directory path.
987
+ * @param {object} [opts] - Re-scaffolding options.
988
+ * @param {object} [opts.context] - Agent context for generated config env.
989
+ * @param {object[]} [opts.descriptors] - Scaffold descriptors from backends.
990
+ * @returns {Promise<void>}
991
+ */
992
+ async function restoreScaffolding(cwd, opts = {}) {
993
+ if (opts.context) {
994
+ await prepare(cwd, opts.context);
995
+ await shield(cwd);
996
+ return;
997
+ }
998
+
999
+ if (!opts.descriptors) return;
1000
+ await prepare(cwd, opts.context, opts.descriptors);
1001
+ await shield(cwd);
1002
+ }
1003
+
1004
+ /**
1005
+ * Check if a workspace has uncommitted or untracked changes.
1006
+ *
1007
+ * Uses `git status --porcelain` — any output means there are changes.
1008
+ * Used to detect uncommitted work in an agent's workspace, e.g.
1009
+ * before branch reset or cleanup.
1010
+ *
1011
+ * @param {string} cwd - Workspace directory path.
1012
+ * @returns {Promise<boolean>} True if there are uncommitted changes.
1013
+ */
1014
+ export async function hasChanges(cwd) {
1015
+ const { stdout } = await run('git', ['-C', cwd, 'status', '--porcelain']);
1016
+ return stdout.trim().length > 0;
1017
+ }
1018
+
1019
+ export async function commitAndPush(cwd, message) {
1020
+ await run('git', ['-C', cwd, 'add', '-A']);
1021
+
1022
+ // Unstage scaffolding files after git add -A. If a previous E2E run
1023
+ // merged these into main, they're tracked and .gitignore can't prevent
1024
+ // `git add -A` from staging modifications. `git reset HEAD` unstages
1025
+ // them so the commit diff ONLY contains agent-authored work — no
1026
+ // deletions, no modifications to config files. This avoids reviewer
1027
+ // agents flagging scaffolding changes as "scope violations."
1028
+ // Legacy root-level patterns (.loreli-prompt-*, .loreli-task-*,
1029
+ // .loreli-mcp-ready) kept for repos where a previous run leaked
1030
+ // these into main. git reset HEAD -- .loreli only covers the
1031
+ // directory, not root-level files with a .loreli- prefix.
1032
+ const scaffolding = [
1033
+ '.mcp.json', '.codex', '.cursor', '.claude', '.loreli', '.gitignore',
1034
+ '.secretlintrc.json', '.secretlintrc',
1035
+ '.loreli-prompt-*', '.loreli-task-*', '.loreli-mcp-ready'
1036
+ ];
1037
+ for (const pattern of scaffolding) {
1038
+ try {
1039
+ await run('git', ['-C', cwd, 'reset', 'HEAD', '--', pattern]);
1040
+ } catch { /* not staged or not tracked — nothing to unstage */ }
1041
+ }
1042
+
1043
+ // Secretlint backstop: scan staged files for secrets before committing.
1044
+ // This is the hard enforcement layer — covers all backends including
1045
+ // Codex which has no IDE-level hook support.
1046
+ // Conditional on secretlint being installed in the workspace.
1047
+ const secretlintBin = join(cwd, 'node_modules', '.bin', 'secretlint');
1048
+ let hasSecretlint = true;
1049
+ try { await access(secretlintBin); } catch { hasSecretlint = false; }
1050
+
1051
+ if (hasSecretlint) {
1052
+ const { stdout: staged } = await run('git', ['-C', cwd, 'diff', '--cached', '--name-only']);
1053
+ if (staged.trim()) {
1054
+ const files = staged.trim().split('\n');
1055
+ try {
1056
+ await run(secretlintBin, files, { cwd });
1057
+ } catch {
1058
+ throw new Error('commitAndPush blocked: secretlint detected secrets in staged files');
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ await run('git', ['-C', cwd, 'commit', '-m', message]);
1064
+
1065
+ const { stdout } = await run('git', ['-C', cwd, 'rev-parse', 'HEAD']);
1066
+ const sha = stdout.trim();
1067
+
1068
+ // Push may fail if remote is a local bare mirror (unit tests).
1069
+ // The caller decides whether push failure is fatal.
1070
+ let pushed = false;
1071
+ try {
1072
+ await run('git', ['-C', cwd, 'push', '-u', 'origin', 'HEAD'], { timeout: GIT_TIMEOUT });
1073
+ pushed = true;
1074
+ } catch { /* push failure is non-fatal — caller may retry */ }
1075
+
1076
+ return { pushed, sha };
1077
+ }
1078
+
1079
+ /**
1080
+ * Run git blame on a specific line and parse porcelain output.
1081
+ *
1082
+ * @param {string} cwd - Working directory (repo clone).
1083
+ * @param {string} file - Relative file path.
1084
+ * @param {number} line - 1-based line number.
1085
+ * @returns {Promise<{sha: string, author: string, date: string, summary: string}>}
1086
+ */
1087
+ export async function blame(cwd, file, line) {
1088
+ const { stdout } = await run('git', [
1089
+ '-C', cwd, 'blame', '--porcelain', `-L${line},${line}`, '--', file
1090
+ ]);
1091
+ const lines = stdout.split('\n');
1092
+ const sha = lines[0]?.split(' ')[0] ?? '';
1093
+ let author = '';
1094
+ let date = '';
1095
+ let summary = '';
1096
+ for (const ln of lines) {
1097
+ if (ln.startsWith('author ')) author = ln.slice(7);
1098
+ else if (ln.startsWith('author-time ')) date = new Date(parseInt(ln.slice(12), 10) * 1000).toISOString();
1099
+ else if (ln.startsWith('summary ')) summary = ln.slice(8);
1100
+ }
1101
+ return { sha, author, date, summary };
1102
+ }
1103
+
1104
+ /**
1105
+ * Run git log for a file and parse commit entries.
1106
+ *
1107
+ * @param {string} cwd - Working directory (repo clone).
1108
+ * @param {string} file - Relative file path.
1109
+ * @param {object} [opts] - Options.
1110
+ * @param {number} [opts.limit=10] - Maximum commits to return.
1111
+ * @returns {Promise<Array<{sha: string, date: string, message: string}>>}
1112
+ */
1113
+ export async function gitlog(cwd, file, opts = {}) {
1114
+ const limit = opts.limit ?? 10;
1115
+ const { stdout } = await run('git', [
1116
+ '-C', cwd, 'log', '--follow', `--format=%H %aI %s`, `-n${limit}`, '--', file
1117
+ ]);
1118
+ return stdout.trim().split('\n').filter(Boolean).map(function entry(line) {
1119
+ const first = line.indexOf(' ');
1120
+ const second = line.indexOf(' ', first + 1);
1121
+ return {
1122
+ sha: line.slice(0, first),
1123
+ date: line.slice(first + 1, second),
1124
+ message: line.slice(second + 1)
1125
+ };
1126
+ });
1127
+ }