loreli 0.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +710 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +77 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/prompts/action.md +172 -0
  8. package/packages/action/src/index.js +684 -0
  9. package/packages/agent/README.md +606 -0
  10. package/packages/agent/src/backends/claude.js +387 -0
  11. package/packages/agent/src/backends/codex.js +351 -0
  12. package/packages/agent/src/backends/cursor.js +371 -0
  13. package/packages/agent/src/backends/index.js +486 -0
  14. package/packages/agent/src/base.js +138 -0
  15. package/packages/agent/src/cli.js +275 -0
  16. package/packages/agent/src/discover.js +396 -0
  17. package/packages/agent/src/factory.js +124 -0
  18. package/packages/agent/src/index.js +12 -0
  19. package/packages/agent/src/models.js +159 -0
  20. package/packages/agent/src/output.js +62 -0
  21. package/packages/agent/src/session.js +162 -0
  22. package/packages/agent/src/trace.js +186 -0
  23. package/packages/classify/README.md +136 -0
  24. package/packages/classify/prompts/blocker.md +12 -0
  25. package/packages/classify/prompts/feedback.md +14 -0
  26. package/packages/classify/prompts/pane-state.md +20 -0
  27. package/packages/classify/src/index.js +81 -0
  28. package/packages/config/README.md +898 -0
  29. package/packages/config/src/defaults.js +145 -0
  30. package/packages/config/src/index.js +223 -0
  31. package/packages/config/src/schema.js +291 -0
  32. package/packages/config/src/validate.js +160 -0
  33. package/packages/context/README.md +165 -0
  34. package/packages/context/src/index.js +198 -0
  35. package/packages/hub/README.md +338 -0
  36. package/packages/hub/src/base.js +154 -0
  37. package/packages/hub/src/github.js +1597 -0
  38. package/packages/hub/src/index.js +79 -0
  39. package/packages/hub/src/labels.js +48 -0
  40. package/packages/identity/README.md +288 -0
  41. package/packages/identity/src/index.js +620 -0
  42. package/packages/identity/src/themes/avatar.js +217 -0
  43. package/packages/identity/src/themes/digimon.js +217 -0
  44. package/packages/identity/src/themes/dragonball.js +217 -0
  45. package/packages/identity/src/themes/lotr.js +217 -0
  46. package/packages/identity/src/themes/marvel.js +217 -0
  47. package/packages/identity/src/themes/pokemon.js +217 -0
  48. package/packages/identity/src/themes/starwars.js +217 -0
  49. package/packages/identity/src/themes/transformers.js +217 -0
  50. package/packages/identity/src/themes/zelda.js +217 -0
  51. package/packages/knowledge/README.md +217 -0
  52. package/packages/knowledge/src/index.js +243 -0
  53. package/packages/log/README.md +93 -0
  54. package/packages/log/src/index.js +252 -0
  55. package/packages/marker/README.md +200 -0
  56. package/packages/marker/src/index.js +184 -0
  57. package/packages/mcp/README.md +323 -0
  58. package/packages/mcp/instructions.md +126 -0
  59. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  60. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  61. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  62. package/packages/mcp/scaffolding/loreli.yml +491 -0
  63. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
  64. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
  65. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
  66. package/packages/mcp/scaffolding/pull-request.md +23 -0
  67. package/packages/mcp/src/index.js +600 -0
  68. package/packages/mcp/src/tools/agent-context.js +44 -0
  69. package/packages/mcp/src/tools/agents.js +450 -0
  70. package/packages/mcp/src/tools/context.js +200 -0
  71. package/packages/mcp/src/tools/github.js +1163 -0
  72. package/packages/mcp/src/tools/hitl.js +162 -0
  73. package/packages/mcp/src/tools/index.js +18 -0
  74. package/packages/mcp/src/tools/refactor.js +227 -0
  75. package/packages/mcp/src/tools/repo.js +44 -0
  76. package/packages/mcp/src/tools/start.js +904 -0
  77. package/packages/mcp/src/tools/status.js +149 -0
  78. package/packages/mcp/src/tools/work.js +134 -0
  79. package/packages/orchestrator/README.md +192 -0
  80. package/packages/orchestrator/src/index.js +1492 -0
  81. package/packages/planner/README.md +251 -0
  82. package/packages/planner/prompts/plan-reviewer.md +109 -0
  83. package/packages/planner/prompts/planner.md +191 -0
  84. package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
  85. package/packages/planner/src/index.js +1381 -0
  86. package/packages/review/README.md +129 -0
  87. package/packages/review/prompts/reviewer.md +158 -0
  88. package/packages/review/src/index.js +1403 -0
  89. package/packages/risk/README.md +178 -0
  90. package/packages/risk/prompts/risk.md +272 -0
  91. package/packages/risk/src/index.js +439 -0
  92. package/packages/session/README.md +165 -0
  93. package/packages/session/src/index.js +215 -0
  94. package/packages/test-utils/README.md +96 -0
  95. package/packages/test-utils/src/index.js +354 -0
  96. package/packages/tmux/README.md +261 -0
  97. package/packages/tmux/src/index.js +501 -0
  98. package/packages/workflow/README.md +317 -0
  99. package/packages/workflow/prompts/preamble.md +14 -0
  100. package/packages/workflow/src/index.js +660 -0
  101. package/packages/workflow/src/proof-of-life.js +74 -0
  102. package/packages/workspace/README.md +143 -0
  103. package/packages/workspace/src/index.js +1127 -0
  104. package/index.js +0 -8
@@ -0,0 +1,660 @@
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
+ import { Tmux } from 'loreli/tmux';
7
+ import { classify } from 'loreli/classify';
8
+ import { logger } from 'loreli/log';
9
+
10
+ export { responder } from './proof-of-life.js';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+
14
+ /**
15
+ * Absolute path to the shared autonomous-mode preamble prepended to
16
+ * every agent prompt. Lives in the workflow package because render()
17
+ * is the single funnel all prompts flow through.
18
+ *
19
+ * @type {string}
20
+ */
21
+ const PREAMBLE = join(__dirname, '..', 'prompts', 'preamble.md');
22
+ const log = logger('workflow');
23
+
24
+ /** @type {string|null} Cached preamble text — loaded once, reused. */
25
+ let _preamble = null;
26
+
27
+ /**
28
+ * Maximum backend-diagnose checks after prompt dispatch.
29
+ *
30
+ * @type {number}
31
+ */
32
+ const DISPATCH_NUDGE_CHECKS = 48;
33
+
34
+ /**
35
+ * Delay between post-dispatch diagnose checks.
36
+ *
37
+ * @type {number}
38
+ */
39
+ const DISPATCH_NUDGE_INTERVAL = 5000;
40
+
41
+ /**
42
+ * Load the shared preamble, caching after the first read.
43
+ *
44
+ * @returns {Promise<string>}
45
+ */
46
+ async function preamble() {
47
+ if (_preamble === null) _preamble = await readFile(PREAMBLE, 'utf8');
48
+ return _preamble;
49
+ }
50
+
51
+ /**
52
+ * Wait for the specified duration.
53
+ *
54
+ * @param {number} ms - Milliseconds to sleep.
55
+ * @returns {Promise<void>}
56
+ */
57
+ async function sleep(ms) {
58
+ await new Promise(function onTimer(resolve) {
59
+ const handle = setTimeout(resolve, ms);
60
+ handle?.unref?.();
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Normalize remedy values into tmux key sequences.
66
+ *
67
+ * Classifier output may provide remedy as a single space-delimited string,
68
+ * while backend fallback diagnosers usually return string arrays.
69
+ *
70
+ * @param {string|string[]|null|undefined} remedy - Remedy value from diagnosis.
71
+ * @returns {string[]} Keys to send through tmux.
72
+ */
73
+ function keys(remedy) {
74
+ if (Array.isArray(remedy)) {
75
+ const list = remedy.filter(Boolean);
76
+ if (list.length > 0) return list;
77
+ return ['Enter'];
78
+ }
79
+
80
+ if (typeof remedy === 'string') {
81
+ const list = remedy.split(/\s+/).filter(Boolean);
82
+ if (list.length > 0) return list;
83
+ return ['Enter'];
84
+ }
85
+
86
+ return ['Enter'];
87
+ }
88
+
89
+ /**
90
+ * Return whether a diagnosis category requires immediate remediation.
91
+ *
92
+ * @param {string|undefined} category - Diagnosis category.
93
+ * @returns {boolean} True when remediation should be applied immediately.
94
+ */
95
+ function actionable(category) {
96
+ return category === 'option_dialog'
97
+ || category === 'waiting_for_input'
98
+ || category === 'fatal'
99
+ || category === 'dead';
100
+ }
101
+
102
+ /**
103
+ * Run pane classification through loreli/classify.
104
+ *
105
+ * This is best-effort: classification errors should not block dispatch
106
+ * nudging because backend regex diagnosis can still recover option dialogs.
107
+ *
108
+ * @param {object} orchestrator - Orchestrator instance containing config/backends.
109
+ * @param {object} agent - Agent metadata.
110
+ * @param {string} pane - Captured pane output.
111
+ * @returns {Promise<object|null>} Classifier diagnosis, or null when unavailable/failed.
112
+ */
113
+ async function diagnose(orchestrator, agent, pane) {
114
+ const backends = orchestrator?.backendRegistry;
115
+ if (!backends?.oneshot) return null;
116
+
117
+ try {
118
+ const result = await classify('pane-state', pane, {
119
+ backends,
120
+ config: orchestrator?.cfg,
121
+ vars: { model: agent.model, backend: agent.backend, role: agent.role }
122
+ });
123
+
124
+ if (result?.category === 'option_dialog') {
125
+ log.info(
126
+ `dispatch nudge classify: ${agent.identity?.name ?? 'unknown'} ${agent.backend} `
127
+ + `${result.category} — ${result.reasoning}`
128
+ );
129
+ }
130
+
131
+ return result;
132
+ } catch (err) {
133
+ log.debug(`dispatch nudge classify failed for ${agent.identity?.name ?? 'unknown'}: ${err.message}`);
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Swallow background nudge errors.
140
+ *
141
+ * @returns {void}
142
+ */
143
+ function ignoreNudgeError() {}
144
+
145
+ /**
146
+ * Abstract base class for role-specific workflow packages.
147
+ *
148
+ * Subclasses must define `static role` (the agent role name) and
149
+ * `static template` (absolute path to the Mustache prompt file).
150
+ * They implement `reactor()` and `events()` to register handlers
151
+ * with the orchestrator's tick loop and EventEmitter respectively.
152
+ *
153
+ * This class absorbs the template rendering responsibility from the
154
+ * deleted `Role` class, adds agent filtering by role, and provides
155
+ * the `renderFrom()` method for cross-role rendering (e.g. the
156
+ * review package rendering the action prompt for `forward()`).
157
+ */
158
+ export class Workflow {
159
+ /**
160
+ * @param {object} orchestrator - Orchestrator instance with agents Map and identityRegistry.
161
+ * @param {object} hub - GitHub hub instance for API calls.
162
+ */
163
+ constructor(orchestrator, hub) {
164
+ if (!new.target.role) {
165
+ throw new Error('Workflow subclass must define static role');
166
+ }
167
+
168
+ /** @type {object} The orchestrator for agent coordination. */
169
+ this.orchestrator = orchestrator;
170
+
171
+ /** @type {object} GitHub hub for API calls. */
172
+ this.hub = hub;
173
+
174
+ /** @type {Map<string, string|null>} Per-role cached custom prompt content. */
175
+ this._custom = new Map();
176
+ }
177
+
178
+ /**
179
+ * Return reactor handler map for the orchestrator's tick loop.
180
+ * Subclasses override to register their specific handlers.
181
+ *
182
+ * @returns {Record<string, Function>} Handler name to async function.
183
+ */
184
+ reactor() {
185
+ return {};
186
+ }
187
+
188
+ /**
189
+ * Return event listener map for orchestrator EventEmitter events.
190
+ * Subclasses override to register their specific listeners.
191
+ *
192
+ * @returns {Record<string, Function>} Event name to handler function.
193
+ */
194
+ events() {
195
+ return {};
196
+ }
197
+
198
+ /**
199
+ * Filter orchestrator agents to those matching this workflow's role.
200
+ *
201
+ * @returns {object[]} Agents whose role matches this workflow's static role.
202
+ */
203
+ agents() {
204
+ const role = this.constructor.role;
205
+ return [...this.orchestrator.agents.values()]
206
+ .filter(function byRole(a) { return a.role === role; });
207
+ }
208
+
209
+ /**
210
+ * Report the current demand signal for this workflow's role.
211
+ *
212
+ * Returns a structured object so the orchestrator can make informed
213
+ * scaling decisions — spawn decisions use `deficit`, cross-role
214
+ * prioritization uses `workload`, and logging uses all three.
215
+ *
216
+ * Subclasses override to compute real values from their hydrated
217
+ * state. The base implementation reports zero demand.
218
+ *
219
+ * @param {string} _repo - Repository in "owner/name" format.
220
+ * @returns {Promise<{workload: number, supply: number, deficit: number}>}
221
+ */
222
+ async demand(_repo) {
223
+ return { workload: 0, supply: 0, deficit: 0 };
224
+ }
225
+
226
+ /**
227
+ * Find the opposing-provider match for yin/yang review pairing.
228
+ * Delegates to the orchestrator's identity registry.
229
+ *
230
+ * @param {object} identity - The identity to find an opposite for.
231
+ * @param {object[]} candidates - Objects with an identity property.
232
+ * @returns {object|null} The matching candidate, or null.
233
+ */
234
+ pair(identity, candidates) {
235
+ return this.orchestrator.identityRegistry.pair(identity, candidates);
236
+ }
237
+
238
+ /**
239
+ * Create and spawn a new agent via the orchestrator's factory.
240
+ *
241
+ * @param {string} provider - AI provider.
242
+ * @param {string} role - Agent role.
243
+ * @param {object} [opts] - Additional factory options.
244
+ * @returns {Promise<object>} The spawned agent.
245
+ */
246
+ async enlist(provider, role, opts) {
247
+ return this.orchestrator.enlist(provider, role, opts);
248
+ }
249
+
250
+ /**
251
+ * Load the project-specific custom prompt for this workflow's role.
252
+ *
253
+ * Resolves `workflows.{role}.prompt` from config, reads the file
254
+ * once from the target repo via the hub, and caches the result on
255
+ * this instance. Returns an empty string when the config key is
256
+ * unset, the hub is unavailable, or the file cannot be read —
257
+ * prompt rendering never fails due to a missing custom prompt.
258
+ *
259
+ * @param {object} [opts] - Options.
260
+ * @param {string} [opts.role] - Role to resolve prompt for.
261
+ * @returns {Promise<string>} Custom prompt text, or empty string.
262
+ */
263
+ async custom(opts = {}) {
264
+ const role = opts.role ?? this.constructor.role;
265
+ if (this._custom.has(role)) return this._custom.get(role) ?? '';
266
+
267
+ const path = this.orchestrator.cfg?.get?.(`workflows.${role}.prompt`);
268
+ if (!path || !this.hub?.read || !this.orchestrator.repo) {
269
+ this._custom.set(role, null);
270
+ return '';
271
+ }
272
+
273
+ try {
274
+ const result = await this.hub.read(this.orchestrator.repo, path);
275
+ this._custom.set(role, result?.content ?? null);
276
+ } catch {
277
+ this._custom.set(role, null);
278
+ }
279
+
280
+ return this._custom.get(role) ?? '';
281
+ }
282
+
283
+ /**
284
+ * Load and render this workflow's prompt template.
285
+ *
286
+ * Automatically injects the workflow's `role` into the template
287
+ * variables so subclasses don't need to pass it explicitly.
288
+ * Prepends the shared autonomous-mode preamble and any project-
289
+ * specific custom prompt from `workflows.{role}.prompt` so every
290
+ * agent prompt carries the headless operation directive and project rules.
291
+ *
292
+ * @param {object} vars - Mustache template variables.
293
+ * @returns {Promise<string>} Rendered prompt text.
294
+ */
295
+ async render(vars) {
296
+ const [pre, ext, template] = await Promise.all([
297
+ preamble(),
298
+ this.custom({ role: this.constructor.role }),
299
+ readFile(this.constructor.template, 'utf8')
300
+ ]);
301
+ const body = Mustache.render(template, { ...vars, role: this.constructor.role });
302
+ const prefix = ext ? `${pre}\n${ext}\n` : `${pre}\n`;
303
+ return `${prefix}${body}`;
304
+ }
305
+
306
+ /**
307
+ * Load and render an arbitrary Mustache template by path.
308
+ *
309
+ * Used for cross-role rendering where one workflow needs another
310
+ * role's prompt (e.g. review rendering the action prompt for forward).
311
+ * Prepends the shared autonomous-mode preamble and any project-
312
+ * specific custom prompt from `workflows.{role}.prompt`.
313
+ *
314
+ * @param {string} path - Absolute path to a .md template file.
315
+ * @param {object} vars - Mustache template variables.
316
+ * @param {object} [opts] - Options.
317
+ * @param {string} [opts.role] - Role to resolve prompt for.
318
+ * @returns {Promise<string>} Rendered prompt text.
319
+ */
320
+ async renderFrom(path, vars, opts = {}) {
321
+ const role = opts.role ?? this.constructor.role;
322
+ const [pre, ext, template] = await Promise.all([
323
+ preamble(),
324
+ this.custom({ role }),
325
+ readFile(path, 'utf8')
326
+ ]);
327
+ const body = Mustache.render(template, vars);
328
+ const prefix = ext ? `${pre}\n${ext}\n` : `${pre}\n`;
329
+ return `${prefix}${body}`;
330
+ }
331
+
332
+ /**
333
+ * Write task context to the agent's session in storage.
334
+ *
335
+ * Called by workflows before dispatching an agent so the agent's
336
+ * MCP server can read the task context (discussion ID, PR number,
337
+ * etc.) without the agent needing to pass it as a parameter.
338
+ *
339
+ * No-op when sessionId or storage is not available (e.g. tests
340
+ * without persistent storage).
341
+ *
342
+ * @param {string} agentName - Agent identity name.
343
+ * @param {object} task - Task context object (e.g. { type: 'review_pr', pr: 42 }).
344
+ * @returns {Promise<void>}
345
+ */
346
+ async saveTask(agentName, task) {
347
+ const { storage, sessionId } = this.orchestrator;
348
+ if (!sessionId || !storage) return;
349
+
350
+ const data = await storage.load(sessionId, agentName);
351
+ if (!data) return;
352
+
353
+ data.task = task;
354
+ await storage.save(sessionId, agentName, data);
355
+ }
356
+
357
+ /**
358
+ * Render this workflow's template and send to an agent.
359
+ *
360
+ * @param {object} agent - Target agent with a send() method.
361
+ * @param {object} vars - Mustache template variables.
362
+ * @returns {Promise<void>}
363
+ */
364
+ async dispatch(agent, vars) {
365
+ const prompt = await this.render(vars);
366
+ await agent.send(prompt);
367
+ this.nudge(agent).catch(ignoreNudgeError);
368
+ }
369
+
370
+ /**
371
+ * Nudge known CLI option dialogs after prompt dispatch.
372
+ *
373
+ * Some backends surface interactive confirmation dialogs seconds
374
+ * after a prompt is submitted (for example MCP tool approvals).
375
+ * This helper polls pane output and applies backend-provided
376
+ * dialog remedies so workflows remain unattended.
377
+ *
378
+ * @param {object} agent - Target agent with backend/pane metadata.
379
+ * @returns {Promise<void>}
380
+ */
381
+ async nudge(agent) {
382
+ if (!agent?.backend || !agent?.paneId || !agent?.capture) return;
383
+
384
+ const registry = this.orchestrator?.backendRegistry;
385
+ if (!registry?.diagnose) return;
386
+
387
+ for (let i = 0; i < DISPATCH_NUDGE_CHECKS; i++) {
388
+ if (agent.state === 'dormant') return;
389
+
390
+ let pane;
391
+ try {
392
+ pane = await agent.capture(this.orchestrator?.cfg?.get?.('classify.maxLines') ?? 100);
393
+ } catch {
394
+ return;
395
+ }
396
+
397
+ const llm = await diagnose(this.orchestrator, agent, pane);
398
+ const fallback = registry.diagnose(agent.backend, pane);
399
+
400
+ let result = llm;
401
+ if (!result?.category && fallback?.category) {
402
+ result = fallback;
403
+ log.info(
404
+ `dispatch nudge fallback diagnose: ${agent.identity?.name ?? 'unknown'} ${agent.backend} `
405
+ + `${fallback.category} — ${fallback.reasoning}`
406
+ );
407
+ } else if (actionable(fallback?.category) && !actionable(result?.category)) {
408
+ result = fallback;
409
+ log.info(
410
+ `dispatch nudge fallback override: ${agent.identity?.name ?? 'unknown'} ${agent.backend} `
411
+ + `${fallback.category} over ${llm?.category ?? 'unknown'} — ${fallback.reasoning}`
412
+ );
413
+ }
414
+
415
+ if (result?.category === 'option_dialog') {
416
+ const seq = keys(result.remedy);
417
+ log.info(`dispatch nudge: ${agent.identity?.name ?? 'unknown'} ${agent.backend} option_dialog — sending ${seq.join('+')}`);
418
+ try {
419
+ const tmux = new Tmux();
420
+ await tmux.keys(agent.paneId, ...seq);
421
+ } catch {
422
+ log.debug(`dispatch nudge: keys failed for ${agent.identity?.name ?? 'unknown'}`);
423
+ return;
424
+ }
425
+ this.orchestrator.activity?.(agent.identity?.name);
426
+
427
+ if (i < DISPATCH_NUDGE_CHECKS - 1)
428
+ await sleep(DISPATCH_NUDGE_INTERVAL);
429
+ continue;
430
+ }
431
+
432
+ if (i < DISPATCH_NUDGE_CHECKS - 1)
433
+ await sleep(DISPATCH_NUDGE_INTERVAL);
434
+ }
435
+ }
436
+
437
+ // ── Proof of Life ───────────────────────────────────
438
+
439
+ /**
440
+ * Post a themed proof-of-life request on an issue/PR.
441
+ *
442
+ * Uses the orchestrator's client identity for scoping and the
443
+ * council-level themed message from the identity's theme. Falls
444
+ * back to an unscoped invisible marker when no client identity
445
+ * is available (e.g. in tests with bare stubs).
446
+ *
447
+ * @param {string} repo - Repository in "owner/name" format.
448
+ * @param {number} number - Issue or PR number.
449
+ * @param {string} agent - Name of the agent being checked.
450
+ * @returns {Promise<void>}
451
+ */
452
+ async requestProofOfLife(repo, number, agent) {
453
+ const orch = this.orchestrator.sessionId;
454
+ const payload = { agent, ts: String(Date.now()) };
455
+ if (orch) payload.orch = orch;
456
+ const marker = mark('proof-of-life', payload);
457
+ const identity = this.orchestrator.clientIdentity;
458
+ if (!identity) {
459
+ await this.hub.comment(repo, number, marker);
460
+ return;
461
+ }
462
+ const visible = identity.proofOfLife(agent);
463
+ const body = `${visible}\n\n${marker}`;
464
+ const scoped = this.hub.as(identity, 'orchestrator');
465
+ await scoped.comment(repo, number, body);
466
+ }
467
+
468
+ /**
469
+ * Post a themed alive response with an embedded machine-readable marker.
470
+ *
471
+ * @param {string} repo - Repository in "owner/name" format.
472
+ * @param {number} number - Issue or PR number.
473
+ * @param {object} identity - Agent identity for hub scoping.
474
+ * @param {string} role - Agent role for hub scoping.
475
+ * @param {{alive: boolean, status: string, details: string, outputLength?: number}} verdict - Health check result.
476
+ * @returns {Promise<void>}
477
+ */
478
+ async respondProofOfLife(repo, number, identity, role, verdict) {
479
+ const marker = mark('alive', {
480
+ agent: identity.name,
481
+ ts: String(Date.now()),
482
+ status: verdict.status
483
+ });
484
+ const visible = `**${identity.name}** is **${verdict.status}** — ${verdict.details}`;
485
+ const body = `${visible}\n\n${marker}`;
486
+ const scoped = this.hub.as(identity, role);
487
+ await scoped.comment(repo, number, body);
488
+ }
489
+
490
+ /**
491
+ * Find a pending proof-of-life request for a specific agent.
492
+ *
493
+ * When `orch` is provided, only matches requests posted by that
494
+ * orchestrator session. When omitted, matches any request — used
495
+ * by the responder which answers regardless of who asked.
496
+ *
497
+ * @param {string} repo - Repository in "owner/name" format.
498
+ * @param {number} number - Issue or PR number.
499
+ * @param {string} agent - Agent name to search for.
500
+ * @param {string} [orch] - Orchestrator session ID filter.
501
+ * @returns {Promise<{agent: string, ts: string, orch?: string}|null>} Parsed request data, or null.
502
+ */
503
+ async findProofOfLifeRequest(repo, number, agent, orch) {
504
+ const comments = await this.hub.comments(repo, number);
505
+ for (const c of comments) {
506
+ if (!has(c.body, 'proof-of-life')) continue;
507
+ const data = parse(c.body, 'proof-of-life');
508
+ if (data?.agent !== agent) continue;
509
+ if (orch && data.orch !== orch) continue;
510
+ return data;
511
+ }
512
+ return null;
513
+ }
514
+
515
+ /**
516
+ * Find an alive response for a specific agent posted after a given timestamp.
517
+ *
518
+ * @param {string} repo - Repository in "owner/name" format.
519
+ * @param {number} number - Issue or PR number.
520
+ * @param {string} agent - Agent name to search for.
521
+ * @param {number} after - Only match responses with ts greater than this epoch ms.
522
+ * @returns {Promise<{agent: string, ts: string, status: string}|null>} Parsed response, or null.
523
+ */
524
+ async findAliveResponse(repo, number, agent, after) {
525
+ const comments = await this.hub.comments(repo, number);
526
+ for (const c of comments) {
527
+ if (!has(c.body, 'alive')) continue;
528
+ const data = parse(c.body, 'alive');
529
+ if (data?.agent !== agent) continue;
530
+ if (Number(data.ts) > after) return data;
531
+ }
532
+ return null;
533
+ }
534
+
535
+ /**
536
+ * Check whether an issue/PR has any GitHub comment within the timeout window.
537
+ *
538
+ * @param {string} repo - Repository in "owner/name" format.
539
+ * @param {number} number - Issue or PR number.
540
+ * @param {number} timeout - Maximum age in ms for activity to be considered recent.
541
+ * @returns {Promise<boolean>}
542
+ */
543
+ async hasRecentActivity(repo, number, timeout) {
544
+ const comments = await this.hub.comments(repo, number);
545
+ const cutoff = Date.now() - timeout;
546
+ return comments.some(function recent(c) {
547
+ return new Date(c.created_at).getTime() > cutoff;
548
+ });
549
+ }
550
+
551
+ /**
552
+ * Evaluate an agent through the proof-of-life protocol.
553
+ *
554
+ * Unified gate for ALL eviction decisions — foreign agents, local
555
+ * idle agents, and any future workflow that needs to verify liveness
556
+ * before releasing a claim. Posts a PoL request if none exists,
557
+ * checks for alive responses, and considers the response status
558
+ * (healthy vs unhealthy) when making the verdict.
559
+ *
560
+ * Status-aware: an alive response with `status: 'unhealthy'` means
561
+ * the agent's own orchestrator confirmed it is stalled, so the gate
562
+ * returns `'release'` instead of `'active'`.
563
+ *
564
+ * @param {string} repo - Repository in "owner/name" format.
565
+ * @param {number} number - Issue or PR number.
566
+ * @param {string} agent - Agent identity name to check.
567
+ * @returns {Promise<string>} 'active'|'requested'|'pending'|'release'.
568
+ */
569
+ async check(repo, number, agent) {
570
+ const timeout = this.orchestrator.cfg?.get?.('proofOfLife.timeout') ?? 300000;
571
+ const orch = this.orchestrator.sessionId;
572
+
573
+ const ours = await this.findProofOfLifeRequest(repo, number, agent, orch);
574
+ if (ours) {
575
+ const response = await this.findAliveResponse(repo, number, agent, Number(ours.ts));
576
+ if (response) return response.status === 'unhealthy' ? 'release' : 'active';
577
+ if (Date.now() - Number(ours.ts) > timeout) return 'release';
578
+ return 'pending';
579
+ }
580
+
581
+ const any = await this.findProofOfLifeRequest(repo, number, agent);
582
+ if (any) {
583
+ const response = await this.findAliveResponse(repo, number, agent, Number(any.ts));
584
+ if (response) return response.status === 'unhealthy' ? 'release' : 'active';
585
+ if (Date.now() - Number(any.ts) > timeout) {
586
+ await this.requestProofOfLife(repo, number, agent);
587
+ return 'requested';
588
+ }
589
+ return 'pending';
590
+ }
591
+
592
+ const recent = await this.hasRecentActivity(repo, number, timeout);
593
+ if (recent) return 'active';
594
+
595
+ await this.requestProofOfLife(repo, number, agent);
596
+ return 'requested';
597
+ }
598
+
599
+ // ── Optimistic Claim ─────────────────────────────────
600
+
601
+ /**
602
+ * Optimistic claim on an issue or PR: post a marker comment, then
603
+ * re-read comments to verify this agent was first.
604
+ *
605
+ * This eliminates the TOCTOU race in the check-then-claim pattern.
606
+ * By claiming first and verifying after, two participants racing on
607
+ * the same item will both post, but only the first poster wins.
608
+ *
609
+ * @param {string} repo - Repository in "owner/name" format.
610
+ * @param {number} number - Issue or PR number.
611
+ * @param {string} type - Marker type (e.g. 'claim', 'review-claim', 'risk-claim').
612
+ * @param {object} identity - Agent identity.
613
+ * @param {string} role - Agent role for hub scoping.
614
+ * @param {string} [visible] - Optional visible text appended after the marker.
615
+ * @returns {Promise<boolean>} True if this agent was the first claimant.
616
+ */
617
+ async claimFirst(repo, number, type, identity, role, visible) {
618
+ const scoped = this.hub.as(identity, role);
619
+ const marker = mark(type, { agent: identity.name });
620
+ const body = visible ? `${marker}\n${visible}` : marker;
621
+ await scoped.comment(repo, number, body);
622
+
623
+ // Re-read all comments and find the first with this marker type.
624
+ // The first poster wins — all others back off.
625
+ const comments = await this.hub.comments(repo, number);
626
+ const first = comments.find(function isClaim(c) { return has(c.body, type); });
627
+ return parse(first?.body, type)?.agent === identity.name;
628
+ }
629
+
630
+ /**
631
+ * Optimistic claim on a discussion: post a marker comment, then
632
+ * re-read discussion comments to verify this agent was first.
633
+ *
634
+ * Identical pattern to {@link claimFirst} but uses the discussion
635
+ * comment API (node IDs, GraphQL) instead of issue/PR comments.
636
+ *
637
+ * @param {string} repo - Repository in "owner/name" format.
638
+ * @param {number} number - Discussion number (for reading).
639
+ * @param {string} discId - Discussion node ID (for posting).
640
+ * @param {string} type - Marker type (e.g. 'review-claim').
641
+ * @param {object} identity - Agent identity.
642
+ * @param {string} role - Agent role for hub scoping.
643
+ * @param {string} [visible] - Optional visible text appended after the marker.
644
+ * @returns {Promise<boolean>} True if this agent was the first claimant.
645
+ */
646
+ async claimFirstDiscussion(repo, number, discId, type, identity, role, visible) {
647
+ const scoped = this.hub.as(identity, role);
648
+ const marker = mark(type, { agent: identity.name });
649
+ const body = visible ? `${marker}\n${visible}` : marker;
650
+ await scoped.discussionComment(discId, body);
651
+
652
+ // Use the LAST claim comment to determine the winner. Discussions
653
+ // that go through revise/re-review cycles accumulate old claim
654
+ // comments from previous cycles — checking the first would always
655
+ // match the stale claim and block re-dispatch.
656
+ const full = await this.hub.discussion(repo, number);
657
+ const last = full.comments?.findLast(function isClaim(c) { return has(c.body, type); });
658
+ return parse(last?.body, type)?.agent === identity.name;
659
+ }
660
+ }