loreli 0.0.0 → 1.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 +670 -97
- package/bin/loreli.js +89 -0
- package/package.json +74 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/src/index.js +656 -0
- package/packages/agent/README.md +517 -0
- package/packages/agent/src/backends/claude.js +287 -0
- package/packages/agent/src/backends/codex.js +278 -0
- package/packages/agent/src/backends/cursor.js +294 -0
- package/packages/agent/src/backends/index.js +329 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +198 -0
- package/packages/agent/src/factory.js +119 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +141 -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/config/README.md +833 -0
- package/packages/config/src/defaults.js +134 -0
- package/packages/config/src/index.js +192 -0
- package/packages/config/src/schema.js +273 -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 +1558 -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 +237 -0
- package/packages/knowledge/src/index.js +412 -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 +279 -0
- package/packages/mcp/instructions.md +121 -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 +453 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +571 -0
- package/packages/mcp/src/tools/agents.js +429 -0
- package/packages/mcp/src/tools/context.js +199 -0
- package/packages/mcp/src/tools/github.js +1199 -0
- package/packages/mcp/src/tools/hitl.js +149 -0
- package/packages/mcp/src/tools/index.js +17 -0
- package/packages/mcp/src/tools/start.js +835 -0
- package/packages/mcp/src/tools/status.js +146 -0
- package/packages/mcp/src/tools/work.js +124 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1226 -0
- package/packages/planner/README.md +168 -0
- package/packages/planner/src/index.js +1166 -0
- package/packages/review/README.md +129 -0
- package/packages/review/src/index.js +1283 -0
- package/packages/risk/README.md +119 -0
- package/packages/risk/src/index.js +428 -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 +452 -0
- package/packages/workflow/README.md +313 -0
- package/packages/workflow/src/index.js +481 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1076 -0
- package/index.js +0 -8
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import Mustache from 'mustache';
|
|
5
|
+
import { mark, has, parse } from 'loreli/marker';
|
|
6
|
+
|
|
7
|
+
export { responder } from './proof-of-life.js';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Absolute path to the shared autonomous-mode preamble prepended to
|
|
13
|
+
* every agent prompt. Lives in the workflow package because render()
|
|
14
|
+
* is the single funnel all prompts flow through.
|
|
15
|
+
*
|
|
16
|
+
* @type {string}
|
|
17
|
+
*/
|
|
18
|
+
const PREAMBLE = join(__dirname, '..', 'prompts', 'preamble.md');
|
|
19
|
+
|
|
20
|
+
/** @type {string|null} Cached preamble text — loaded once, reused. */
|
|
21
|
+
let _preamble = null;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load the shared preamble, caching after the first read.
|
|
25
|
+
*
|
|
26
|
+
* @returns {Promise<string>}
|
|
27
|
+
*/
|
|
28
|
+
async function preamble() {
|
|
29
|
+
if (_preamble === null) _preamble = await readFile(PREAMBLE, 'utf8');
|
|
30
|
+
return _preamble;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Abstract base class for role-specific workflow packages.
|
|
35
|
+
*
|
|
36
|
+
* Subclasses must define `static role` (the agent role name) and
|
|
37
|
+
* `static template` (absolute path to the Mustache prompt file).
|
|
38
|
+
* They implement `reactor()` and `events()` to register handlers
|
|
39
|
+
* with the orchestrator's tick loop and EventEmitter respectively.
|
|
40
|
+
*
|
|
41
|
+
* This class absorbs the template rendering responsibility from the
|
|
42
|
+
* deleted `Role` class, adds agent filtering by role, and provides
|
|
43
|
+
* the `renderFrom()` method for cross-role rendering (e.g. the
|
|
44
|
+
* review package rendering the action prompt for `forward()`).
|
|
45
|
+
*/
|
|
46
|
+
export class Workflow {
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} orchestrator - Orchestrator instance with agents Map and identityRegistry.
|
|
49
|
+
* @param {object} hub - GitHub hub instance for API calls.
|
|
50
|
+
*/
|
|
51
|
+
constructor(orchestrator, hub) {
|
|
52
|
+
if (!new.target.role) {
|
|
53
|
+
throw new Error('Workflow subclass must define static role');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @type {object} The orchestrator for agent coordination. */
|
|
57
|
+
this.orchestrator = orchestrator;
|
|
58
|
+
|
|
59
|
+
/** @type {object} GitHub hub for API calls. */
|
|
60
|
+
this.hub = hub;
|
|
61
|
+
|
|
62
|
+
/** @type {Map<string, string|null>} Per-role cached custom prompt content. */
|
|
63
|
+
this._custom = new Map();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Return reactor handler map for the orchestrator's tick loop.
|
|
68
|
+
* Subclasses override to register their specific handlers.
|
|
69
|
+
*
|
|
70
|
+
* @returns {Record<string, Function>} Handler name to async function.
|
|
71
|
+
*/
|
|
72
|
+
reactor() {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Return event listener map for orchestrator EventEmitter events.
|
|
78
|
+
* Subclasses override to register their specific listeners.
|
|
79
|
+
*
|
|
80
|
+
* @returns {Record<string, Function>} Event name to handler function.
|
|
81
|
+
*/
|
|
82
|
+
events() {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Filter orchestrator agents to those matching this workflow's role.
|
|
88
|
+
*
|
|
89
|
+
* @returns {object[]} Agents whose role matches this workflow's static role.
|
|
90
|
+
*/
|
|
91
|
+
agents() {
|
|
92
|
+
const role = this.constructor.role;
|
|
93
|
+
return [...this.orchestrator.agents.values()]
|
|
94
|
+
.filter(function byRole(a) { return a.role === role; });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Report the current demand signal for this workflow's role.
|
|
99
|
+
*
|
|
100
|
+
* Returns a structured object so the orchestrator can make informed
|
|
101
|
+
* scaling decisions — spawn decisions use `deficit`, cross-role
|
|
102
|
+
* prioritization uses `workload`, and logging uses all three.
|
|
103
|
+
*
|
|
104
|
+
* Subclasses override to compute real values from their hydrated
|
|
105
|
+
* state. The base implementation reports zero demand.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} _repo - Repository in "owner/name" format.
|
|
108
|
+
* @returns {Promise<{workload: number, supply: number, deficit: number}>}
|
|
109
|
+
*/
|
|
110
|
+
async demand(_repo) {
|
|
111
|
+
return { workload: 0, supply: 0, deficit: 0 };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Find the opposing-provider match for yin/yang review pairing.
|
|
116
|
+
* Delegates to the orchestrator's identity registry.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} identity - The identity to find an opposite for.
|
|
119
|
+
* @param {object[]} candidates - Objects with an identity property.
|
|
120
|
+
* @returns {object|null} The matching candidate, or null.
|
|
121
|
+
*/
|
|
122
|
+
pair(identity, candidates) {
|
|
123
|
+
return this.orchestrator.identityRegistry.pair(identity, candidates);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create and spawn a new agent via the orchestrator's factory.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} provider - AI provider.
|
|
130
|
+
* @param {string} role - Agent role.
|
|
131
|
+
* @param {object} [opts] - Additional factory options.
|
|
132
|
+
* @returns {Promise<object>} The spawned agent.
|
|
133
|
+
*/
|
|
134
|
+
async enlist(provider, role, opts) {
|
|
135
|
+
return this.orchestrator.enlist(provider, role, opts);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Load the project-specific custom prompt for this workflow's role.
|
|
140
|
+
*
|
|
141
|
+
* Resolves `prompts.{role}` from config (e.g. `prompts.action`,
|
|
142
|
+
* `prompts.reviewer`, `prompts.planner`), reads the file once from
|
|
143
|
+
* the target repo via the hub, and caches the result on this
|
|
144
|
+
* instance. Returns an empty string when the config key is unset,
|
|
145
|
+
* the hub is unavailable, or the file cannot be read — prompt
|
|
146
|
+
* rendering never fails due to a missing custom prompt.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} [opts] - Options.
|
|
149
|
+
* @param {string} [opts.role] - Role to resolve `prompts.{role}` for.
|
|
150
|
+
* @returns {Promise<string>} Custom prompt text, or empty string.
|
|
151
|
+
*/
|
|
152
|
+
async custom(opts = {}) {
|
|
153
|
+
const role = opts.role ?? this.constructor.role;
|
|
154
|
+
if (this._custom.has(role)) return this._custom.get(role) ?? '';
|
|
155
|
+
|
|
156
|
+
const path = this.orchestrator.cfg?.get?.(`prompts.${role}`);
|
|
157
|
+
if (!path || !this.hub?.read || !this.orchestrator.repo) {
|
|
158
|
+
this._custom.set(role, null);
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const result = await this.hub.read(this.orchestrator.repo, path);
|
|
164
|
+
this._custom.set(role, result?.content ?? null);
|
|
165
|
+
} catch {
|
|
166
|
+
this._custom.set(role, null);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return this._custom.get(role) ?? '';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Load and render this workflow's prompt template.
|
|
174
|
+
*
|
|
175
|
+
* Automatically injects the workflow's `role` into the template
|
|
176
|
+
* variables so subclasses don't need to pass it explicitly.
|
|
177
|
+
* Prepends the shared autonomous-mode preamble and any project-
|
|
178
|
+
* specific custom prompt from `prompts.{role}` so every agent
|
|
179
|
+
* prompt carries the headless operation directive and project rules.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} vars - Mustache template variables.
|
|
182
|
+
* @returns {Promise<string>} Rendered prompt text.
|
|
183
|
+
*/
|
|
184
|
+
async render(vars) {
|
|
185
|
+
const [pre, ext, template] = await Promise.all([
|
|
186
|
+
preamble(),
|
|
187
|
+
this.custom({ role: this.constructor.role }),
|
|
188
|
+
readFile(this.constructor.template, 'utf8')
|
|
189
|
+
]);
|
|
190
|
+
const body = Mustache.render(template, { ...vars, role: this.constructor.role });
|
|
191
|
+
const prefix = ext ? `${pre}\n${ext}\n` : `${pre}\n`;
|
|
192
|
+
return `${prefix}${body}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Load and render an arbitrary Mustache template by path.
|
|
197
|
+
*
|
|
198
|
+
* Used for cross-role rendering where one workflow needs another
|
|
199
|
+
* role's prompt (e.g. review rendering the action prompt for forward).
|
|
200
|
+
* Prepends the shared autonomous-mode preamble and any project-
|
|
201
|
+
* specific custom prompt from `prompts.{role}`.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} path - Absolute path to a .md template file.
|
|
204
|
+
* @param {object} vars - Mustache template variables.
|
|
205
|
+
* @param {object} [opts] - Options.
|
|
206
|
+
* @param {string} [opts.role] - Role to resolve `prompts.{role}` for.
|
|
207
|
+
* @returns {Promise<string>} Rendered prompt text.
|
|
208
|
+
*/
|
|
209
|
+
async renderFrom(path, vars, opts = {}) {
|
|
210
|
+
const role = opts.role ?? this.constructor.role;
|
|
211
|
+
const [pre, ext, template] = await Promise.all([
|
|
212
|
+
preamble(),
|
|
213
|
+
this.custom({ role }),
|
|
214
|
+
readFile(path, 'utf8')
|
|
215
|
+
]);
|
|
216
|
+
const body = Mustache.render(template, vars);
|
|
217
|
+
const prefix = ext ? `${pre}\n${ext}\n` : `${pre}\n`;
|
|
218
|
+
return `${prefix}${body}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Write task context to the agent's session in storage.
|
|
223
|
+
*
|
|
224
|
+
* Called by workflows before dispatching an agent so the agent's
|
|
225
|
+
* MCP server can read the task context (discussion ID, PR number,
|
|
226
|
+
* etc.) without the agent needing to pass it as a parameter.
|
|
227
|
+
*
|
|
228
|
+
* No-op when sessionId or storage is not available (e.g. tests
|
|
229
|
+
* without persistent storage).
|
|
230
|
+
*
|
|
231
|
+
* @param {string} agentName - Agent identity name.
|
|
232
|
+
* @param {object} task - Task context object (e.g. { type: 'review_pr', pr: 42 }).
|
|
233
|
+
* @returns {Promise<void>}
|
|
234
|
+
*/
|
|
235
|
+
async saveTask(agentName, task) {
|
|
236
|
+
const { storage, sessionId } = this.orchestrator;
|
|
237
|
+
if (!sessionId || !storage) return;
|
|
238
|
+
|
|
239
|
+
const data = await storage.load(sessionId, agentName);
|
|
240
|
+
if (!data) return;
|
|
241
|
+
|
|
242
|
+
data.task = task;
|
|
243
|
+
await storage.save(sessionId, agentName, data);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Render this workflow's template and send to an agent.
|
|
248
|
+
*
|
|
249
|
+
* @param {object} agent - Target agent with a send() method.
|
|
250
|
+
* @param {object} vars - Mustache template variables.
|
|
251
|
+
* @returns {Promise<void>}
|
|
252
|
+
*/
|
|
253
|
+
async dispatch(agent, vars) {
|
|
254
|
+
const prompt = await this.render(vars);
|
|
255
|
+
await agent.send(prompt);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Proof of Life ───────────────────────────────────
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Post a themed proof-of-life request on an issue/PR.
|
|
262
|
+
*
|
|
263
|
+
* Uses the orchestrator's client identity for scoping and the
|
|
264
|
+
* council-level themed message from the identity's theme. Falls
|
|
265
|
+
* back to an unscoped invisible marker when no client identity
|
|
266
|
+
* is available (e.g. in tests with bare stubs).
|
|
267
|
+
*
|
|
268
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
269
|
+
* @param {number} number - Issue or PR number.
|
|
270
|
+
* @param {string} agent - Name of the agent being checked.
|
|
271
|
+
* @returns {Promise<void>}
|
|
272
|
+
*/
|
|
273
|
+
async requestProofOfLife(repo, number, agent) {
|
|
274
|
+
const orch = this.orchestrator.sessionId;
|
|
275
|
+
const payload = { agent, ts: String(Date.now()) };
|
|
276
|
+
if (orch) payload.orch = orch;
|
|
277
|
+
const marker = mark('proof-of-life', payload);
|
|
278
|
+
const identity = this.orchestrator.clientIdentity;
|
|
279
|
+
if (!identity) {
|
|
280
|
+
await this.hub.comment(repo, number, marker);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const visible = identity.proofOfLife(agent);
|
|
284
|
+
const body = `${visible}\n\n${marker}`;
|
|
285
|
+
const scoped = this.hub.as(identity, 'orchestrator');
|
|
286
|
+
await scoped.comment(repo, number, body);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Post a themed alive response with an embedded machine-readable marker.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
293
|
+
* @param {number} number - Issue or PR number.
|
|
294
|
+
* @param {object} identity - Agent identity for hub scoping.
|
|
295
|
+
* @param {string} role - Agent role for hub scoping.
|
|
296
|
+
* @param {{alive: boolean, status: string, details: string, outputLength?: number}} verdict - Health check result.
|
|
297
|
+
* @returns {Promise<void>}
|
|
298
|
+
*/
|
|
299
|
+
async respondProofOfLife(repo, number, identity, role, verdict) {
|
|
300
|
+
const marker = mark('alive', {
|
|
301
|
+
agent: identity.name,
|
|
302
|
+
ts: String(Date.now()),
|
|
303
|
+
status: verdict.status
|
|
304
|
+
});
|
|
305
|
+
const visible = `**${identity.name}** is **${verdict.status}** — ${verdict.details}`;
|
|
306
|
+
const body = `${visible}\n\n${marker}`;
|
|
307
|
+
const scoped = this.hub.as(identity, role);
|
|
308
|
+
await scoped.comment(repo, number, body);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Find a pending proof-of-life request for a specific agent.
|
|
313
|
+
*
|
|
314
|
+
* When `orch` is provided, only matches requests posted by that
|
|
315
|
+
* orchestrator session. When omitted, matches any request — used
|
|
316
|
+
* by the responder which answers regardless of who asked.
|
|
317
|
+
*
|
|
318
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
319
|
+
* @param {number} number - Issue or PR number.
|
|
320
|
+
* @param {string} agent - Agent name to search for.
|
|
321
|
+
* @param {string} [orch] - Orchestrator session ID filter.
|
|
322
|
+
* @returns {Promise<{agent: string, ts: string, orch?: string}|null>} Parsed request data, or null.
|
|
323
|
+
*/
|
|
324
|
+
async findProofOfLifeRequest(repo, number, agent, orch) {
|
|
325
|
+
const comments = await this.hub.comments(repo, number);
|
|
326
|
+
for (const c of comments) {
|
|
327
|
+
if (!has(c.body, 'proof-of-life')) continue;
|
|
328
|
+
const data = parse(c.body, 'proof-of-life');
|
|
329
|
+
if (data?.agent !== agent) continue;
|
|
330
|
+
if (orch && data.orch !== orch) continue;
|
|
331
|
+
return data;
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Find an alive response for a specific agent posted after a given timestamp.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
340
|
+
* @param {number} number - Issue or PR number.
|
|
341
|
+
* @param {string} agent - Agent name to search for.
|
|
342
|
+
* @param {number} after - Only match responses with ts greater than this epoch ms.
|
|
343
|
+
* @returns {Promise<{agent: string, ts: string, status: string}|null>} Parsed response, or null.
|
|
344
|
+
*/
|
|
345
|
+
async findAliveResponse(repo, number, agent, after) {
|
|
346
|
+
const comments = await this.hub.comments(repo, number);
|
|
347
|
+
for (const c of comments) {
|
|
348
|
+
if (!has(c.body, 'alive')) continue;
|
|
349
|
+
const data = parse(c.body, 'alive');
|
|
350
|
+
if (data?.agent !== agent) continue;
|
|
351
|
+
if (Number(data.ts) > after) return data;
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Check whether an issue/PR has any GitHub comment within the timeout window.
|
|
358
|
+
*
|
|
359
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
360
|
+
* @param {number} number - Issue or PR number.
|
|
361
|
+
* @param {number} timeout - Maximum age in ms for activity to be considered recent.
|
|
362
|
+
* @returns {Promise<boolean>}
|
|
363
|
+
*/
|
|
364
|
+
async hasRecentActivity(repo, number, timeout) {
|
|
365
|
+
const comments = await this.hub.comments(repo, number);
|
|
366
|
+
const cutoff = Date.now() - timeout;
|
|
367
|
+
return comments.some(function recent(c) {
|
|
368
|
+
return new Date(c.created_at).getTime() > cutoff;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Evaluate an agent through the proof-of-life protocol.
|
|
374
|
+
*
|
|
375
|
+
* Unified gate for ALL eviction decisions — foreign agents, local
|
|
376
|
+
* idle agents, and any future workflow that needs to verify liveness
|
|
377
|
+
* before releasing a claim. Posts a PoL request if none exists,
|
|
378
|
+
* checks for alive responses, and considers the response status
|
|
379
|
+
* (healthy vs unhealthy) when making the verdict.
|
|
380
|
+
*
|
|
381
|
+
* Status-aware: an alive response with `status: 'unhealthy'` means
|
|
382
|
+
* the agent's own orchestrator confirmed it is stalled, so the gate
|
|
383
|
+
* returns `'release'` instead of `'active'`.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
386
|
+
* @param {number} number - Issue or PR number.
|
|
387
|
+
* @param {string} agent - Agent identity name to check.
|
|
388
|
+
* @returns {Promise<string>} 'active'|'requested'|'pending'|'release'.
|
|
389
|
+
*/
|
|
390
|
+
async check(repo, number, agent) {
|
|
391
|
+
const timeout = this.orchestrator.cfg?.get?.('proofOfLife.timeout') ?? 300000;
|
|
392
|
+
const orch = this.orchestrator.sessionId;
|
|
393
|
+
|
|
394
|
+
const ours = await this.findProofOfLifeRequest(repo, number, agent, orch);
|
|
395
|
+
if (ours) {
|
|
396
|
+
const response = await this.findAliveResponse(repo, number, agent, Number(ours.ts));
|
|
397
|
+
if (response) return response.status === 'unhealthy' ? 'release' : 'active';
|
|
398
|
+
if (Date.now() - Number(ours.ts) > timeout) return 'release';
|
|
399
|
+
return 'pending';
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const any = await this.findProofOfLifeRequest(repo, number, agent);
|
|
403
|
+
if (any) {
|
|
404
|
+
const response = await this.findAliveResponse(repo, number, agent, Number(any.ts));
|
|
405
|
+
if (response) return response.status === 'unhealthy' ? 'release' : 'active';
|
|
406
|
+
if (Date.now() - Number(any.ts) > timeout) {
|
|
407
|
+
await this.requestProofOfLife(repo, number, agent);
|
|
408
|
+
return 'requested';
|
|
409
|
+
}
|
|
410
|
+
return 'pending';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const recent = await this.hasRecentActivity(repo, number, timeout);
|
|
414
|
+
if (recent) return 'active';
|
|
415
|
+
|
|
416
|
+
await this.requestProofOfLife(repo, number, agent);
|
|
417
|
+
return 'requested';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── Optimistic Claim ─────────────────────────────────
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Optimistic claim on an issue or PR: post a marker comment, then
|
|
424
|
+
* re-read comments to verify this agent was first.
|
|
425
|
+
*
|
|
426
|
+
* This eliminates the TOCTOU race in the check-then-claim pattern.
|
|
427
|
+
* By claiming first and verifying after, two participants racing on
|
|
428
|
+
* the same item will both post, but only the first poster wins.
|
|
429
|
+
*
|
|
430
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
431
|
+
* @param {number} number - Issue or PR number.
|
|
432
|
+
* @param {string} type - Marker type (e.g. 'claim', 'review-claim', 'risk-claim').
|
|
433
|
+
* @param {object} identity - Agent identity.
|
|
434
|
+
* @param {string} role - Agent role for hub scoping.
|
|
435
|
+
* @param {string} [visible] - Optional visible text appended after the marker.
|
|
436
|
+
* @returns {Promise<boolean>} True if this agent was the first claimant.
|
|
437
|
+
*/
|
|
438
|
+
async claimFirst(repo, number, type, identity, role, visible) {
|
|
439
|
+
const scoped = this.hub.as(identity, role);
|
|
440
|
+
const marker = mark(type, { agent: identity.name });
|
|
441
|
+
const body = visible ? `${marker}\n${visible}` : marker;
|
|
442
|
+
await scoped.comment(repo, number, body);
|
|
443
|
+
|
|
444
|
+
// Re-read all comments and find the first with this marker type.
|
|
445
|
+
// The first poster wins — all others back off.
|
|
446
|
+
const comments = await this.hub.comments(repo, number);
|
|
447
|
+
const first = comments.find(function isClaim(c) { return has(c.body, type); });
|
|
448
|
+
return parse(first?.body, type)?.agent === identity.name;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Optimistic claim on a discussion: post a marker comment, then
|
|
453
|
+
* re-read discussion comments to verify this agent was first.
|
|
454
|
+
*
|
|
455
|
+
* Identical pattern to {@link claimFirst} but uses the discussion
|
|
456
|
+
* comment API (node IDs, GraphQL) instead of issue/PR comments.
|
|
457
|
+
*
|
|
458
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
459
|
+
* @param {number} number - Discussion number (for reading).
|
|
460
|
+
* @param {string} discId - Discussion node ID (for posting).
|
|
461
|
+
* @param {string} type - Marker type (e.g. 'review-claim').
|
|
462
|
+
* @param {object} identity - Agent identity.
|
|
463
|
+
* @param {string} role - Agent role for hub scoping.
|
|
464
|
+
* @param {string} [visible] - Optional visible text appended after the marker.
|
|
465
|
+
* @returns {Promise<boolean>} True if this agent was the first claimant.
|
|
466
|
+
*/
|
|
467
|
+
async claimFirstDiscussion(repo, number, discId, type, identity, role, visible) {
|
|
468
|
+
const scoped = this.hub.as(identity, role);
|
|
469
|
+
const marker = mark(type, { agent: identity.name });
|
|
470
|
+
const body = visible ? `${marker}\n${visible}` : marker;
|
|
471
|
+
await scoped.discussionComment(discId, body);
|
|
472
|
+
|
|
473
|
+
// Use the LAST claim comment to determine the winner. Discussions
|
|
474
|
+
// that go through revise/re-review cycles accumulate old claim
|
|
475
|
+
// comments from previous cycles — checking the first would always
|
|
476
|
+
// match the stale claim and block re-dispatch.
|
|
477
|
+
const full = await this.hub.discussion(repo, number);
|
|
478
|
+
const last = full.comments?.findLast(function isClaim(c) { return has(c.body, type); });
|
|
479
|
+
return parse(last?.body, type)?.agent === identity.name;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof-of-life reactor handler factory.
|
|
3
|
+
*
|
|
4
|
+
* Creates a handler that scans open issues and PRs for proof-of-life
|
|
5
|
+
* requests targeting agents in the local agents map, runs health
|
|
6
|
+
* checks, and posts themed responses.
|
|
7
|
+
*
|
|
8
|
+
* Registered once at start and runs on every reactor tick.
|
|
9
|
+
*
|
|
10
|
+
* @module loreli/workflow/proof-of-life
|
|
11
|
+
*/
|
|
12
|
+
import { has, parse, mark } from 'loreli/marker';
|
|
13
|
+
import { logger } from 'loreli/log';
|
|
14
|
+
|
|
15
|
+
const log = logger('proof-of-life');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create the proof-of-life reactor handler.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} orchestrator - Orchestrator with agents map and health().
|
|
21
|
+
* @param {object} hub - GitHub hub for reading/posting comments.
|
|
22
|
+
* @returns {function(string): Promise<void>} Reactor handler receiving repo.
|
|
23
|
+
*/
|
|
24
|
+
export function responder(orchestrator, hub) {
|
|
25
|
+
return async function proofOfLife(repo) {
|
|
26
|
+
const [issues, prs] = await Promise.all([
|
|
27
|
+
hub.issues(repo, { state: 'open', labels: ['loreli'] }).catch(function noop() { return []; }),
|
|
28
|
+
hub.pulls(repo, { state: 'open' }).catch(function noop() { return []; })
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const items = [
|
|
32
|
+
...issues.map(function toItem(i) { return { number: i.number }; }),
|
|
33
|
+
...prs.map(function toItem(p) { return { number: p.number }; })
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
for (const item of items) {
|
|
37
|
+
const comments = await hub.comments(repo, item.number);
|
|
38
|
+
|
|
39
|
+
for (const c of comments) {
|
|
40
|
+
if (!has(c.body, 'proof-of-life')) continue;
|
|
41
|
+
|
|
42
|
+
const data = parse(c.body, 'proof-of-life');
|
|
43
|
+
if (!data?.agent) continue;
|
|
44
|
+
|
|
45
|
+
const agent = orchestrator.agents.get(data.agent);
|
|
46
|
+
if (!agent) continue;
|
|
47
|
+
|
|
48
|
+
const responded = comments.some(function alreadyAlive(r) {
|
|
49
|
+
if (!has(r.body, 'alive')) return false;
|
|
50
|
+
const alive = parse(r.body, 'alive');
|
|
51
|
+
return alive?.agent === data.agent && Number(alive.ts) > Number(data.ts);
|
|
52
|
+
});
|
|
53
|
+
if (responded) continue;
|
|
54
|
+
|
|
55
|
+
const verdict = await orchestrator.health(data.agent);
|
|
56
|
+
const aliveMarker = mark('alive', {
|
|
57
|
+
agent: agent.identity.name,
|
|
58
|
+
ts: String(Date.now()),
|
|
59
|
+
status: verdict.status
|
|
60
|
+
});
|
|
61
|
+
const visible = `**${agent.identity.name}** is **${verdict.status}** — ${verdict.details}`;
|
|
62
|
+
const body = `${visible}\n\n${aliveMarker}`;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const scoped = hub.as(agent.identity, agent.role);
|
|
66
|
+
await scoped.comment(repo, item.number, body);
|
|
67
|
+
log.info(`proof-of-life: responded for ${data.agent} on #${item.number} — ${verdict.status}`);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
log.warn(`proof-of-life: failed to respond for ${data.agent} on #${item.number}: ${err.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# loreli/workspace
|
|
2
|
+
|
|
3
|
+
Local workspace lifecycle for Loreli agents.
|
|
4
|
+
|
|
5
|
+
This package prepares agent directories, scaffolds local MCP configs and safety hooks, manages clone-based workspaces, and provides git helpers used by action/review/context flows.
|
|
6
|
+
|
|
7
|
+
## API Reference
|
|
8
|
+
|
|
9
|
+
### Constants
|
|
10
|
+
|
|
11
|
+
| Name | Type | Description |
|
|
12
|
+
|------|------|-------------|
|
|
13
|
+
| `ENTRY` | `{ command: string, args: string[] }` | Loreli MCP entry used in JSON-based configs. |
|
|
14
|
+
| `MCP_JSON` | `string` | Default JSON config body for `.mcp.json` and `.cursor/mcp.json`. |
|
|
15
|
+
| `CODEX_TOML` | `string` | Default TOML config body for `.codex/config.toml`. |
|
|
16
|
+
|
|
17
|
+
### Config Generators
|
|
18
|
+
|
|
19
|
+
#### `codexToml(context?)` → `string`
|
|
20
|
+
|
|
21
|
+
Generate Codex TOML config content with optional agent context env keys.
|
|
22
|
+
|
|
23
|
+
#### `mcpJson(context?, opts?)` → `string`
|
|
24
|
+
|
|
25
|
+
Generate JSON MCP config content with optional agent context, token reference (`tokenRef`), and env file path (`envFile`).
|
|
26
|
+
|
|
27
|
+
### Security/Hook Generators
|
|
28
|
+
|
|
29
|
+
#### `denyScript(denied)` → `string`
|
|
30
|
+
|
|
31
|
+
Generate the shared deny hook script used by Claude/Cursor shell hooks.
|
|
32
|
+
|
|
33
|
+
#### `protectScript()` → `string`
|
|
34
|
+
|
|
35
|
+
Generate the file-write protection hook script for protected paths.
|
|
36
|
+
|
|
37
|
+
#### `claudeHooks()` → `string`
|
|
38
|
+
|
|
39
|
+
Generate `.claude/settings.local.json` hook config content.
|
|
40
|
+
|
|
41
|
+
#### `cursorMatcher(denied)` → `string`
|
|
42
|
+
|
|
43
|
+
Build Cursor `beforeShellExecution` matcher regex from denied commands.
|
|
44
|
+
|
|
45
|
+
#### `cursorHooks(denied)` → `string`
|
|
46
|
+
|
|
47
|
+
Generate `.cursor/hooks.json` hook config content.
|
|
48
|
+
|
|
49
|
+
### Workspace Lifecycle
|
|
50
|
+
|
|
51
|
+
#### `pathFor(name, root?)` → `string`
|
|
52
|
+
|
|
53
|
+
Resolve deterministic workspace path: `<root>/loreli-<name>`.
|
|
54
|
+
|
|
55
|
+
- Default `root`: `~/.loreli/workspaces` (or `LORELI_HOME/workspaces`).
|
|
56
|
+
|
|
57
|
+
#### `prepare(cwd, context?, descriptors?)` → `Promise<void>`
|
|
58
|
+
|
|
59
|
+
Prepare a workspace directory by writing MCP configs, safety files, deny/protect scripts, and merged hook entries.
|
|
60
|
+
|
|
61
|
+
When `context` is provided, session/agent/repo env values are embedded into generated configs. When `descriptors` are provided (from backend scaffolding), descriptor-driven config/hook/file generation is used.
|
|
62
|
+
|
|
63
|
+
This example demonstrates preparing a workspace with context and denied commands. This matters because agents must boot with MCP connectivity and command guardrails. Expected result: config files and hook scripts are created/updated in place.
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
import { prepare } from 'loreli/workspace';
|
|
67
|
+
|
|
68
|
+
await prepare('/tmp/loreli-optimus-0', {
|
|
69
|
+
session: 's1',
|
|
70
|
+
agent: 'optimus-0',
|
|
71
|
+
repo: 'owner/repo',
|
|
72
|
+
denied: ['gh', 'curl']
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
#### `mirror(url, opts?)` → `Promise<string>`
|
|
77
|
+
|
|
78
|
+
Ensure a local bare mirror exists under `<home>/repos`. Fetches if mirror exists, clones if not.
|
|
79
|
+
|
|
80
|
+
#### `create(repo, branch, name, root?, context?, descriptors?)` → `Promise<string>`
|
|
81
|
+
|
|
82
|
+
Create or reuse an agent workspace. Current behavior is clone-based workspace creation (not git worktree-based execution) with branch initialization, optional remote auth setup, and preparation/scaffolding.
|
|
83
|
+
|
|
84
|
+
This example demonstrates provisioning an agent workspace for branch work. This matters because action agents need an isolated git directory with local `.git` state. Expected result: returned `cwd` contains a prepared clone with agent branch checked out.
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
import { create } from 'loreli/workspace';
|
|
88
|
+
|
|
89
|
+
const cwd = await create('/path/to/bare-or-local-repo', 'main', 'optimus-0');
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### `clean(name, root?)` → `Promise<void>`
|
|
93
|
+
|
|
94
|
+
Delete an agent workspace directory (best effort). Attempts worktree remove first, then force-removes directory.
|
|
95
|
+
|
|
96
|
+
#### `prune(sessionId, root?)` → `Promise<string[]>`
|
|
97
|
+
|
|
98
|
+
Remove stale `loreli-*` workspaces not owned by `sessionId`.
|
|
99
|
+
|
|
100
|
+
#### `reset(cwd, name, issue?, base?, opts?)` → `Promise<void>`
|
|
101
|
+
|
|
102
|
+
Reset workspace to a clean issue branch from remote `base` (default `main`).
|
|
103
|
+
|
|
104
|
+
When `opts.context` and `opts.descriptors` are provided, `reset()` re-applies
|
|
105
|
+
workspace scaffolding after the branch switch so MCP CLI config files remain
|
|
106
|
+
available for shell-based Loreli tool commands.
|
|
107
|
+
|
|
108
|
+
#### `checkout(cwd, branch, base?, opts?)` → `Promise<void>`
|
|
109
|
+
|
|
110
|
+
Fetch and check out a remote branch in an existing workspace, also syncing local base ref.
|
|
111
|
+
|
|
112
|
+
When `opts.context` and `opts.descriptors` are provided, `checkout()` re-applies
|
|
113
|
+
workspace scaffolding after branch switch for the same reason as `reset()`.
|
|
114
|
+
|
|
115
|
+
#### `hasChanges(cwd)` → `Promise<boolean>`
|
|
116
|
+
|
|
117
|
+
Return whether workspace has uncommitted/untracked changes (`git status --porcelain`).
|
|
118
|
+
|
|
119
|
+
#### `commitAndPush(cwd, message)` → `Promise<{ pushed: boolean, sha: string }>`
|
|
120
|
+
|
|
121
|
+
Stage, commit, and attempt push. Returns commit SHA and whether push succeeded.
|
|
122
|
+
|
|
123
|
+
### Git Context Helpers
|
|
124
|
+
|
|
125
|
+
#### `blame(cwd, file, line)` → `Promise<{ sha: string, author: string, date: string, summary: string }>`
|
|
126
|
+
|
|
127
|
+
Run porcelain git blame for a single line and return parsed metadata.
|
|
128
|
+
|
|
129
|
+
#### `gitlog(cwd, file, opts?)` → `Promise<Array<{ sha: string, date: string, message: string }>>`
|
|
130
|
+
|
|
131
|
+
Run git log for a file and return parsed commit entries.
|
|
132
|
+
|
|
133
|
+
## Scope Boundary
|
|
134
|
+
|
|
135
|
+
In scope:
|
|
136
|
+
- local workspace preparation and cleanup
|
|
137
|
+
- local MCP config/hook generation
|
|
138
|
+
- clone/branch/reset/checkout helper operations
|
|
139
|
+
- git context lookup helpers
|
|
140
|
+
|
|
141
|
+
Out of scope:
|
|
142
|
+
- remote GitHub scaffolding and repository writes (`loreli/hub`)
|
|
143
|
+
- agent process lifecycle (`loreli/agent`, `loreli/orchestrator`)
|