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.
- package/LICENSE +1 -1
- package/README.md +710 -97
- package/bin/loreli.js +89 -0
- package/package.json +77 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +684 -0
- package/packages/agent/README.md +606 -0
- package/packages/agent/src/backends/claude.js +387 -0
- package/packages/agent/src/backends/codex.js +351 -0
- package/packages/agent/src/backends/cursor.js +371 -0
- package/packages/agent/src/backends/index.js +486 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +275 -0
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +124 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +159 -0
- package/packages/agent/src/output.js +62 -0
- package/packages/agent/src/session.js +162 -0
- package/packages/agent/src/trace.js +186 -0
- package/packages/classify/README.md +136 -0
- package/packages/classify/prompts/blocker.md +12 -0
- package/packages/classify/prompts/feedback.md +14 -0
- package/packages/classify/prompts/pane-state.md +20 -0
- package/packages/classify/src/index.js +81 -0
- package/packages/config/README.md +898 -0
- package/packages/config/src/defaults.js +145 -0
- package/packages/config/src/index.js +223 -0
- package/packages/config/src/schema.js +291 -0
- package/packages/config/src/validate.js +160 -0
- package/packages/context/README.md +165 -0
- package/packages/context/src/index.js +198 -0
- package/packages/hub/README.md +338 -0
- package/packages/hub/src/base.js +154 -0
- package/packages/hub/src/github.js +1597 -0
- package/packages/hub/src/index.js +79 -0
- package/packages/hub/src/labels.js +48 -0
- package/packages/identity/README.md +288 -0
- package/packages/identity/src/index.js +620 -0
- package/packages/identity/src/themes/avatar.js +217 -0
- package/packages/identity/src/themes/digimon.js +217 -0
- package/packages/identity/src/themes/dragonball.js +217 -0
- package/packages/identity/src/themes/lotr.js +217 -0
- package/packages/identity/src/themes/marvel.js +217 -0
- package/packages/identity/src/themes/pokemon.js +217 -0
- package/packages/identity/src/themes/starwars.js +217 -0
- package/packages/identity/src/themes/transformers.js +217 -0
- package/packages/identity/src/themes/zelda.js +217 -0
- package/packages/knowledge/README.md +217 -0
- package/packages/knowledge/src/index.js +243 -0
- package/packages/log/README.md +93 -0
- package/packages/log/src/index.js +252 -0
- package/packages/marker/README.md +200 -0
- package/packages/marker/src/index.js +184 -0
- package/packages/mcp/README.md +323 -0
- package/packages/mcp/instructions.md +126 -0
- package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
- package/packages/mcp/scaffolding/loreli.yml +491 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +600 -0
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +450 -0
- package/packages/mcp/src/tools/context.js +200 -0
- package/packages/mcp/src/tools/github.js +1163 -0
- package/packages/mcp/src/tools/hitl.js +162 -0
- package/packages/mcp/src/tools/index.js +18 -0
- package/packages/mcp/src/tools/refactor.js +227 -0
- package/packages/mcp/src/tools/repo.js +44 -0
- package/packages/mcp/src/tools/start.js +904 -0
- package/packages/mcp/src/tools/status.js +149 -0
- package/packages/mcp/src/tools/work.js +134 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1492 -0
- package/packages/planner/README.md +251 -0
- package/packages/planner/prompts/plan-reviewer.md +109 -0
- package/packages/planner/prompts/planner.md +191 -0
- package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
- package/packages/planner/src/index.js +1381 -0
- package/packages/review/README.md +129 -0
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +1403 -0
- package/packages/risk/README.md +178 -0
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +439 -0
- package/packages/session/README.md +165 -0
- package/packages/session/src/index.js +215 -0
- package/packages/test-utils/README.md +96 -0
- package/packages/test-utils/src/index.js +354 -0
- package/packages/tmux/README.md +261 -0
- package/packages/tmux/src/index.js +501 -0
- package/packages/workflow/README.md +317 -0
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +660 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1127 -0
- 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
|
+
}
|