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,1166 @@
|
|
|
1
|
+
import { join, dirname } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { Workflow } from 'loreli/workflow';
|
|
4
|
+
import { Identity, capability } from 'loreli/identity';
|
|
5
|
+
import { mark, has, parse } from 'loreli/marker';
|
|
6
|
+
import { classifyRefs, extractRefs } from 'loreli/knowledge';
|
|
7
|
+
import { logger } from 'loreli/log';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const log = logger('planner');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Path to the plan-reviewer prompt template. Used for rendering the
|
|
14
|
+
* adversarial plan review prompt via cross-role rendering.
|
|
15
|
+
*
|
|
16
|
+
* @type {string}
|
|
17
|
+
*/
|
|
18
|
+
const PLAN_REVIEWER_TEMPLATE = join(__dirname, '..', 'prompts', 'plan-reviewer.md');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Path to the tiebreaker-reviewer prompt template. Used when a discussion
|
|
22
|
+
* has exhausted maxRounds of revision and needs pragmatic evaluation.
|
|
23
|
+
*
|
|
24
|
+
* @type {string}
|
|
25
|
+
*/
|
|
26
|
+
const TIEBREAKER_TEMPLATE = join(__dirname, '..', 'prompts', 'tiebreaker-reviewer.md');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Planning workflow for Loreli's orchestration pipeline.
|
|
30
|
+
*
|
|
31
|
+
* Uses GitHub Discussions as the planning primitive. The planner creates
|
|
32
|
+
* discussions in a "Loreli" category, an adversarial reviewer applies
|
|
33
|
+
* labels to approve or request changes, and approved discussions are
|
|
34
|
+
* promoted to real issues for action agents.
|
|
35
|
+
*
|
|
36
|
+
* Labels act as a state machine:
|
|
37
|
+
* - No review label → needs review (dispatched to reviewer)
|
|
38
|
+
* - `loreli:changes-requested` → needs revision (dispatched to planner)
|
|
39
|
+
* - `loreli:blocked` → parked until dependency resolves
|
|
40
|
+
* - `loreli:approved` → ready for promotion (creates issue, closes discussion)
|
|
41
|
+
*
|
|
42
|
+
* @extends Workflow
|
|
43
|
+
*/
|
|
44
|
+
export class PlannerWorkflow extends Workflow {
|
|
45
|
+
/** @type {string} Agent role this workflow manages. */
|
|
46
|
+
static role = 'planner';
|
|
47
|
+
|
|
48
|
+
/** @type {string} Mustache template path for planner prompts. */
|
|
49
|
+
static template = join(__dirname, '..', 'prompts', 'planner.md');
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Discussion category ID for the current planning session.
|
|
53
|
+
* Set by plan() and used across tick cycles.
|
|
54
|
+
*
|
|
55
|
+
* @type {string|null}
|
|
56
|
+
*/
|
|
57
|
+
categoryId = null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Set of discussion numbers currently in-flight for review.
|
|
61
|
+
* Prevents the reactor from re-dispatching the same discussion
|
|
62
|
+
* every tick while a reviewer is still working on it.
|
|
63
|
+
*
|
|
64
|
+
* @type {Set<number>}
|
|
65
|
+
*/
|
|
66
|
+
_inflight = new Set();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Discussion number the reviewer is locked to for the current
|
|
70
|
+
* review/revision cycle. Keeps the reviewer focused on one
|
|
71
|
+
* discussion through its full review cycle (review → revision →
|
|
72
|
+
* re-review → approved) before moving to the next.
|
|
73
|
+
*
|
|
74
|
+
* @type {number|null}
|
|
75
|
+
*/
|
|
76
|
+
_reviewing = null;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Name of the reviewer agent assigned to `_reviewing`. Used to
|
|
80
|
+
* detect when the committed reviewer has been killed or gone dormant,
|
|
81
|
+
* which means the in-flight entry is stale and should be cleared.
|
|
82
|
+
*
|
|
83
|
+
* @type {string|null}
|
|
84
|
+
*/
|
|
85
|
+
_reviewerName = null;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Set of discussion numbers currently in-flight for revision.
|
|
89
|
+
* Prevents the reactor from re-dispatching revise prompts every
|
|
90
|
+
* tick while the planner is still processing a revision. Keeps
|
|
91
|
+
* the planner focused on one discussion through its full revision
|
|
92
|
+
* cycle before moving to the next.
|
|
93
|
+
*
|
|
94
|
+
* Entries self-clear when the discussion label changes (the planner
|
|
95
|
+
* calls `plan revise` which removes `loreli:changes-requested`).
|
|
96
|
+
*
|
|
97
|
+
* @type {Set<number>}
|
|
98
|
+
*/
|
|
99
|
+
_revising = new Set();
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Name of the reviewer agent dispatched for discussion review.
|
|
103
|
+
* Tracked so promote() only kills this agent — not PR reviewers
|
|
104
|
+
* managed by ReviewWorkflow sharing the same role.
|
|
105
|
+
*
|
|
106
|
+
* @type {string|null}
|
|
107
|
+
*/
|
|
108
|
+
_discussionReviewer = null;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Revision round counter per discussion. Incremented each time
|
|
112
|
+
* revise() processes a non-blocked `loreli:changes-requested` discussion.
|
|
113
|
+
* At maxRounds, a tiebreaker revision is dispatched. Beyond maxRounds,
|
|
114
|
+
* HITL escalation blocks until a human intervenes.
|
|
115
|
+
*
|
|
116
|
+
* @type {Map<number, number>}
|
|
117
|
+
*/
|
|
118
|
+
_revisionRounds = new Map();
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Discussion numbers whose reviewer died during revision. Persists
|
|
122
|
+
* across ticks so the claim check can be skipped when the discussion
|
|
123
|
+
* finally exits the `changes-requested` state. Cleared after a
|
|
124
|
+
* successful re-dispatch.
|
|
125
|
+
*
|
|
126
|
+
* @type {Set<number>}
|
|
127
|
+
*/
|
|
128
|
+
_cleared = new Set();
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Blocked discussions awaiting dependency resolution.
|
|
132
|
+
* Keys are discussion numbers, values contain the discussion node ID
|
|
133
|
+
* and the open issue numbers that are blocking progress.
|
|
134
|
+
*
|
|
135
|
+
* @type {Map<number, { id: string, blockers: number[] }>}
|
|
136
|
+
*/
|
|
137
|
+
_blocked = new Map();
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Repository node ID needed for createDiscussion mutation.
|
|
141
|
+
* Stored alongside categoryId during plan().
|
|
142
|
+
*
|
|
143
|
+
* @type {string|null}
|
|
144
|
+
*/
|
|
145
|
+
repositoryId = null;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Planning objective text. Stored by plan() so the link() reactor
|
|
149
|
+
* handler can use it as the parent tracking issue title.
|
|
150
|
+
*
|
|
151
|
+
* @type {string|null}
|
|
152
|
+
*/
|
|
153
|
+
objective = null;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Names of planner agents that have already received the objective.
|
|
157
|
+
* Used by recover() to avoid re-dispatching to planners that were
|
|
158
|
+
* already sent the prompt (whether they completed it or not).
|
|
159
|
+
*
|
|
160
|
+
* @type {Set<string>}
|
|
161
|
+
*/
|
|
162
|
+
_dispatched = new Set();
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Report planner demand: how many open discussions need a planner
|
|
166
|
+
* agent. Planner demand is typically low — one planner handles the
|
|
167
|
+
* entire discussion lifecycle. Uses hydrated state.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
170
|
+
* @returns {Promise<{workload: number, supply: number, deficit: number}>}
|
|
171
|
+
*/
|
|
172
|
+
async demand(repo) {
|
|
173
|
+
if (!this.categoryId) return { workload: 0, supply: 0, deficit: 0 };
|
|
174
|
+
|
|
175
|
+
const all = await this.hub.discussions(repo, this.categoryId);
|
|
176
|
+
const open = all.filter(function isOpen(d) { return !d.closed; });
|
|
177
|
+
|
|
178
|
+
// Objective dispatched but planner died before creating any
|
|
179
|
+
// discussion → treat as pending workload so scale() spawns
|
|
180
|
+
// a replacement planner.
|
|
181
|
+
const pending = this.objective && all.length === 0;
|
|
182
|
+
const workload = open.length > 0 || pending ? 1 : 0;
|
|
183
|
+
const supply = this.agents().filter(function active(a) {
|
|
184
|
+
return a.state !== 'dormant';
|
|
185
|
+
}).length;
|
|
186
|
+
|
|
187
|
+
return { workload, supply, deficit: Math.max(0, workload - supply) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Register reactor handlers for the orchestrator's tick loop.
|
|
192
|
+
*
|
|
193
|
+
* Tick order: hydrate → unblock → revise → review → promote → link
|
|
194
|
+
* Hydrate runs first to recover distributed state from GitHub.
|
|
195
|
+
* unblock runs next so freshly unblocked discussions can enter
|
|
196
|
+
* review on the same tick. revise runs before review so a just-revised
|
|
197
|
+
* discussion can be reviewed immediately. link runs last to create
|
|
198
|
+
* parent tracking issues and wire sub-issue relationships after
|
|
199
|
+
* promote has created the child issues.
|
|
200
|
+
*
|
|
201
|
+
* @returns {Record<string, function>} Handler map.
|
|
202
|
+
*/
|
|
203
|
+
reactor() {
|
|
204
|
+
const self = this;
|
|
205
|
+
return {
|
|
206
|
+
async 'planner-hydrate'(repo) { await self.hydrate(repo); },
|
|
207
|
+
async 'planner-recover'(repo) { await self.recover(repo); },
|
|
208
|
+
async unblock(repo) { await self.unblock(repo); },
|
|
209
|
+
async revise(repo) { await self.revise(repo); },
|
|
210
|
+
async review(repo) { await self.review(repo); },
|
|
211
|
+
async promote(repo) { await self.promote(repo); },
|
|
212
|
+
async link(repo) { await self.link(repo); },
|
|
213
|
+
async 'planner-reap'(repo) { await self.reap(repo); }
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Rehydrate in-memory planner state from GitHub artifacts.
|
|
219
|
+
*
|
|
220
|
+
* Called at the start of each reactor tick so that discussions
|
|
221
|
+
* tracked by other participants (or lost to a process restart)
|
|
222
|
+
* are visible to revise(), review(), and promote().
|
|
223
|
+
*
|
|
224
|
+
* Derives state from:
|
|
225
|
+
* - `loreli:blocked` label → blocked discussions
|
|
226
|
+
* - `review-claim` marker comments → discussions in-flight for review
|
|
227
|
+
* - `loreli:changes-requested` label → discussions needing revision
|
|
228
|
+
* - Comment history → revision round counts
|
|
229
|
+
*
|
|
230
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
231
|
+
* @returns {Promise<void>}
|
|
232
|
+
*/
|
|
233
|
+
async hydrate(repo) {
|
|
234
|
+
if (!this.hub || !this.categoryId) return;
|
|
235
|
+
|
|
236
|
+
const all = await this.hub.discussions(repo, this.categoryId);
|
|
237
|
+
|
|
238
|
+
for (const d of all) {
|
|
239
|
+
if (d.closed) continue;
|
|
240
|
+
|
|
241
|
+
// Blocked: derive from loreli:blocked label
|
|
242
|
+
if (d.labels.includes('loreli:blocked') && !this._blocked.has(d.number)) {
|
|
243
|
+
const full = await this.hub.discussion(repo, d.number);
|
|
244
|
+
const blockComment = full.comments?.find(function isBlock(c) {
|
|
245
|
+
return c.body.includes('Blocked by open items');
|
|
246
|
+
});
|
|
247
|
+
const blockers = [];
|
|
248
|
+
if (blockComment) {
|
|
249
|
+
for (const m of blockComment.body.matchAll(/#(\d+)/g)) {
|
|
250
|
+
blockers.push(Number(m[1]));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
this._blocked.set(d.number, { id: d.id, blockers });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Already tracked locally
|
|
258
|
+
if (this._inflight.has(d.number)) continue;
|
|
259
|
+
|
|
260
|
+
// In-flight for review: has review-claim marker, no terminal label.
|
|
261
|
+
// Skip the committed discussion — review() handles re-dispatch
|
|
262
|
+
// via its commitment check, and re-adding it here after revise()
|
|
263
|
+
// clears it would create a deadlock (review-claim marker persists
|
|
264
|
+
// across revision cycles).
|
|
265
|
+
if (d.number === this._reviewing) continue;
|
|
266
|
+
|
|
267
|
+
if (!d.labels.includes('loreli:approved') &&
|
|
268
|
+
!d.labels.includes('loreli:changes-requested') &&
|
|
269
|
+
!d.labels.includes('loreli:blocked')) {
|
|
270
|
+
const full = await this.hub.discussion(repo, d.number);
|
|
271
|
+
const claimComment = full.comments?.find(function isClaim(c) {
|
|
272
|
+
return has(c.body, 'review-claim');
|
|
273
|
+
});
|
|
274
|
+
if (claimComment) {
|
|
275
|
+
this._inflight.add(d.number);
|
|
276
|
+
const parsed = parse(claimComment.body, 'review-claim');
|
|
277
|
+
if (parsed?.agent) this._reviewerName = parsed.agent;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Revision rounds: count loreli:changes-requested → revision cycles.
|
|
282
|
+
// Each cycle is a reviewer requesting changes + planner revising.
|
|
283
|
+
// The count is derived from the number of review-claim markers
|
|
284
|
+
// (each review cycle starts with a new claim or re-claim).
|
|
285
|
+
if (d.labels.includes('loreli:changes-requested') &&
|
|
286
|
+
!this._revisionRounds.has(d.number)) {
|
|
287
|
+
const full = await this.hub.discussion(repo, d.number);
|
|
288
|
+
const claimCount = full.comments?.filter(function isClaim(c) {
|
|
289
|
+
return has(c.body, 'review-claim');
|
|
290
|
+
}).length ?? 0;
|
|
291
|
+
if (claimCount > 0) this._revisionRounds.set(d.number, claimCount);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Dispatch the planning workflow to planner agents.
|
|
298
|
+
*
|
|
299
|
+
* Finds the "Loreli" discussion category, renders the planner
|
|
300
|
+
* prompt with the given objective, and sends it to every planner
|
|
301
|
+
* agent. Returns the dispatched agent names and category ID.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
304
|
+
* @param {string} objective - What should be planned.
|
|
305
|
+
* @returns {Promise<{planners: string[], categoryId: string}>}
|
|
306
|
+
* @throws {Error} When no planner agents are available.
|
|
307
|
+
*/
|
|
308
|
+
async plan(repo, objective) {
|
|
309
|
+
log.info(`planning: ${repo} — objective: ${objective}`);
|
|
310
|
+
const planners = this.agents();
|
|
311
|
+
|
|
312
|
+
if (!planners.length) throw new Error('No planner agents available');
|
|
313
|
+
|
|
314
|
+
this.objective = objective;
|
|
315
|
+
|
|
316
|
+
const cat = await this.hub.category(repo, 'Loreli');
|
|
317
|
+
this.categoryId = cat.id;
|
|
318
|
+
this.repositoryId = cat.repositoryId;
|
|
319
|
+
const dispatched = [];
|
|
320
|
+
|
|
321
|
+
for (const agent of planners) {
|
|
322
|
+
await this.dispatch(agent, {
|
|
323
|
+
name: agent.identity.name,
|
|
324
|
+
repo,
|
|
325
|
+
faction: agent.identity.faction,
|
|
326
|
+
provider: agent.identity.provider,
|
|
327
|
+
model: agent.identity.model,
|
|
328
|
+
objective,
|
|
329
|
+
labels: agent.identity.labels?.('planner'),
|
|
330
|
+
plan: true,
|
|
331
|
+
categoryId: cat.id,
|
|
332
|
+
repositoryId: cat.repositoryId
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
this.orchestrator.activity(agent.identity.name);
|
|
336
|
+
this._dispatched.add(agent.identity.name);
|
|
337
|
+
dispatched.push(agent.identity.name);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
log.info(`planning dispatched to ${dispatched.length} planners`);
|
|
341
|
+
return { planners: dispatched, categoryId: cat.id };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Re-dispatch the planning objective to newly spawned planners.
|
|
346
|
+
*
|
|
347
|
+
* When a planner dies before creating any discussions (e.g. budget
|
|
348
|
+
* exhaustion), scale() spawns a replacement. This handler detects
|
|
349
|
+
* the new planner and sends it the original objective so planning
|
|
350
|
+
* can resume without external intervention.
|
|
351
|
+
*
|
|
352
|
+
* Runs on every reactor tick. No-ops when:
|
|
353
|
+
* - No objective is pending
|
|
354
|
+
* - Discussions already exist (original planner succeeded)
|
|
355
|
+
* - No new (undispatched, non-dormant) planners are available
|
|
356
|
+
*
|
|
357
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
358
|
+
* @returns {Promise<void>}
|
|
359
|
+
*/
|
|
360
|
+
async recover(repo) {
|
|
361
|
+
if (!this.objective || !this.categoryId) return;
|
|
362
|
+
|
|
363
|
+
const all = await this.hub.discussions(repo, this.categoryId);
|
|
364
|
+
if (all.length > 0) return;
|
|
365
|
+
|
|
366
|
+
const planners = this.agents().filter(function eligible(a) {
|
|
367
|
+
return a.state !== 'dormant';
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
for (const agent of planners) {
|
|
371
|
+
if (this._dispatched.has(agent.identity.name)) continue;
|
|
372
|
+
|
|
373
|
+
log.info(`recover: re-dispatching objective to ${agent.identity.name}`);
|
|
374
|
+
await this.dispatch(agent, {
|
|
375
|
+
name: agent.identity.name,
|
|
376
|
+
repo,
|
|
377
|
+
faction: agent.identity.faction,
|
|
378
|
+
provider: agent.identity.provider,
|
|
379
|
+
model: agent.identity.model,
|
|
380
|
+
objective: this.objective,
|
|
381
|
+
labels: agent.identity.labels?.('planner'),
|
|
382
|
+
plan: true,
|
|
383
|
+
categoryId: this.categoryId,
|
|
384
|
+
repositoryId: this.repositoryId
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
this.orchestrator.activity(agent.identity.name);
|
|
388
|
+
this._dispatched.add(agent.identity.name);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Check blocked discussions for resolved dependencies.
|
|
394
|
+
*
|
|
395
|
+
* Runs FIRST in the tick pipeline (before revise). For each discussion
|
|
396
|
+
* in the `_blocked` map, verifies whether all blocking issues/PRs have
|
|
397
|
+
* been closed or merged. When resolved, removes both `loreli:blocked`
|
|
398
|
+
* and `loreli:changes-requested` labels so the discussion re-enters
|
|
399
|
+
* the review pipeline fresh.
|
|
400
|
+
*
|
|
401
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
402
|
+
* @returns {Promise<void>}
|
|
403
|
+
*/
|
|
404
|
+
async unblock(repo) {
|
|
405
|
+
if (!this._blocked.size) return;
|
|
406
|
+
|
|
407
|
+
for (const [num, entry] of this._blocked) {
|
|
408
|
+
const open = [];
|
|
409
|
+
for (const ref of entry.blockers) {
|
|
410
|
+
try {
|
|
411
|
+
const item = await this.hub.issue(repo, ref);
|
|
412
|
+
if (item.state === 'open') open.push(ref);
|
|
413
|
+
} catch {
|
|
414
|
+
// Reference may not exist as an issue — skip
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!open.length) {
|
|
419
|
+
try {
|
|
420
|
+
await this.hub.removeDiscussionLabels(repo, entry.id,
|
|
421
|
+
['loreli:blocked', 'loreli:changes-requested']);
|
|
422
|
+
} catch (err) { log.warn(`unblock: label removal failed for #${num}: ${err.message}`); }
|
|
423
|
+
|
|
424
|
+
// discussionComment requires scoping — use planner identity or client fallback
|
|
425
|
+
const signer = this.agents()[0]?.identity ?? this.orchestrator?.clientIdentity;
|
|
426
|
+
if (signer) {
|
|
427
|
+
try {
|
|
428
|
+
const scoped = this.hub.as(signer, 'planner');
|
|
429
|
+
await scoped.discussionComment(entry.id,
|
|
430
|
+
`Blockers resolved (${entry.blockers.map(function hash(n) { return '#' + n; }).join(', ')}). Restarting review.`);
|
|
431
|
+
} catch (err) { log.warn(`unblock: comment failed for #${num}: ${err.message}`); }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
this._blocked.delete(num);
|
|
435
|
+
log.info(`unblock: discussion #${num} unblocked — blockers resolved`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Handle discussions with `loreli:changes-requested` label.
|
|
442
|
+
*
|
|
443
|
+
* Runs SECOND in the tick pipeline (after unblock). For each discussion
|
|
444
|
+
* with changes requested:
|
|
445
|
+
*
|
|
446
|
+
* 1. Blocker detection: parse comments for issue refs, classify via LLM,
|
|
447
|
+
* park if open blockers found
|
|
448
|
+
* 2. Round tracking: increment `_revisionRounds`
|
|
449
|
+
* 3. HITL escalation: if rounds > maxRounds, escalate to human (no auto-approve)
|
|
450
|
+
* 4. Tiebreaker: if rounds === maxRounds, dispatch with tiebreaker prompt
|
|
451
|
+
* 5. Normal: dispatch planner for standard revision
|
|
452
|
+
*
|
|
453
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
454
|
+
* @returns {Promise<Array<{number: number, planner: string}>>}
|
|
455
|
+
*/
|
|
456
|
+
async revise(repo) {
|
|
457
|
+
if (!this.categoryId) return [];
|
|
458
|
+
|
|
459
|
+
const all = await this.hub.discussions(repo, this.categoryId);
|
|
460
|
+
const self = this;
|
|
461
|
+
|
|
462
|
+
// Self-clear _revising for discussions whose label changed (planner
|
|
463
|
+
// successfully revised and the `loreli:changes-requested` was removed).
|
|
464
|
+
const currentCR = new Set(
|
|
465
|
+
all.filter(function hasCR(d) { return d.labels.includes('loreli:changes-requested'); })
|
|
466
|
+
.map(function num(d) { return d.number; })
|
|
467
|
+
);
|
|
468
|
+
for (const num of this._revising) {
|
|
469
|
+
if (!currentCR.has(num)) this._revising.delete(num);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const needsRevision = all.filter(function changesRequested(d) {
|
|
473
|
+
return !d.closed &&
|
|
474
|
+
d.labels.includes('loreli:changes-requested') &&
|
|
475
|
+
!d.labels.includes('loreli:rejected') &&
|
|
476
|
+
!self._blocked.has(d.number) &&
|
|
477
|
+
!self._revising.has(d.number);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Clear in-flight tracking for discussions that now have labels —
|
|
481
|
+
// the reviewer completed their work.
|
|
482
|
+
for (const d of needsRevision) this._inflight.delete(d.number);
|
|
483
|
+
|
|
484
|
+
if (!needsRevision.length) return [];
|
|
485
|
+
|
|
486
|
+
const planner = this.agents()[0];
|
|
487
|
+
if (!planner) {
|
|
488
|
+
log.info('revise: no planner agent available — skipping');
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const maxRounds = this.orchestrator.cfg?.get?.('watch.maxRounds') ?? 7;
|
|
493
|
+
const revised = [];
|
|
494
|
+
|
|
495
|
+
for (const disc of needsRevision) {
|
|
496
|
+
const full = await this.hub.discussion(repo, disc.number);
|
|
497
|
+
|
|
498
|
+
// ── Blocker detection via heuristic classification ──
|
|
499
|
+
const refs = extractRefs(full.comments);
|
|
500
|
+
if (refs.length) {
|
|
501
|
+
const classified = classifyRefs(full.comments, refs);
|
|
502
|
+
|
|
503
|
+
if (classified.blockers.length) {
|
|
504
|
+
// Verify classified blockers are actually open issues
|
|
505
|
+
const open = [];
|
|
506
|
+
for (const ref of classified.blockers) {
|
|
507
|
+
try {
|
|
508
|
+
const item = await this.hub.issue(repo, ref);
|
|
509
|
+
if (item.state === 'open') open.push(ref);
|
|
510
|
+
} catch {
|
|
511
|
+
// Not an issue — skip
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (open.length) {
|
|
516
|
+
try {
|
|
517
|
+
await this.hub.applyDiscussionLabels(repo, disc.id, ['loreli:blocked']);
|
|
518
|
+
} catch (err) { log.warn(`revise: label failed for #${disc.number}: ${err.message}`); }
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
await this.hub.discussionComment(disc.id,
|
|
522
|
+
`Blocked by open items: ${open.map(function hash(n) { return '#' + n; }).join(', ')}. Will resume review when resolved.`);
|
|
523
|
+
} catch (err) { log.warn(`revise: comment failed for #${disc.number}: ${err.message}`); }
|
|
524
|
+
|
|
525
|
+
this._blocked.set(disc.number, { id: disc.id, blockers: open });
|
|
526
|
+
log.info(`revise: discussion #${disc.number} blocked by ${open.join(', ')}`);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── Round tracking and tiebreaker/safety-net ──
|
|
533
|
+
const rounds = (this._revisionRounds.get(disc.number) ?? 0) + 1;
|
|
534
|
+
this._revisionRounds.set(disc.number, rounds);
|
|
535
|
+
|
|
536
|
+
if (rounds > maxRounds) {
|
|
537
|
+
// HITL escalation: do NOT auto-approve. Tag humans and block.
|
|
538
|
+
try {
|
|
539
|
+
await this.hub.applyDiscussionLabels(repo, disc.id, ['loreli:needs-attention']);
|
|
540
|
+
|
|
541
|
+
const reviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
|
|
542
|
+
const mentions = reviewers.length
|
|
543
|
+
? reviewers.map(function mention(r) { return `@${r}`; }).join(', ')
|
|
544
|
+
: '';
|
|
545
|
+
|
|
546
|
+
const signer = planner?.identity ?? this.orchestrator?.clientIdentity;
|
|
547
|
+
if (signer) {
|
|
548
|
+
const scoped = this.hub.as(signer, planner?.role ?? 'planner');
|
|
549
|
+
const msg = mentions
|
|
550
|
+
? `**Needs human attention** — ${mentions}\n\n` +
|
|
551
|
+
`This discussion exceeded ${rounds} revision rounds without convergence. ` +
|
|
552
|
+
'A human must review and decide how to proceed.'
|
|
553
|
+
: `**Needs human attention**\n\n` +
|
|
554
|
+
`This discussion exceeded ${rounds} revision rounds without convergence. ` +
|
|
555
|
+
'A human must review and decide how to proceed.';
|
|
556
|
+
await scoped.discussionComment(disc.id, `${mark('hitl')}\n${msg}`);
|
|
557
|
+
}
|
|
558
|
+
} catch (err) { log.warn(`revise: HITL escalation failed for #${disc.number}: ${err.message}`); }
|
|
559
|
+
|
|
560
|
+
this._inflight.delete(disc.number);
|
|
561
|
+
log.warn(`revise: HITL escalation for discussion #${disc.number} after ${rounds} rounds`);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const isTiebreaker = rounds === maxRounds;
|
|
566
|
+
|
|
567
|
+
// Write task context for the agent's MCP tools
|
|
568
|
+
await this.saveTask(planner.identity.name, {
|
|
569
|
+
type: 'revise_discussion',
|
|
570
|
+
discussion: full.number,
|
|
571
|
+
discussionId: full.id
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
await this.dispatch(planner, {
|
|
575
|
+
name: planner.identity.name,
|
|
576
|
+
repo,
|
|
577
|
+
faction: planner.identity.faction,
|
|
578
|
+
provider: planner.identity.provider,
|
|
579
|
+
model: planner.identity.model,
|
|
580
|
+
labels: planner.identity.labels?.('planner'),
|
|
581
|
+
revise: {
|
|
582
|
+
discussionId: full.id,
|
|
583
|
+
number: full.number,
|
|
584
|
+
title: full.title,
|
|
585
|
+
body: full.body,
|
|
586
|
+
comments: full.comments,
|
|
587
|
+
tiebreaker: isTiebreaker,
|
|
588
|
+
rounds,
|
|
589
|
+
maxRounds
|
|
590
|
+
},
|
|
591
|
+
categoryId: this.categoryId,
|
|
592
|
+
repositoryId: this.repositoryId
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
this._revising.add(disc.number);
|
|
596
|
+
this.orchestrator.activity(planner.identity.name);
|
|
597
|
+
revised.push({ number: disc.number, planner: planner.identity.name });
|
|
598
|
+
log.info(`revise: dispatched ${planner.identity.name} for discussion #${disc.number}${isTiebreaker ? ' (TIEBREAKER)' : ''}`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return revised;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Dispatch discussions without review labels to a reviewer agent.
|
|
606
|
+
*
|
|
607
|
+
* Runs THIRD in the tick pipeline (after unblock and revise). Selects
|
|
608
|
+
* the appropriate reviewer template based on the discussion's revision
|
|
609
|
+
* round count:
|
|
610
|
+
* - Normal: `plan-reviewer.md` for standard adversarial review
|
|
611
|
+
* - Tiebreaker: `tiebreaker-reviewer.md` when rounds >= maxRounds
|
|
612
|
+
*
|
|
613
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
614
|
+
* @returns {Promise<{reviewer: string|null, discussions: number}>}
|
|
615
|
+
*/
|
|
616
|
+
async review(repo) {
|
|
617
|
+
if (!this.categoryId) return { reviewer: null, discussions: 0 };
|
|
618
|
+
|
|
619
|
+
// Clear stale in-flight entries when no productive reviewer exists.
|
|
620
|
+
// An active reviewer must be in a working state (spawned/running) —
|
|
621
|
+
// dormant agents finished their work, dead agents crashed, and
|
|
622
|
+
// stalled agents may never produce output.
|
|
623
|
+
const reviewers = [...this.orchestrator.agents.values()]
|
|
624
|
+
.filter(function isReviewer(a) { return a.role === 'reviewer'; });
|
|
625
|
+
const productive = reviewers.filter(function working(a) {
|
|
626
|
+
return a.state === 'spawned' || a.state === 'running';
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
if (!productive.length && this._inflight.size) {
|
|
630
|
+
for (const n of this._inflight) this._cleared.add(n);
|
|
631
|
+
log.info(`review: clearing ${this._inflight.size} stale in-flight entries — no productive reviewer (${reviewers.length} registered, 0 working)`);
|
|
632
|
+
this._inflight.clear();
|
|
633
|
+
this._reviewing = null;
|
|
634
|
+
this._reviewerName = null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const all = await this.hub.discussions(repo, this.categoryId);
|
|
638
|
+
const self = this;
|
|
639
|
+
|
|
640
|
+
// ── Commitment check ────────────────────────────────────
|
|
641
|
+
// Lock the reviewer to a discussion's full review/revision cycle.
|
|
642
|
+
// Keeps the reviewer focused on one discussion through its full
|
|
643
|
+
// review cycle before moving to the next.
|
|
644
|
+
if (this._reviewing !== null) {
|
|
645
|
+
const committed = all.find(function locked(d) { return d.number === self._reviewing; });
|
|
646
|
+
|
|
647
|
+
if (!committed || committed.closed || committed.labels.includes('loreli:approved')) {
|
|
648
|
+
// Discussion resolved or disappeared — unlock
|
|
649
|
+
this._reviewing = null;
|
|
650
|
+
this._reviewerName = null;
|
|
651
|
+
} else if (committed.labels.includes('loreli:changes-requested') ||
|
|
652
|
+
committed.labels.includes('loreli:blocked')) {
|
|
653
|
+
// In revision or blocked — wait for the cycle to complete
|
|
654
|
+
log.debug(`review: discussion #${this._reviewing} in revision — reviewer waiting`);
|
|
655
|
+
return { reviewer: null, discussions: 0 };
|
|
656
|
+
} else if (this._inflight.has(this._reviewing)) {
|
|
657
|
+
// Verify the committed reviewer agent is still registered and
|
|
658
|
+
// productive. A reviewer can become dormant (completed other work)
|
|
659
|
+
// or dead (killed by stall detection) while the in-flight entry
|
|
660
|
+
// lingers — without this check, the system deadlocks.
|
|
661
|
+
const assignee = this._reviewerName
|
|
662
|
+
? this.orchestrator.agents.get(this._reviewerName)
|
|
663
|
+
: null;
|
|
664
|
+
const alive = assignee && (assignee.state === 'spawned' || assignee.state === 'running' || assignee.state === 'working');
|
|
665
|
+
if (alive) {
|
|
666
|
+
log.debug(`review: reviewer ${this._reviewerName} still working on #${this._reviewing} — waiting`);
|
|
667
|
+
return { reviewer: null, discussions: 0 };
|
|
668
|
+
}
|
|
669
|
+
// Committed reviewer is gone or dormant — clear stale state
|
|
670
|
+
log.info(`review: clearing stale commitment to #${this._reviewing} — reviewer ${this._reviewerName ?? 'unknown'} no longer active`);
|
|
671
|
+
this._cleared.add(this._reviewing);
|
|
672
|
+
this._inflight.delete(this._reviewing);
|
|
673
|
+
this._reviewing = null;
|
|
674
|
+
this._reviewerName = null;
|
|
675
|
+
}
|
|
676
|
+
// else: no label, not in _inflight → revised and needs re-review
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Pre-check: fetch discussion details for review-claim markers
|
|
680
|
+
// to avoid dispatching reviewers for discussions already claimed
|
|
681
|
+
// by another participant. Only fetch for discussions that pass
|
|
682
|
+
// the basic label filter to minimize API calls.
|
|
683
|
+
const candidates = all.filter(function unlabeled(d) {
|
|
684
|
+
if (self._reviewing !== null && d.number !== self._reviewing) return false;
|
|
685
|
+
return !d.closed &&
|
|
686
|
+
!d.labels.includes('loreli:approved') &&
|
|
687
|
+
!d.labels.includes('loreli:changes-requested') &&
|
|
688
|
+
!d.labels.includes('loreli:blocked') &&
|
|
689
|
+
!self._inflight.has(d.number);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
const needsReview = [];
|
|
693
|
+
for (const d of candidates) {
|
|
694
|
+
const full = await this.hub.discussion(repo, d.number);
|
|
695
|
+
const claimed = full.comments?.some(function isClaimed(c) { return has(c.body, 'review-claim'); });
|
|
696
|
+
// Re-review: when committed to this discussion, our prior claim is from us — allow re-dispatch
|
|
697
|
+
// Stale clear: discussions we just cleared from _inflight (reviewer died) need re-dispatch
|
|
698
|
+
if (!claimed || self._reviewing === d.number || self._cleared.has(d.number)) needsReview.push(d);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
log.debug(`review: ${all.length} total, ${needsReview.length} need review, ${this._inflight.size} in-flight`);
|
|
702
|
+
|
|
703
|
+
if (!needsReview.length) return { reviewer: null, discussions: 0 };
|
|
704
|
+
|
|
705
|
+
// Find the first planner to determine opposing provider
|
|
706
|
+
const planner = this.agents()[0];
|
|
707
|
+
let providers = [];
|
|
708
|
+
let mode = 'none';
|
|
709
|
+
|
|
710
|
+
try {
|
|
711
|
+
await this.orchestrator.backendRegistry?.discover?.();
|
|
712
|
+
providers = this.orchestrator.backendRegistry?.providers?.() ?? [];
|
|
713
|
+
mode = capability(providers).mode;
|
|
714
|
+
} catch { /* non-fatal: enlist fallback uses planner provider */ }
|
|
715
|
+
|
|
716
|
+
// Cross-provider pairing: find a reviewer from the opposing provider.
|
|
717
|
+
// Reuses the `reviewers` list computed above for stale in-flight detection.
|
|
718
|
+
let reviewer;
|
|
719
|
+
if (planner) {
|
|
720
|
+
reviewer = this.pair(planner.identity, reviewers);
|
|
721
|
+
|
|
722
|
+
// Prefer cross-provider review in dual-side environments.
|
|
723
|
+
// In single-side environments, fall back to any available provider.
|
|
724
|
+
if (!reviewer) {
|
|
725
|
+
if (mode === 'dual') {
|
|
726
|
+
const opposing = this.orchestrator.identityRegistry.opposite(planner.identity);
|
|
727
|
+
reviewer = await this.enlist(opposing.provider, 'reviewer');
|
|
728
|
+
} else {
|
|
729
|
+
const fallbackProvider = providers[0] ?? planner.identity.provider;
|
|
730
|
+
reviewer = await this.enlist(fallbackProvider, 'reviewer');
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
} else {
|
|
734
|
+
// Planner died (budget, crash) — infer the planner's side from
|
|
735
|
+
// the discussion body's agent stamp to maintain adversarial pairing.
|
|
736
|
+
const prefetch = await this.hub.discussion(repo, needsReview[0].number);
|
|
737
|
+
const stamp = parse(prefetch?.body, 'agent');
|
|
738
|
+
if (stamp?.provider) {
|
|
739
|
+
reviewer = this.pair({ provider: stamp.provider }, reviewers);
|
|
740
|
+
}
|
|
741
|
+
if (!reviewer) reviewer = reviewers[0] ?? null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (!reviewer) {
|
|
745
|
+
log.info('review: no reviewer available — skipping');
|
|
746
|
+
return { reviewer: null, discussions: 0 };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Dispatch ONE discussion per tick. Sequential dispatch ensures each
|
|
750
|
+
// discussion gets full attention. Remaining discussions are picked
|
|
751
|
+
// up on the next tick when this method runs again.
|
|
752
|
+
const disc = needsReview[0];
|
|
753
|
+
|
|
754
|
+
const full = await this.hub.discussion(repo, disc.number);
|
|
755
|
+
|
|
756
|
+
// Optimistic claim: post a review-claim marker on the discussion,
|
|
757
|
+
// then verify this agent was first. Prevents two reactors from
|
|
758
|
+
// both dispatching reviewers for the same discussion.
|
|
759
|
+
// Skip claim when re-dispatching for a cleared discussion — the prior
|
|
760
|
+
// claim was from a dead reviewer; we are forcibly re-assigning.
|
|
761
|
+
const claimIdentity = reviewer?.identity ?? planner?.identity ?? this.orchestrator.clientIdentity;
|
|
762
|
+
if (claimIdentity && !this._cleared.has(disc.number)) {
|
|
763
|
+
const visible = reviewer?.identity?.claim?.() ?? claimIdentity.claim?.() ?? `Claimed by **${claimIdentity.name}**`;
|
|
764
|
+
const won = await this.claimFirstDiscussion(repo, disc.number, full.id, 'review-claim', claimIdentity, 'reviewer', visible);
|
|
765
|
+
if (!won) {
|
|
766
|
+
log.info(`review: lost review-claim race for discussion #${disc.number} — skipping`);
|
|
767
|
+
return { reviewer: null, discussions: 0 };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
this._inflight.add(disc.number);
|
|
772
|
+
this._cleared.delete(disc.number);
|
|
773
|
+
this._reviewing = disc.number;
|
|
774
|
+
this._reviewerName = reviewer.identity.name;
|
|
775
|
+
|
|
776
|
+
await this.saveTask(reviewer.identity.name, {
|
|
777
|
+
type: 'review_discussion',
|
|
778
|
+
discussion: full.number,
|
|
779
|
+
discussionId: full.id
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// Select reviewer template based on revision history
|
|
783
|
+
const maxRounds = this.orchestrator.cfg?.get?.('watch.maxRounds') ?? 7;
|
|
784
|
+
const rounds = this._revisionRounds.get(disc.number) ?? 0;
|
|
785
|
+
const template = rounds >= maxRounds ? TIEBREAKER_TEMPLATE : PLAN_REVIEWER_TEMPLATE;
|
|
786
|
+
|
|
787
|
+
const prompt = await this.renderFrom(template, {
|
|
788
|
+
name: reviewer.identity.name,
|
|
789
|
+
repo,
|
|
790
|
+
faction: reviewer.identity.faction,
|
|
791
|
+
provider: reviewer.identity.provider,
|
|
792
|
+
model: reviewer.identity.model,
|
|
793
|
+
labels: reviewer.identity.labels?.('reviewer'),
|
|
794
|
+
planReview: {
|
|
795
|
+
discussionId: full.id,
|
|
796
|
+
number: full.number,
|
|
797
|
+
title: full.title,
|
|
798
|
+
body: full.body,
|
|
799
|
+
comments: full.comments,
|
|
800
|
+
rounds,
|
|
801
|
+
maxRounds
|
|
802
|
+
}
|
|
803
|
+
}, { role: 'reviewer' });
|
|
804
|
+
|
|
805
|
+
await reviewer.send(prompt);
|
|
806
|
+
this.orchestrator.activity(reviewer.identity.name);
|
|
807
|
+
this._discussionReviewer = reviewer.identity.name;
|
|
808
|
+
|
|
809
|
+
const reviewMode = rounds >= maxRounds ? 'TIEBREAKER' : 'normal';
|
|
810
|
+
log.info(`review: dispatched ${reviewer.identity.name} for 1 discussion (#${disc.number}, ${reviewMode})`);
|
|
811
|
+
return { reviewer: reviewer.identity.name, discussions: 1 };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Promote approved discussions to real issues.
|
|
816
|
+
*
|
|
817
|
+
* Runs LAST in the tick pipeline. For each discussion with the
|
|
818
|
+
* `loreli:approved` label, creates a real issue, posts a closing
|
|
819
|
+
* comment linking to the issue, and closes/locks the discussion.
|
|
820
|
+
*
|
|
821
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
822
|
+
* @returns {Promise<Array<{number: number, title: string}>>}
|
|
823
|
+
*/
|
|
824
|
+
async promote(repo) {
|
|
825
|
+
if (!this.categoryId) return [];
|
|
826
|
+
|
|
827
|
+
const all = await this.hub.discussions(repo, this.categoryId);
|
|
828
|
+
const approved = all.filter(function isApproved(d) {
|
|
829
|
+
return !d.closed && d.labels.includes('loreli:approved');
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
// Clear tracking state for approved discussions
|
|
833
|
+
for (const d of approved) {
|
|
834
|
+
this._inflight.delete(d.number);
|
|
835
|
+
this._revisionRounds.delete(d.number);
|
|
836
|
+
this._blocked.delete(d.number);
|
|
837
|
+
this._revising.delete(d.number);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (!approved.length) return [];
|
|
841
|
+
|
|
842
|
+
// Get a signer for the promotion — prefer planner, fall back to any agent
|
|
843
|
+
const planner = this.agents()[0];
|
|
844
|
+
const signer = planner?.identity ?? this.orchestrator.clientIdentity;
|
|
845
|
+
if (!signer) {
|
|
846
|
+
log.warn('promote: no identity available for signing — skipping');
|
|
847
|
+
return [];
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const scoped = this.hub.as(signer, planner?.role ?? 'planner');
|
|
851
|
+
const promoted = [];
|
|
852
|
+
|
|
853
|
+
for (const disc of approved) {
|
|
854
|
+
try {
|
|
855
|
+
// Strip the discussion's existing signature so open() applies
|
|
856
|
+
// exactly one fresh stamp — prevents double-signature on promoted issues.
|
|
857
|
+
const issue = await scoped.open(repo, {
|
|
858
|
+
title: disc.title,
|
|
859
|
+
body: Identity.strip(disc.body)
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// Post themed closing comment linking to the new issue.
|
|
863
|
+
// GitHub auto-links #N references, so only the number is needed.
|
|
864
|
+
const visible = signer.promote?.(issue.number)
|
|
865
|
+
?? `Promoted to issue #${issue.number}`;
|
|
866
|
+
await scoped.discussionComment(disc.id, visible);
|
|
867
|
+
|
|
868
|
+
// Close and lock the discussion
|
|
869
|
+
await this.hub.closeDiscussion(disc.id);
|
|
870
|
+
|
|
871
|
+
promoted.push({ number: issue.number, title: issue.title });
|
|
872
|
+
log.info(`promote: discussion #${disc.number} → issue #${issue.number}`);
|
|
873
|
+
} catch (err) {
|
|
874
|
+
log.warn(`promote: failed for discussion #${disc.number}: ${err.message}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Signal link() to defer parent creation by one tick. Creating
|
|
879
|
+
// the parent on the same tick as promotion causes a race: the
|
|
880
|
+
// parent appears in hub.issues() queries alongside the children,
|
|
881
|
+
// inflating issue counts for consumers that poll open issues.
|
|
882
|
+
if (promoted.length) this._justPromoted = true;
|
|
883
|
+
|
|
884
|
+
// Only shut down agents when ALL discussions are resolved (approved+promoted
|
|
885
|
+
// or closed). If any open discussion still needs revision or review, the
|
|
886
|
+
// planner and reviewer must stay alive to handle it.
|
|
887
|
+
const remaining = all.filter(function unresolved(d) {
|
|
888
|
+
return !d.closed && !d.labels.includes('loreli:approved');
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
if (remaining.length === 0) {
|
|
892
|
+
// Clear dispatch state — planning phase is complete
|
|
893
|
+
this._reviewing = null;
|
|
894
|
+
this._reviewerName = null;
|
|
895
|
+
this._inflight.clear();
|
|
896
|
+
this._revising.clear();
|
|
897
|
+
|
|
898
|
+
const planners = this.agents();
|
|
899
|
+
for (const p of planners) {
|
|
900
|
+
try {
|
|
901
|
+
await this.orchestrator.kill(p.identity.name);
|
|
902
|
+
log.info(`promote: killed planner ${p.identity.name} — all discussions resolved`);
|
|
903
|
+
} catch (err) {
|
|
904
|
+
log.warn(`promote: failed to kill ${p.identity.name}: ${err.message}`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Kill only the discussion reviewer — PR reviewers share the same
|
|
909
|
+
// role but are managed by ReviewWorkflow and must not be disrupted.
|
|
910
|
+
if (this._discussionReviewer) {
|
|
911
|
+
try {
|
|
912
|
+
await this.orchestrator.kill(this._discussionReviewer);
|
|
913
|
+
log.info(`promote: killed reviewer ${this._discussionReviewer} — all discussions resolved`);
|
|
914
|
+
} catch (err) {
|
|
915
|
+
log.warn(`promote: failed to kill ${this._discussionReviewer}: ${err.message}`);
|
|
916
|
+
}
|
|
917
|
+
this._discussionReviewer = null;
|
|
918
|
+
}
|
|
919
|
+
} else {
|
|
920
|
+
log.info(`promote: ${remaining.length} discussion(s) still open — planners stay alive`);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
return promoted;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Link promoted child issues to a parent tracking issue using GitHub
|
|
928
|
+
* sub-issues. Runs on every reactor tick after promote().
|
|
929
|
+
*
|
|
930
|
+
* All state is derived from GitHub (labels + markers), so this handler
|
|
931
|
+
* is fully idempotent and distributed-safe across multiple machines.
|
|
932
|
+
*
|
|
933
|
+
* Flow:
|
|
934
|
+
* 1. Scan for existing parent issue (loreli:parent label + marker)
|
|
935
|
+
* 2. Deduplicate racing parents (keep lowest issue number)
|
|
936
|
+
* 3. Discover unlinked child issues (loreli-labeled, agent-stamped, not parent)
|
|
937
|
+
* 4. Create parent if >= 2 children and no parent exists
|
|
938
|
+
* 5. Link unlinked children via sub-issues API
|
|
939
|
+
*
|
|
940
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
941
|
+
* @returns {Promise<void>}
|
|
942
|
+
*/
|
|
943
|
+
async link(repo) {
|
|
944
|
+
// Defer parent creation by one tick after any promotion. This
|
|
945
|
+
// prevents the parent from appearing in the same hub.issues()
|
|
946
|
+
// result set as the newly promoted children.
|
|
947
|
+
if (this._justPromoted) {
|
|
948
|
+
this._justPromoted = false;
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Find existing parent issues via label scan
|
|
953
|
+
const candidates = await this.hub.issues(repo, {
|
|
954
|
+
state: 'open', labels: ['loreli:parent']
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
// Confirm via marker — guards against accidental manual labeling
|
|
958
|
+
const parents = candidates.filter(function confirmed(i) {
|
|
959
|
+
return has(i.body, 'parent');
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
let parent = null;
|
|
963
|
+
|
|
964
|
+
// Deduplicate racing parents: keep lowest number, close others
|
|
965
|
+
if (parents.length > 1) {
|
|
966
|
+
parents.sort(function byNumber(a, b) { return a.number - b.number; });
|
|
967
|
+
parent = parents[0];
|
|
968
|
+
|
|
969
|
+
for (let i = 1; i < parents.length; i++) {
|
|
970
|
+
try {
|
|
971
|
+
const signer = this.agents()[0]?.identity ?? this.orchestrator?.clientIdentity;
|
|
972
|
+
if (signer) {
|
|
973
|
+
const scoped = this.hub.as(signer, 'planner');
|
|
974
|
+
await scoped.comment(repo, parents[i].number,
|
|
975
|
+
`Duplicate parent detected. Keeping #${parent.number} as canonical parent.`);
|
|
976
|
+
}
|
|
977
|
+
// Close via direct Octokit — hub doesn't expose issue update yet
|
|
978
|
+
const [owner, name] = this.hub.parse(repo);
|
|
979
|
+
await this.hub.client.issues.update({
|
|
980
|
+
owner, repo: name, issue_number: parents[i].number, state: 'closed'
|
|
981
|
+
});
|
|
982
|
+
log.info(`link: closed duplicate parent #${parents[i].number} — keeping #${parent.number}`);
|
|
983
|
+
} catch (err) {
|
|
984
|
+
log.warn(`link: failed to close duplicate parent #${parents[i].number}: ${err.message}`);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
} else if (parents.length === 1) {
|
|
988
|
+
parent = parents[0];
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Discover child issues: agent-stamped loreli issues that are NOT the parent.
|
|
992
|
+
// The agent marker guards against human-created issues being linked as
|
|
993
|
+
// sub-issues — only issues promoted from plans carry the stamp.
|
|
994
|
+
const all = await this.hub.issues(repo, { state: 'open', labels: ['loreli'] });
|
|
995
|
+
const children = all.filter(function isChild(i) {
|
|
996
|
+
return has(i.body, 'agent') && !has(i.body, 'parent') && i.number !== parent?.number;
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
if (children.length < 2 && !parent) {
|
|
1000
|
+
// Not enough children for a parent hierarchy yet
|
|
1001
|
+
if (children.length > 0) {
|
|
1002
|
+
log.debug(`link: ${children.length} child issue(s) — waiting for more before creating parent`);
|
|
1003
|
+
}
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Create parent if needed
|
|
1008
|
+
if (!parent && children.length >= 2) {
|
|
1009
|
+
const signer = this.agents()[0]?.identity ?? this.orchestrator?.clientIdentity;
|
|
1010
|
+
if (!signer) {
|
|
1011
|
+
log.warn('link: no identity available for creating parent issue');
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const objective = this.objective ?? 'Planning objective';
|
|
1016
|
+
const title = objective.split('\n')[0].slice(0, 128);
|
|
1017
|
+
const taskList = children
|
|
1018
|
+
.map(function line(c) { return `- [ ] #${c.number} — ${c.title}`; })
|
|
1019
|
+
.join('\n');
|
|
1020
|
+
|
|
1021
|
+
const body = [
|
|
1022
|
+
'## Objective\n',
|
|
1023
|
+
objective,
|
|
1024
|
+
'\n### Work Items\n',
|
|
1025
|
+
taskList,
|
|
1026
|
+
'\n',
|
|
1027
|
+
mark('parent', { objective })
|
|
1028
|
+
].join('\n');
|
|
1029
|
+
|
|
1030
|
+
const scoped = this.hub.as(signer, 'planner');
|
|
1031
|
+
const created = await scoped.open(repo, {
|
|
1032
|
+
title,
|
|
1033
|
+
body,
|
|
1034
|
+
labels: ['loreli:parent']
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
parent = created;
|
|
1038
|
+
log.info(`link: created parent issue #${parent.number} for objective: ${objective}`);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (!parent) return;
|
|
1042
|
+
|
|
1043
|
+
// Get already-linked sub-issues for idempotency
|
|
1044
|
+
let linked;
|
|
1045
|
+
try {
|
|
1046
|
+
linked = await this.hub.subs(repo, parent.number);
|
|
1047
|
+
} catch {
|
|
1048
|
+
// Sub-issues API may not be available — log and bail
|
|
1049
|
+
log.warn('link: sub-issues API unavailable — skipping linking');
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const linkedIds = new Set(linked.map(function id(s) { return s.id; }));
|
|
1054
|
+
|
|
1055
|
+
// Link unlinked children
|
|
1056
|
+
for (const child of children) {
|
|
1057
|
+
if (linkedIds.has(child.id)) continue;
|
|
1058
|
+
|
|
1059
|
+
try {
|
|
1060
|
+
await this.hub.sub(repo, parent.number, child.id);
|
|
1061
|
+
log.info(`link: linked #${child.number} as sub-issue of #${parent.number}`);
|
|
1062
|
+
} catch (err) {
|
|
1063
|
+
log.warn(`link: failed to link #${child.number} to #${parent.number}: ${err.message}`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Scan for stale HITL discussions that have timed out without human response.
|
|
1070
|
+
* Applies loreli:stale label and re-pings configured reviewers.
|
|
1071
|
+
* Idempotent — discussions already labeled loreli:stale are skipped.
|
|
1072
|
+
*
|
|
1073
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
1074
|
+
* @returns {Promise<void>}
|
|
1075
|
+
*/
|
|
1076
|
+
async reap(repo) {
|
|
1077
|
+
const timeout = this.orchestrator.cfg?.get?.('hitl.timeout');
|
|
1078
|
+
if (timeout == null) return;
|
|
1079
|
+
|
|
1080
|
+
if (!this.categoryId) return;
|
|
1081
|
+
|
|
1082
|
+
const all = await this.hub.discussions(repo, this.categoryId);
|
|
1083
|
+
const needsAttention = all.filter(function hasNeedsAttention(d) {
|
|
1084
|
+
return !d.closed &&
|
|
1085
|
+
d.labels.includes('loreli:needs-attention') &&
|
|
1086
|
+
!d.labels.includes('loreli:stale');
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
if (!needsAttention.length) return;
|
|
1090
|
+
|
|
1091
|
+
const now = Date.now();
|
|
1092
|
+
const reviewers = this.orchestrator.cfg?.get?.('reviewers') ?? [];
|
|
1093
|
+
|
|
1094
|
+
for (const disc of needsAttention) {
|
|
1095
|
+
const full = await this.hub.discussion(repo, disc.number);
|
|
1096
|
+
const hitlComment = full.comments?.find(function isHitl(c) { return has(c.body, 'hitl'); });
|
|
1097
|
+
if (!hitlComment) continue;
|
|
1098
|
+
|
|
1099
|
+
const elapsed = now - new Date(hitlComment.created ?? hitlComment.created_at).getTime();
|
|
1100
|
+
if (elapsed < timeout) continue;
|
|
1101
|
+
|
|
1102
|
+
const mentions = reviewers.length
|
|
1103
|
+
? reviewers.map(function mention(r) { return `@${r}`; }).join(', ')
|
|
1104
|
+
: '';
|
|
1105
|
+
|
|
1106
|
+
const signer = this.agents()[0]?.identity ?? this.orchestrator.clientIdentity;
|
|
1107
|
+
if (signer) {
|
|
1108
|
+
try {
|
|
1109
|
+
const scoped = this.hub.as(signer, 'planner');
|
|
1110
|
+
const msg = mentions
|
|
1111
|
+
? `**HITL reminder** — ${mentions}\n\nThis discussion has been awaiting human attention for over ${Math.round(elapsed / 3600000)} hours.`
|
|
1112
|
+
: `**HITL reminder**\n\nThis discussion has been awaiting human attention for over ${Math.round(elapsed / 3600000)} hours.`;
|
|
1113
|
+
await scoped.discussionComment(disc.id, `${mark('hitl')}\n${msg}`);
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
log.warn(`reap: failed to post reminder on discussion #${disc.number}: ${err.message}`);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
try {
|
|
1120
|
+
await this.hub.applyDiscussionLabels(repo, disc.id, ['loreli:stale']);
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
log.warn(`reap: failed to label discussion #${disc.number} as stale: ${err.message}`);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
log.warn(`reap: discussion #${disc.number} marked stale — HITL timeout exceeded (${Math.round(elapsed / 3600000)}h)`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Signal a concern or feature idea to the Loreli discussion category.
|
|
1131
|
+
*
|
|
1132
|
+
* Creates a discussion for adversarial review, rather than directly
|
|
1133
|
+
* creating an issue. The discussion goes through the standard
|
|
1134
|
+
* review → revise → approve → promote pipeline.
|
|
1135
|
+
*
|
|
1136
|
+
* @param {string} repo - Repository in "owner/name" format.
|
|
1137
|
+
* @param {string} title - Brief title for the escalation.
|
|
1138
|
+
* @param {string} body - Detailed description of the concern.
|
|
1139
|
+
* @param {object} [fallbackIdentity] - Identity to use when no planner agents exist.
|
|
1140
|
+
* @returns {Promise<{discussionId: string, categoryId: string}>}
|
|
1141
|
+
* @throws {Error} When no identity is available for signing.
|
|
1142
|
+
*/
|
|
1143
|
+
async escalate(repo, title, body, fallbackIdentity) {
|
|
1144
|
+
// Ensure category is resolved
|
|
1145
|
+
if (!this.categoryId) {
|
|
1146
|
+
const cat = await this.hub.category(repo, 'Loreli');
|
|
1147
|
+
this.categoryId = cat.id;
|
|
1148
|
+
this.repositoryId = cat.repositoryId;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const planner = this.agents()[0];
|
|
1152
|
+
const signer = planner?.identity ?? fallbackIdentity;
|
|
1153
|
+
if (!signer) throw new Error('Cannot escalate without an identity');
|
|
1154
|
+
|
|
1155
|
+
const scoped = this.hub.as(signer, planner?.role ?? 'planner');
|
|
1156
|
+
const disc = await scoped.discuss(repo, {
|
|
1157
|
+
title,
|
|
1158
|
+
body,
|
|
1159
|
+
categoryId: this.categoryId,
|
|
1160
|
+
repositoryId: this.repositoryId
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
log.info(`escalated: "${title}" → discussion #${disc.number}`);
|
|
1164
|
+
return { discussionId: disc.id, categoryId: this.categoryId };
|
|
1165
|
+
}
|
|
1166
|
+
}
|