loreli 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -26
- package/package.json +17 -14
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +33 -5
- package/packages/agent/README.md +107 -18
- package/packages/agent/src/backends/claude.js +111 -11
- package/packages/agent/src/backends/codex.js +78 -5
- package/packages/agent/src/backends/cursor.js +104 -27
- package/packages/agent/src/backends/index.js +162 -5
- package/packages/agent/src/cli.js +80 -3
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +39 -34
- package/packages/agent/src/models.js +24 -6
- 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 +156 -91
- package/packages/config/src/defaults.js +32 -21
- package/packages/config/src/index.js +33 -2
- package/packages/config/src/schema.js +57 -39
- package/packages/hub/src/github.js +59 -20
- package/packages/identity/README.md +1 -1
- package/packages/identity/src/index.js +2 -2
- package/packages/knowledge/README.md +86 -106
- package/packages/knowledge/src/index.js +56 -225
- package/packages/mcp/README.md +51 -7
- package/packages/mcp/instructions.md +6 -1
- package/packages/mcp/scaffolding/loreli.yml +115 -77
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +1 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +4 -1
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +4 -1
- package/packages/mcp/src/index.js +45 -16
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +34 -13
- package/packages/mcp/src/tools/context.js +3 -2
- package/packages/mcp/src/tools/github.js +11 -47
- package/packages/mcp/src/tools/hitl.js +19 -6
- package/packages/mcp/src/tools/index.js +2 -1
- 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 +159 -90
- package/packages/mcp/src/tools/status.js +5 -2
- package/packages/mcp/src/tools/work.js +18 -8
- package/packages/orchestrator/src/index.js +345 -79
- package/packages/planner/README.md +84 -1
- 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 +326 -111
- package/packages/review/README.md +2 -2
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +196 -76
- package/packages/risk/README.md +81 -22
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +44 -33
- package/packages/tmux/src/index.js +61 -12
- package/packages/workflow/README.md +18 -14
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +191 -12
- package/packages/workspace/README.md +2 -2
- package/packages/workspace/src/index.js +69 -18
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { execFile as execFileCb, execFileSync } from 'node:child_process';
|
|
2
|
+
import { writeFileSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
2
6
|
import { promisify } from 'node:util';
|
|
3
7
|
|
|
4
8
|
const execFile = promisify(execFileCb);
|
|
5
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Pattern for safe tmux session names — alphanumeric, hyphens, and underscores only.
|
|
12
|
+
* Prevents shell injection and tmux command-line metacharacter issues.
|
|
13
|
+
* @type {RegExp}
|
|
14
|
+
*/
|
|
15
|
+
const SAFE_SESSION_NAME = /^[a-zA-Z0-9_-]+$/;
|
|
16
|
+
|
|
6
17
|
/**
|
|
7
18
|
* Clean, modern, ESM Node.js wrapper for tmux with pane-level control.
|
|
8
19
|
*
|
|
@@ -85,6 +96,9 @@ export class Tmux {
|
|
|
85
96
|
* @throws {Error} When a session with the same name already exists.
|
|
86
97
|
*/
|
|
87
98
|
async create(name, opts = {}) {
|
|
99
|
+
if (!SAFE_SESSION_NAME.test(name))
|
|
100
|
+
throw new Error(`Invalid session name "${name}": must match ${SAFE_SESSION_NAME}`);
|
|
101
|
+
|
|
88
102
|
if (await this.has(name)) {
|
|
89
103
|
throw new Error(`Session "${name}" already exists`);
|
|
90
104
|
}
|
|
@@ -98,7 +112,13 @@ export class Tmux {
|
|
|
98
112
|
args.push('-P', '-F', '#{pane_id}');
|
|
99
113
|
if (opts.cwd) args.push('-c', opts.cwd);
|
|
100
114
|
args.push(opts.command);
|
|
101
|
-
|
|
115
|
+
let paneId;
|
|
116
|
+
try {
|
|
117
|
+
paneId = await this.#exec(args);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
try { await this.#exec(['kill-session', '-t', name]); } catch { /* best-effort */ }
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
102
122
|
this.#sessions.add(name);
|
|
103
123
|
return paneId;
|
|
104
124
|
}
|
|
@@ -295,24 +315,49 @@ export class Tmux {
|
|
|
295
315
|
return this.#exec(args);
|
|
296
316
|
}
|
|
297
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Maximum length for direct send-keys. Longer text uses load-buffer
|
|
320
|
+
* to avoid terminal truncation.
|
|
321
|
+
* @type {number}
|
|
322
|
+
*/
|
|
323
|
+
static SEND_MAX = 200;
|
|
324
|
+
|
|
298
325
|
/**
|
|
299
326
|
* Send text to a pane followed by Enter.
|
|
300
327
|
*
|
|
328
|
+
* Clears any partial input first with C-u, then delivers the text.
|
|
329
|
+
* For short single-line text, uses `send-keys -l` (literal mode) so
|
|
330
|
+
* strings like "Enter" or "Space" are not interpreted as key names.
|
|
331
|
+
* For long or multiline text, writes to a temp file and uses
|
|
332
|
+
* `load-buffer` / `paste-buffer` to avoid terminal truncation.
|
|
333
|
+
*
|
|
301
334
|
* Text and Enter are sent as separate operations with a brief delay.
|
|
302
|
-
* TUI applications
|
|
303
|
-
*
|
|
304
|
-
* delivers text + Enter in a single call, both arrive in one PTY
|
|
305
|
-
* read buffer. The TUI's event loop processes them in a single tick,
|
|
306
|
-
* causing Enter to be consumed by the text rendering pipeline instead
|
|
307
|
-
* of triggering "submit". Splitting into two calls guarantees Enter
|
|
308
|
-
* arrives in a separate stdin data event.
|
|
335
|
+
* TUI applications process keystrokes asynchronously — splitting
|
|
336
|
+
* guarantees Enter arrives in a separate stdin data event.
|
|
309
337
|
*
|
|
310
338
|
* @param {string} target - Pane ID or session:window.pane target.
|
|
311
339
|
* @param {string} text - The text to send.
|
|
312
340
|
* @returns {Promise<void>}
|
|
313
341
|
*/
|
|
314
342
|
async send(target, text) {
|
|
315
|
-
await this.#exec(['send-keys', '-t', target,
|
|
343
|
+
await this.#exec(['send-keys', '-t', target, 'C-u']);
|
|
344
|
+
|
|
345
|
+
if (text.includes('\n') || text.length > Tmux.SEND_MAX) {
|
|
346
|
+
const id = randomUUID().slice(0, 8);
|
|
347
|
+
const bufName = `loreli-${id}`;
|
|
348
|
+
const tmpPath = join(tmpdir(), `loreli-send-${id}.txt`);
|
|
349
|
+
writeFileSync(tmpPath, text, { encoding: 'utf-8', mode: 0o600 });
|
|
350
|
+
try {
|
|
351
|
+
await this.#exec(['load-buffer', '-b', bufName, tmpPath]);
|
|
352
|
+
await this.#exec(['paste-buffer', '-b', bufName, '-t', target, '-d']);
|
|
353
|
+
} finally {
|
|
354
|
+
try { unlinkSync(tmpPath); } catch { /* may already be removed */ }
|
|
355
|
+
try { await this.#exec(['delete-buffer', '-b', bufName]); } catch { /* may already be deleted */ }
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
await this.#exec(['send-keys', '-l', '-t', target, text]);
|
|
359
|
+
}
|
|
360
|
+
|
|
316
361
|
await new Promise(function settle(r) { setTimeout(r, 200); });
|
|
317
362
|
await this.#exec(['send-keys', '-t', target, 'Enter']);
|
|
318
363
|
}
|
|
@@ -342,9 +387,13 @@ export class Tmux {
|
|
|
342
387
|
* @returns {Promise<string>} The captured pane content.
|
|
343
388
|
*/
|
|
344
389
|
async capture(target, lines = 500) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
390
|
+
try {
|
|
391
|
+
return await this.#exec([
|
|
392
|
+
'capture-pane', '-p', '-J', '-t', target, '-S', `-${lines}`
|
|
393
|
+
]);
|
|
394
|
+
} catch {
|
|
395
|
+
return '';
|
|
396
|
+
}
|
|
348
397
|
}
|
|
349
398
|
|
|
350
399
|
/**
|
|
@@ -104,9 +104,9 @@ Create and spawn a new agent via the orchestrator's factory. Convenience wrapper
|
|
|
104
104
|
|
|
105
105
|
#### `workflow.custom(opts?)` → Promise\<string\>
|
|
106
106
|
|
|
107
|
-
Load the project-specific custom prompt from the `
|
|
107
|
+
Load the project-specific custom prompt from the `workflows.{role}.prompt` config key (e.g. `workflows.action.prompt`, `workflows.reviewer.prompt`, `workflows.planner.prompt`, `workflows.risk.prompt`). By default, resolves this workflow's `static role`; pass `opts.role` to resolve another role during cross-role rendering. Reads each role-specific file once from the target repo via the hub and caches it on the instance. Returns an empty string when:
|
|
108
108
|
|
|
109
|
-
- `
|
|
109
|
+
- `workflows.{role}.prompt` is not configured for this workflow's role
|
|
110
110
|
- The hub is unavailable (e.g. unit tests)
|
|
111
111
|
- The repo is not set on the orchestrator
|
|
112
112
|
- The file cannot be read (missing or inaccessible)
|
|
@@ -115,13 +115,13 @@ Rendering never fails due to a missing custom prompt — this method degrades si
|
|
|
115
115
|
|
|
116
116
|
| Parameter | Type | Description |
|
|
117
117
|
|-----------|------|-------------|
|
|
118
|
-
| `opts.role` | `string` | Optional role override for `
|
|
118
|
+
| `opts.role` | `string` | Optional role override for `workflows.{role}.prompt` lookup |
|
|
119
119
|
|
|
120
120
|
**Returns**: `Promise<string>` — Custom prompt text, or empty string.
|
|
121
121
|
|
|
122
122
|
#### `workflow.render(vars)` → Promise\<string\>
|
|
123
123
|
|
|
124
|
-
Load and render this workflow's prompt template with Mustache variables. Automatically prepends the shared autonomous-mode preamble and any per-role custom prompt from `
|
|
124
|
+
Load and render this workflow's prompt template with Mustache variables. Automatically prepends the shared autonomous-mode preamble and any per-role custom prompt from `workflows.{role}.prompt` (see [Autonomous Preamble](#autonomous-preamble) and [Custom Prompt Extensions](#custom-prompt-extensions) below).
|
|
125
125
|
|
|
126
126
|
| Parameter | Type | Description |
|
|
127
127
|
|-----------|------|-------------|
|
|
@@ -137,7 +137,7 @@ Load and render an arbitrary Mustache template. Used for cross-role rendering (e
|
|
|
137
137
|
|-----------|------|-------------|
|
|
138
138
|
| `path` | `string` | Absolute path to a `.md` template file |
|
|
139
139
|
| `vars` | `object` | Template variables |
|
|
140
|
-
| `opts.role` | `string` | Optional role override for custom prompt lookup. When set, resolves `
|
|
140
|
+
| `opts.role` | `string` | Optional role override for custom prompt lookup. When set, resolves `workflows.{opts.role}.prompt` instead of this workflow's `static role` |
|
|
141
141
|
|
|
142
142
|
**Returns**: `Promise<string>` — Rendered prompt text with preamble and custom prompt prepended.
|
|
143
143
|
|
|
@@ -274,24 +274,28 @@ The preamble is loaded once and cached for the lifetime of the process.
|
|
|
274
274
|
|
|
275
275
|
## Custom Prompt Extensions
|
|
276
276
|
|
|
277
|
-
Projects can inject per-role custom instructions into agent prompts by setting `
|
|
277
|
+
Projects can inject per-role custom instructions into agent prompts by setting `workflows.{role}.prompt` in `loreli.yml`. Each role maps to a workflow and the value is a file path relative to the repository root.
|
|
278
278
|
|
|
279
279
|
```yaml
|
|
280
280
|
# loreli.yml
|
|
281
|
-
|
|
282
|
-
action:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
281
|
+
workflows:
|
|
282
|
+
action:
|
|
283
|
+
prompt: .loreli/action.md
|
|
284
|
+
reviewer:
|
|
285
|
+
prompt: .loreli/review.md
|
|
286
|
+
planner:
|
|
287
|
+
prompt: .loreli/planner.md
|
|
288
|
+
risk:
|
|
289
|
+
prompt: .loreli/risk.md
|
|
286
290
|
```
|
|
287
291
|
|
|
288
|
-
When configured, `render()` and `renderFrom()` resolve `
|
|
292
|
+
When configured, `render()` and `renderFrom()` resolve `workflows.{role}.prompt` from config, read the file from the target repo via the hub, and insert its content between the autonomous preamble and the role-specific template:
|
|
289
293
|
|
|
290
294
|
1. **Autonomous preamble** (`prompts/preamble.md`) — always present
|
|
291
|
-
2. **Custom prompt** (`
|
|
295
|
+
2. **Custom prompt** (`workflows.{role}.prompt`) — when configured for the current role
|
|
292
296
|
3. **Role template** (action/planner/reviewer `.md` file)
|
|
293
297
|
|
|
294
|
-
Each role resolves independently — an action workflow only reads `
|
|
298
|
+
Each role resolves independently — an action workflow only reads `workflows.action.prompt`, a reviewer only reads `workflows.reviewer.prompt`. Roles without a configured prompt file are unaffected.
|
|
295
299
|
|
|
296
300
|
The file is read once on the first `render()` call and cached on the `Workflow` instance for the remainder of the session. If the file is missing or unreadable, rendering continues normally — agents are never blocked by a missing custom prompt.
|
|
297
301
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<autonomous>
|
|
2
|
+
|
|
3
|
+
You are running autonomously in a headless tmux pane with no human operator. There is no user to respond to questions.
|
|
4
|
+
|
|
5
|
+
- **Never ask** clarifying questions, present multiple-choice options, or wait for user input.
|
|
6
|
+
- **Never invoke** interactive skills (brainstorming, design reviews, or anything that requires human approval gates).
|
|
7
|
+
- **Always proceed** with your best judgment. When multiple approaches exist, pick the most reasonable default and document your reasoning.
|
|
8
|
+
- If a skill or workflow would normally require user confirmation, skip it entirely and proceed directly to your task.
|
|
9
|
+
- Before planning or coding, explicitly check whether `AGENTS.md` exists at the repository root. If it exists, read it and treat it as normative repository policy for this task.
|
|
10
|
+
- `AGENTS.md` policy takes precedence over generic defaults for repository-specific behavior. Follow it without waiting for confirmation.
|
|
11
|
+
|
|
12
|
+
Violations of these rules will permanently stall your session.
|
|
13
|
+
|
|
14
|
+
</autonomous>
|
|
@@ -3,6 +3,9 @@ import { join, dirname } from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import Mustache from 'mustache';
|
|
5
5
|
import { mark, has, parse } from 'loreli/marker';
|
|
6
|
+
import { Tmux } from 'loreli/tmux';
|
|
7
|
+
import { classify } from 'loreli/classify';
|
|
8
|
+
import { logger } from 'loreli/log';
|
|
6
9
|
|
|
7
10
|
export { responder } from './proof-of-life.js';
|
|
8
11
|
|
|
@@ -16,10 +19,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
16
19
|
* @type {string}
|
|
17
20
|
*/
|
|
18
21
|
const PREAMBLE = join(__dirname, '..', 'prompts', 'preamble.md');
|
|
22
|
+
const log = logger('workflow');
|
|
19
23
|
|
|
20
24
|
/** @type {string|null} Cached preamble text — loaded once, reused. */
|
|
21
25
|
let _preamble = null;
|
|
22
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Maximum backend-diagnose checks after prompt dispatch.
|
|
29
|
+
*
|
|
30
|
+
* @type {number}
|
|
31
|
+
*/
|
|
32
|
+
const DISPATCH_NUDGE_CHECKS = 48;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Delay between post-dispatch diagnose checks.
|
|
36
|
+
*
|
|
37
|
+
* @type {number}
|
|
38
|
+
*/
|
|
39
|
+
const DISPATCH_NUDGE_INTERVAL = 5000;
|
|
40
|
+
|
|
23
41
|
/**
|
|
24
42
|
* Load the shared preamble, caching after the first read.
|
|
25
43
|
*
|
|
@@ -30,6 +48,100 @@ async function preamble() {
|
|
|
30
48
|
return _preamble;
|
|
31
49
|
}
|
|
32
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Wait for the specified duration.
|
|
53
|
+
*
|
|
54
|
+
* @param {number} ms - Milliseconds to sleep.
|
|
55
|
+
* @returns {Promise<void>}
|
|
56
|
+
*/
|
|
57
|
+
async function sleep(ms) {
|
|
58
|
+
await new Promise(function onTimer(resolve) {
|
|
59
|
+
const handle = setTimeout(resolve, ms);
|
|
60
|
+
handle?.unref?.();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Normalize remedy values into tmux key sequences.
|
|
66
|
+
*
|
|
67
|
+
* Classifier output may provide remedy as a single space-delimited string,
|
|
68
|
+
* while backend fallback diagnosers usually return string arrays.
|
|
69
|
+
*
|
|
70
|
+
* @param {string|string[]|null|undefined} remedy - Remedy value from diagnosis.
|
|
71
|
+
* @returns {string[]} Keys to send through tmux.
|
|
72
|
+
*/
|
|
73
|
+
function keys(remedy) {
|
|
74
|
+
if (Array.isArray(remedy)) {
|
|
75
|
+
const list = remedy.filter(Boolean);
|
|
76
|
+
if (list.length > 0) return list;
|
|
77
|
+
return ['Enter'];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof remedy === 'string') {
|
|
81
|
+
const list = remedy.split(/\s+/).filter(Boolean);
|
|
82
|
+
if (list.length > 0) return list;
|
|
83
|
+
return ['Enter'];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return ['Enter'];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Return whether a diagnosis category requires immediate remediation.
|
|
91
|
+
*
|
|
92
|
+
* @param {string|undefined} category - Diagnosis category.
|
|
93
|
+
* @returns {boolean} True when remediation should be applied immediately.
|
|
94
|
+
*/
|
|
95
|
+
function actionable(category) {
|
|
96
|
+
return category === 'option_dialog'
|
|
97
|
+
|| category === 'waiting_for_input'
|
|
98
|
+
|| category === 'fatal'
|
|
99
|
+
|| category === 'dead';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run pane classification through loreli/classify.
|
|
104
|
+
*
|
|
105
|
+
* This is best-effort: classification errors should not block dispatch
|
|
106
|
+
* nudging because backend regex diagnosis can still recover option dialogs.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} orchestrator - Orchestrator instance containing config/backends.
|
|
109
|
+
* @param {object} agent - Agent metadata.
|
|
110
|
+
* @param {string} pane - Captured pane output.
|
|
111
|
+
* @returns {Promise<object|null>} Classifier diagnosis, or null when unavailable/failed.
|
|
112
|
+
*/
|
|
113
|
+
async function diagnose(orchestrator, agent, pane) {
|
|
114
|
+
const backends = orchestrator?.backendRegistry;
|
|
115
|
+
if (!backends?.oneshot) return null;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const result = await classify('pane-state', pane, {
|
|
119
|
+
backends,
|
|
120
|
+
config: orchestrator?.cfg,
|
|
121
|
+
vars: { model: agent.model, backend: agent.backend, role: agent.role }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (result?.category === 'option_dialog') {
|
|
125
|
+
log.info(
|
|
126
|
+
`dispatch nudge classify: ${agent.identity?.name ?? 'unknown'} ${agent.backend} `
|
|
127
|
+
+ `${result.category} — ${result.reasoning}`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return result;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
log.debug(`dispatch nudge classify failed for ${agent.identity?.name ?? 'unknown'}: ${err.message}`);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Swallow background nudge errors.
|
|
140
|
+
*
|
|
141
|
+
* @returns {void}
|
|
142
|
+
*/
|
|
143
|
+
function ignoreNudgeError() {}
|
|
144
|
+
|
|
33
145
|
/**
|
|
34
146
|
* Abstract base class for role-specific workflow packages.
|
|
35
147
|
*
|
|
@@ -138,22 +250,21 @@ export class Workflow {
|
|
|
138
250
|
/**
|
|
139
251
|
* Load the project-specific custom prompt for this workflow's role.
|
|
140
252
|
*
|
|
141
|
-
* Resolves `
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* rendering never fails due to a missing custom prompt.
|
|
253
|
+
* Resolves `workflows.{role}.prompt` from config, reads the file
|
|
254
|
+
* once from the target repo via the hub, and caches the result on
|
|
255
|
+
* this instance. Returns an empty string when the config key is
|
|
256
|
+
* unset, the hub is unavailable, or the file cannot be read —
|
|
257
|
+
* prompt rendering never fails due to a missing custom prompt.
|
|
147
258
|
*
|
|
148
259
|
* @param {object} [opts] - Options.
|
|
149
|
-
* @param {string} [opts.role] - Role to resolve
|
|
260
|
+
* @param {string} [opts.role] - Role to resolve prompt for.
|
|
150
261
|
* @returns {Promise<string>} Custom prompt text, or empty string.
|
|
151
262
|
*/
|
|
152
263
|
async custom(opts = {}) {
|
|
153
264
|
const role = opts.role ?? this.constructor.role;
|
|
154
265
|
if (this._custom.has(role)) return this._custom.get(role) ?? '';
|
|
155
266
|
|
|
156
|
-
const path = this.orchestrator.cfg?.get?.(`
|
|
267
|
+
const path = this.orchestrator.cfg?.get?.(`workflows.${role}.prompt`);
|
|
157
268
|
if (!path || !this.hub?.read || !this.orchestrator.repo) {
|
|
158
269
|
this._custom.set(role, null);
|
|
159
270
|
return '';
|
|
@@ -175,8 +286,8 @@ export class Workflow {
|
|
|
175
286
|
* Automatically injects the workflow's `role` into the template
|
|
176
287
|
* variables so subclasses don't need to pass it explicitly.
|
|
177
288
|
* Prepends the shared autonomous-mode preamble and any project-
|
|
178
|
-
* specific custom prompt from `
|
|
179
|
-
* prompt carries the headless operation directive and project rules.
|
|
289
|
+
* specific custom prompt from `workflows.{role}.prompt` so every
|
|
290
|
+
* agent prompt carries the headless operation directive and project rules.
|
|
180
291
|
*
|
|
181
292
|
* @param {object} vars - Mustache template variables.
|
|
182
293
|
* @returns {Promise<string>} Rendered prompt text.
|
|
@@ -198,12 +309,12 @@ export class Workflow {
|
|
|
198
309
|
* Used for cross-role rendering where one workflow needs another
|
|
199
310
|
* role's prompt (e.g. review rendering the action prompt for forward).
|
|
200
311
|
* Prepends the shared autonomous-mode preamble and any project-
|
|
201
|
-
* specific custom prompt from `
|
|
312
|
+
* specific custom prompt from `workflows.{role}.prompt`.
|
|
202
313
|
*
|
|
203
314
|
* @param {string} path - Absolute path to a .md template file.
|
|
204
315
|
* @param {object} vars - Mustache template variables.
|
|
205
316
|
* @param {object} [opts] - Options.
|
|
206
|
-
* @param {string} [opts.role] - Role to resolve
|
|
317
|
+
* @param {string} [opts.role] - Role to resolve prompt for.
|
|
207
318
|
* @returns {Promise<string>} Rendered prompt text.
|
|
208
319
|
*/
|
|
209
320
|
async renderFrom(path, vars, opts = {}) {
|
|
@@ -253,6 +364,74 @@ export class Workflow {
|
|
|
253
364
|
async dispatch(agent, vars) {
|
|
254
365
|
const prompt = await this.render(vars);
|
|
255
366
|
await agent.send(prompt);
|
|
367
|
+
this.nudge(agent).catch(ignoreNudgeError);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Nudge known CLI option dialogs after prompt dispatch.
|
|
372
|
+
*
|
|
373
|
+
* Some backends surface interactive confirmation dialogs seconds
|
|
374
|
+
* after a prompt is submitted (for example MCP tool approvals).
|
|
375
|
+
* This helper polls pane output and applies backend-provided
|
|
376
|
+
* dialog remedies so workflows remain unattended.
|
|
377
|
+
*
|
|
378
|
+
* @param {object} agent - Target agent with backend/pane metadata.
|
|
379
|
+
* @returns {Promise<void>}
|
|
380
|
+
*/
|
|
381
|
+
async nudge(agent) {
|
|
382
|
+
if (!agent?.backend || !agent?.paneId || !agent?.capture) return;
|
|
383
|
+
|
|
384
|
+
const registry = this.orchestrator?.backendRegistry;
|
|
385
|
+
if (!registry?.diagnose) return;
|
|
386
|
+
|
|
387
|
+
for (let i = 0; i < DISPATCH_NUDGE_CHECKS; i++) {
|
|
388
|
+
if (agent.state === 'dormant') return;
|
|
389
|
+
|
|
390
|
+
let pane;
|
|
391
|
+
try {
|
|
392
|
+
pane = await agent.capture(this.orchestrator?.cfg?.get?.('classify.maxLines') ?? 100);
|
|
393
|
+
} catch {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const llm = await diagnose(this.orchestrator, agent, pane);
|
|
398
|
+
const fallback = registry.diagnose(agent.backend, pane);
|
|
399
|
+
|
|
400
|
+
let result = llm;
|
|
401
|
+
if (!result?.category && fallback?.category) {
|
|
402
|
+
result = fallback;
|
|
403
|
+
log.info(
|
|
404
|
+
`dispatch nudge fallback diagnose: ${agent.identity?.name ?? 'unknown'} ${agent.backend} `
|
|
405
|
+
+ `${fallback.category} — ${fallback.reasoning}`
|
|
406
|
+
);
|
|
407
|
+
} else if (actionable(fallback?.category) && !actionable(result?.category)) {
|
|
408
|
+
result = fallback;
|
|
409
|
+
log.info(
|
|
410
|
+
`dispatch nudge fallback override: ${agent.identity?.name ?? 'unknown'} ${agent.backend} `
|
|
411
|
+
+ `${fallback.category} over ${llm?.category ?? 'unknown'} — ${fallback.reasoning}`
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (result?.category === 'option_dialog') {
|
|
416
|
+
const seq = keys(result.remedy);
|
|
417
|
+
log.info(`dispatch nudge: ${agent.identity?.name ?? 'unknown'} ${agent.backend} option_dialog — sending ${seq.join('+')}`);
|
|
418
|
+
try {
|
|
419
|
+
const tmux = new Tmux();
|
|
420
|
+
await tmux.keys(agent.paneId, ...seq);
|
|
421
|
+
} catch {
|
|
422
|
+
log.debug(`dispatch nudge: keys failed for ${agent.identity?.name ?? 'unknown'}`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
this.orchestrator.activity?.(agent.identity?.name);
|
|
426
|
+
|
|
427
|
+
if (i < DISPATCH_NUDGE_CHECKS - 1)
|
|
428
|
+
await sleep(DISPATCH_NUDGE_INTERVAL);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (i < DISPATCH_NUDGE_CHECKS - 1)
|
|
433
|
+
await sleep(DISPATCH_NUDGE_INTERVAL);
|
|
434
|
+
}
|
|
256
435
|
}
|
|
257
436
|
|
|
258
437
|
// ── Proof of Life ───────────────────────────────────
|
|
@@ -18,11 +18,11 @@ This package prepares agent directories, scaffolds local MCP configs and safety
|
|
|
18
18
|
|
|
19
19
|
#### `codexToml(context?)` → `string`
|
|
20
20
|
|
|
21
|
-
Generate Codex TOML config content with optional agent context env keys.
|
|
21
|
+
Generate Codex TOML config content with `env_vars = ["GITHUB_TOKEN"]` forwarding and optional agent context env keys.
|
|
22
22
|
|
|
23
23
|
#### `mcpJson(context?, opts?)` → `string`
|
|
24
24
|
|
|
25
|
-
Generate JSON MCP config content with optional agent context, token reference (`tokenRef`), and env file path (`envFile`).
|
|
25
|
+
Generate JSON MCP config content with optional agent context, token reference (`tokenRef`), and env file path (`envFile`). Cursor scaffolding keeps both interpolation and envFile fallback for token propagation.
|
|
26
26
|
|
|
27
27
|
### Security/Hook Generators
|
|
28
28
|
|
|
@@ -4,7 +4,45 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
|
|
7
|
-
const
|
|
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
|
+
}
|
|
8
46
|
|
|
9
47
|
/**
|
|
10
48
|
* Default root directory for agent workspaces.
|
|
@@ -38,7 +76,13 @@ export const MCP_JSON = JSON.stringify({
|
|
|
38
76
|
* MCP config for TOML-based CLI tools (Codex).
|
|
39
77
|
* @type {string}
|
|
40
78
|
*/
|
|
41
|
-
export const CODEX_TOML =
|
|
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');
|
|
42
86
|
|
|
43
87
|
/**
|
|
44
88
|
* Build Codex TOML config, optionally injecting agent context env vars.
|
|
@@ -50,13 +94,10 @@ export const CODEX_TOML = '[mcp_servers.loreli]\ncommand = "npx"\nargs = ["lorel
|
|
|
50
94
|
* @returns {string} TOML string for .codex/config.toml.
|
|
51
95
|
*/
|
|
52
96
|
export function codexToml(context) {
|
|
53
|
-
let toml =
|
|
97
|
+
let toml = CODEX_TOML;
|
|
54
98
|
if (context?.session) {
|
|
55
99
|
toml += `\n[mcp_servers.loreli.env]\nLORELI_SESSION = "${context.session}"\nLORELI_AGENT = "${context.agent}"\nLORELI_REPO = "${context.repo}"\n`;
|
|
56
100
|
if (context.home) toml += `LORELI_HOME = "${context.home}"\n`;
|
|
57
|
-
// Codex env_vars whitelists parent process env vars for forwarding
|
|
58
|
-
// to the MCP subprocess — no literal secret in the file.
|
|
59
|
-
toml += 'env_vars = ["GITHUB_TOKEN"]\n';
|
|
60
101
|
}
|
|
61
102
|
return toml;
|
|
62
103
|
}
|
|
@@ -314,12 +355,19 @@ export async function mirror(url, opts = {}) {
|
|
|
314
355
|
authUrl = parsed.toString();
|
|
315
356
|
}
|
|
316
357
|
|
|
358
|
+
const gitEnv = { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, timeout: GIT_TIMEOUT };
|
|
359
|
+
|
|
360
|
+
let exists = false;
|
|
317
361
|
try {
|
|
318
362
|
await access(dir);
|
|
319
|
-
|
|
320
|
-
} catch {
|
|
363
|
+
exists = true;
|
|
364
|
+
} catch { /* directory doesn't exist */ }
|
|
365
|
+
|
|
366
|
+
if (exists) {
|
|
367
|
+
await run('git', ['-C', dir, 'fetch', '--prune'], gitEnv);
|
|
368
|
+
} else {
|
|
321
369
|
await mkdir(reposDir, { recursive: true });
|
|
322
|
-
await run('git', ['clone', '--bare', authUrl, dir],
|
|
370
|
+
await run('git', ['clone', '--bare', authUrl, dir], gitEnv);
|
|
323
371
|
}
|
|
324
372
|
|
|
325
373
|
return dir;
|
|
@@ -337,6 +385,7 @@ export async function mirror(url, opts = {}) {
|
|
|
337
385
|
* @returns {string} Absolute path to the agent's workspace.
|
|
338
386
|
*/
|
|
339
387
|
export function pathFor(name, root = DEFAULT_ROOT) {
|
|
388
|
+
assertSafe(name, 'agent name');
|
|
340
389
|
return join(root, `loreli-${name}`);
|
|
341
390
|
}
|
|
342
391
|
|
|
@@ -359,10 +408,10 @@ export function pathFor(name, root = DEFAULT_ROOT) {
|
|
|
359
408
|
* - Cursor IDE: `${env:GITHUB_TOKEN}`
|
|
360
409
|
* The file only contains a reference — never the literal secret.
|
|
361
410
|
* @param {string} [opts.envFile] - Path to a `.env` file that the
|
|
362
|
-
* MCP host loads at spawn time. Used
|
|
363
|
-
*
|
|
364
|
-
* the referenced file (inside `.git/`,
|
|
365
|
-
* JSON config itself.
|
|
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.
|
|
366
415
|
* @returns {string} JSON string for .mcp.json files.
|
|
367
416
|
*/
|
|
368
417
|
export function mcpJson(context, { tokenRef, envFile } = {}) {
|
|
@@ -432,7 +481,9 @@ function legacyDescriptors(context) {
|
|
|
432
481
|
{
|
|
433
482
|
configs: [{
|
|
434
483
|
path: '.cursor/mcp.json',
|
|
435
|
-
content: mcpJson(context, context ? {
|
|
484
|
+
content: mcpJson(context, context ? {
|
|
485
|
+
envFile: '.git/loreli.env'
|
|
486
|
+
} : {}),
|
|
436
487
|
marker: 'loreli',
|
|
437
488
|
format: 'json'
|
|
438
489
|
}],
|
|
@@ -669,7 +720,7 @@ export async function create(repo, branch, name, root = DEFAULT_ROOT, context, d
|
|
|
669
720
|
const cloneArgs = ['clone', '--single-branch'];
|
|
670
721
|
if (branch && branch !== 'HEAD') cloneArgs.push('--branch', branch);
|
|
671
722
|
cloneArgs.push(repo, cwd);
|
|
672
|
-
await run('git', cloneArgs);
|
|
723
|
+
await run('git', cloneArgs, { timeout: GIT_TIMEOUT });
|
|
673
724
|
|
|
674
725
|
// Create a named branch for the agent. A named branch lets agents
|
|
675
726
|
// commit and push without extra setup — no "HEAD (no branch)" state.
|
|
@@ -871,7 +922,7 @@ export async function reset(cwd, name, issue, base = 'main', opts = {}) {
|
|
|
871
922
|
// tracked branch. When merge.base differs (e.g. 'loreli'), the ref
|
|
872
923
|
// won't exist locally without an explicit refspec.
|
|
873
924
|
await run('git', ['-C', cwd, 'fetch', 'origin',
|
|
874
|
-
`+refs/heads/${base}:refs/remotes/origin/${base}`]);
|
|
925
|
+
`+refs/heads/${base}:refs/remotes/origin/${base}`], { timeout: GIT_TIMEOUT });
|
|
875
926
|
|
|
876
927
|
// Discard any uncommitted changes from the previous run
|
|
877
928
|
await run('git', ['-C', cwd, 'checkout', '--', '.']);
|
|
@@ -915,7 +966,7 @@ export async function reset(cwd, name, issue, base = 'main', opts = {}) {
|
|
|
915
966
|
export async function checkout(cwd, branch, base = 'main', opts = {}) {
|
|
916
967
|
await run('git', ['-C', cwd, 'fetch', 'origin',
|
|
917
968
|
`+refs/heads/${branch}:refs/remotes/origin/${branch}`,
|
|
918
|
-
`+refs/heads/${base}:refs/remotes/origin/${base}`]);
|
|
969
|
+
`+refs/heads/${base}:refs/remotes/origin/${base}`], { timeout: GIT_TIMEOUT });
|
|
919
970
|
await run('git', ['-C', cwd, 'checkout', '--', '.']);
|
|
920
971
|
try { await run('git', ['-C', cwd, 'clean', '-fd']); } catch { /* ok */ }
|
|
921
972
|
await cleanScaffolding(cwd);
|
|
@@ -1018,7 +1069,7 @@ export async function commitAndPush(cwd, message) {
|
|
|
1018
1069
|
// The caller decides whether push failure is fatal.
|
|
1019
1070
|
let pushed = false;
|
|
1020
1071
|
try {
|
|
1021
|
-
await run('git', ['-C', cwd, 'push', '-u', 'origin', 'HEAD']);
|
|
1072
|
+
await run('git', ['-C', cwd, 'push', '-u', 'origin', 'HEAD'], { timeout: GIT_TIMEOUT });
|
|
1022
1073
|
pushed = true;
|
|
1023
1074
|
} catch { /* push failure is non-fatal — caller may retry */ }
|
|
1024
1075
|
|