@juangcarmona/lighthouse-sdlc 0.14.0 → 0.15.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.
@@ -1,4 +1,4 @@
1
- import { refinementAfterRun } from './stage-transition.mjs';
1
+ import { refinementAfterRun, signalRefinementContinuation } from './stage-transition.mjs';
2
2
  import { dispatchId, dispatchArtifact, recordDispatch, sendDispatch, admitDispatch, checkDispatchClaim, finishDispatch, completedEvent, statusText, stages } from './stage-dispatch.mjs';
3
3
  import { readActionsJson } from './actions-artifact.mjs';
4
4
  import { deliveryAfterChecks, dispatchDeliveryChecks } from './delivery-events.mjs';
@@ -10,6 +10,8 @@ import { feedbackKey, feedbackWorker, checkProposal, reviewThread, reviewTarget,
10
10
  import { loadImplementation, loadImplementationSeed, changesDigest, validateChanges, validateCheckReceipt, createImplementationPullRequest } from './implementation.mjs';
11
11
  import { digest, validateStageEnvelope } from './host-contract.mjs';
12
12
  import { loadRefinement, loadStageReport, prepareProposal, proposalRoot, createProposalPullRequest } from './proposal.mjs';
13
+ import { eligibleIssueAuthor } from './request-triage.mjs';
14
+ import { projectImplementationPrState, reconcileImplementationPr, setPullRequestDraft } from './pr-state.mjs';
13
15
 
14
16
  export const repositoryApiUrl = (repository, path = '') => `https://api.github.com/repos/${repository}${path ? `/${path}` : ''}`;
15
17
 
@@ -18,7 +20,7 @@ export function pendingReply(event, comments) {
18
20
  if (!report || /<!-- lighthouse-proposal-review:|\[Review the implementation/.test(report.body) || !/^Outcome: blocked$/m.test(report.body) || !event.comment?.body?.trim() || event.comment.created_at <= report.created_at) return null;
19
21
  const declared = report.body.match(/<!-- lighthouse-stage:([a-z]+):blocked -->/);
20
22
  const stage = declared?.[1] ?? (/^## Proposal: blocked candidate$/m.test(report.body) ? 'propose' : /^## Refinement:/m.test(report.body) ? 'refine' : null);
21
- return ['refine', 'propose', 'implement'].includes(stage) ? { stage, report } : null;
23
+ return ['triage', 'refine', 'propose', 'implement'].includes(stage) ? { stage, report } : null;
22
24
  }
23
25
 
24
26
  export function routeIssue(event, actor, owner, eventName, comments = []) {
@@ -27,13 +29,13 @@ export function routeIssue(event, actor, owner, eventName, comments = []) {
27
29
  if (labels.includes('future') && (event.label?.name === 'implement' || event.inputs?.stage === 'implement')) return { run: false, reason: 'Implementation paused by future label' };
28
30
  if (labels.some(x => ['human-working', 'agent-working'].includes(x))) return { run: false, reason: 'Issue reserved' };
29
31
  if (eventName === 'issues' && event.action === 'unlabeled' && event.label?.name === 'future' && labels.includes('implement')) return { run: true, stage: 'implement', issue: event.issue.number, reason: 'Owner resumed implementation' };
30
- if (eventName === 'issues' && event.action === 'labeled' && ['refine', 'propose', 'implement'].includes(event.label?.name)) return { run: true, stage: event.label.name, issue: event.issue.number, reason: `Owner requested ${event.label.name}` };
32
+ if (eventName === 'issues' && event.action === 'labeled' && ['triage', 'refine', 'propose', 'implement'].includes(event.label?.name)) return { run: true, stage: event.label.name, issue: event.issue.number, reason: `Owner requested ${event.label.name}` };
31
33
  if (eventName === 'issue_comment' && event.action === 'created' && event.comment?.user?.login === owner && event.comment.user.type !== 'Bot' && labels.some(x => ['needs-info', 'waiting-review'].includes(x))) {
32
34
  const pending = pendingReply(event, comments);
33
35
  if (pending) return { run: true, stage: pending.stage, issue: event.issue.number, reason: `Owner answered pending ${pending.stage}` };
34
36
  }
35
37
  if (eventName === 'workflow_dispatch' && ['integrate', 'review'].includes(event.inputs?.stage) && (!/^[1-9][0-9]*$/.test(event.inputs?.pull_request ?? '') || !/^[a-f0-9]{40}$/.test(event.inputs?.head ?? ''))) return { run: false, reason: 'Completion needs a PR and accepted full head SHA' };
36
- if (eventName === 'workflow_dispatch' && ['refine', 'propose', 'implement', 'integrate', 'review'].includes(event.inputs?.stage ?? 'refine') && /^[1-9][0-9]*$/.test(event.inputs?.issue ?? '')) return { run: true, stage: event.inputs.stage ?? 'refine', issue: Number(event.inputs.issue), reason: 'Owner requested manual stage' };
38
+ if (eventName === 'workflow_dispatch' && ['triage', 'refine', 'propose', 'implement', 'integrate', 'review'].includes(event.inputs?.stage ?? 'refine') && /^[1-9][0-9]*$/.test(event.inputs?.issue ?? '')) return { run: true, stage: event.inputs.stage ?? 'refine', issue: Number(event.inputs.issue), reason: 'Owner requested manual stage' };
37
39
  return { run: false, reason: 'No eligible stage event' };
38
40
  }
39
41
  export function checkHandoff(request, envelope, issue, answers, revision, runId) {
@@ -135,7 +137,7 @@ async function main(command) {
135
137
  if (!route.run || route.issue !== issueNumber || !(env.LIGHTHOUSE_ENABLED_STAGES ?? stages.join(',')).split(',').includes(stage) || env.LIGHTHOUSE_STAGE && env.LIGHTHOUSE_STAGE !== stage) throw Error('Stage dispatch is not authorized');
136
138
  const issue = await api(issuePath), answers = ownerAnswers(await comments());
137
139
  const metadata = await api(''), revision = (await api(`commits/${metadata.default_branch}`)).sha;
138
- if (issue.user.login !== owner || issue.state !== 'open' && stage !== 'review' || issue.pull_request || issue.labels.some(x => x.name === 'human-working' || x.name === 'agent-working' && !queue)) throw Error('Issue not eligible for dispatch');
140
+ if (!eligibleIssueAuthor(issue, owner, stage) || issue.state !== 'open' && stage !== 'review' || issue.pull_request || issue.labels.some(x => x.name === 'human-working' || x.name === 'agent-working' && !queue)) throw Error('Issue not eligible for dispatch');
139
141
  if (handoff) {
140
142
  const prior = await readActionsJson(api, repo, route.predecessor.runId, `lighthouse-input-${issueNumber}-${route.predecessor.attempt}`, 'request.json');
141
143
  const result = await readActionsJson(api, repo, route.predecessor.runId, `lighthouse-result-${issueNumber}-${route.predecessor.attempt}`, 'envelope.json');
@@ -173,7 +175,7 @@ async function main(command) {
173
175
  if (handoff && !after) route = { run: env.GITHUB_ACTOR === owner && !reviewTarget(event, env.GITHUB_EVENT_NAME) && ['issues', 'issue_comment', 'workflow_dispatch'].includes(env.GITHUB_EVENT_NAME), stage: 'implement', issue: issueNumber };
174
176
  if (!route.run || issueNumber !== route.issue || env.GITHUB_TRIGGERING_ACTOR !== owner) throw Error('Stage not authorized');
175
177
  const issue = await api(issuePath);
176
- if (issue.user.login !== owner || issue.state !== 'open' || issue.pull_request || issue.labels.some(x => ['agent-working', 'human-working'].includes(x.name))) throw Error('Issue not eligible');
178
+ if (!eligibleIssueAuthor(issue, owner, route.stage) || issue.state !== 'open' || issue.pull_request || issue.labels.some(x => ['agent-working', 'human-working'].includes(x.name))) throw Error('Issue not eligible');
177
179
  const repository = await api('');
178
180
  const baseRevision = (await api(`commits/${repository.default_branch}`)).sha;
179
181
  const revision = { sha: implementationReview ? implementationReview.head : baseRevision };
@@ -204,7 +206,16 @@ async function main(command) {
204
206
  }
205
207
  const stage = route.stage;
206
208
  await output('artifact_prefix', stage === 'implement' ? 'implement-' : '');
207
- let predecessor, implementation, seed, planningReference;
209
+ let predecessor, implementation, seed, planningReference, triage;
210
+ if (stage === 'refine' || issue.user.login !== owner && !proposalReview && !implementationReview && ['propose', 'implement'].includes(stage)) {
211
+ const report = [...all].reverse().find(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body.includes('<!-- lighthouse-stage:triage:passed -->'));
212
+ if (!report && issue.user.login !== owner) throw Error('External request requires authenticated passed triage before later work');
213
+ if (report) {
214
+ triage = await loadStageReport(api, repo, report, { owner, issue: issueNumber });
215
+ const errors = validateStageEnvelope(triage.envelope, triage.request);
216
+ if (errors.length || triage.request.stage !== 'triage' || triage.envelope.result.status !== 'passed' || triage.request.sourceRevision !== revision.sha || triage.request.originalRequest !== (issue.body ?? '')) throw Error('Triage evidence is invalid or stale; request triage again');
217
+ }
218
+ }
208
219
  if (stage === 'implement') {
209
220
  if (handoff) {
210
221
  const prior = JSON.parse(await readFile(join(env.RUNNER_TEMP, 'refinement-handoff', 'request.json'), 'utf8'));
@@ -213,6 +224,7 @@ async function main(command) {
213
224
  checkHandoff(prior, result, issue, answers, revision.sha, dispatch?.request.predecessor?.runId ?? dispatch?.request.routerRunId ?? env.GITHUB_RUN_ID);
214
225
  }
215
226
  if (issue.labels.some(x => x.name === 'future')) throw Error('Implementation paused by future label');
227
+ if (issue.user.login !== owner && !handoff && !implementationReview) await loadRefinement(api, repo, all, { owner, issue: issueNumber, sourceRevision: revision.sha, taskDigest: taskDigest(issue, answers) });
216
228
  implementation = await loadImplementation(api, issueNumber, owner, baseRevision, repository.default_branch);
217
229
  seed = implementationReview ? null : await loadImplementationSeed(api, implementation.policy, revision.sha);
218
230
  const pulls = await api('pulls?state=open&per_page=100');
@@ -237,12 +249,16 @@ async function main(command) {
237
249
  const id = digest(`${repo}:${issueNumber}:${stage}:${env.GITHUB_RUN_ID}:${env.GITHUB_RUN_ATTEMPT}`);
238
250
  await mkdir(dir, { recursive: true });
239
251
  const snapshot = { autoImplement: env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true', ...(dispatch ? { workflowRevision: env.GITHUB_SHA, dispatch: { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest } } : {}), ...(planningReference ? { planningReference } : {}), ...(implementationReview ? { implementationReview, baseRevision } : {}), ...(proposalReview ? { proposalReview } : {}), runId: id, issue: issueNumber, stage, title: issue.title, defaultBranch: repository.default_branch, sourceRevision: revision.sha, taskDigest: taskDigest(issue, answers), workflowRunId: env.GITHUB_RUN_ID, ...(predecessor ? { refinement: predecessor } : {}), ...(continuation ? { replyTo: continuation.request.runId, commentId: event.comment.id } : {}), ...(implementation ? { implementation } : {}), ...(seed ? { seedDigest: changesDigest(seed) } : {}) };
252
+ if (stage === 'triage') snapshot.originalRequest = issue.body ?? '';
253
+ if (triage) snapshot.triageEstimate = triage.envelope.result.output.estimate;
240
254
  await output('source_revision', revision.sha);
241
255
  await writeFile(join(dir, 'request.json'), JSON.stringify(snapshot));
242
256
  if (seed) await writeFile(join(dir, 'seed.json'), JSON.stringify(seed));
243
257
  await writeFile(join(dir, 'issue.json'), JSON.stringify({ ...issue, ownerAnswers: answers.map(c => ({ body: c.body, url: c.html_url, author: c.user.login })) }));
244
258
  const contract = fileURLToPath(new URL('./host-contract.mjs', import.meta.url));
245
259
  const context = JSON.parse(execFileSync(process.execPath, [contract, 'context', join(dir, 'request.json'), join(dir, 'issue.json')], { encoding: 'utf8' }));
260
+ if (triage) context.triage = triage.envelope.result.output;
261
+ if (stage === 'triage') context.contract.rules += ' Preserve the exact issue body in output.requestAnalysis.originalRequest. Return output.estimate and output.requestAnalysis following /harness/request-triage.md. Stop at triaged; never authorize or dispatch refinement.';
246
262
  if (predecessor || proposalReview) {
247
263
  if (predecessor) context.predecessor = predecessor;
248
264
  if (proposalReview) {
@@ -273,7 +289,12 @@ async function main(command) {
273
289
  await writeFile(join(dir, 'context.json'), JSON.stringify(context));
274
290
  await api(`${issuePath}/labels`, 'POST', { labels: ['agent-working'] });
275
291
  await writeFile(join(dir, 'reserved'), id);
276
- for (const label of stage === 'refine' ? ['needs-info', 'ready', 'waiting-review'] : ['needs-info', 'waiting-review']) if (issue.labels.some(x => x.name === label)) await api(`${issuePath}/labels/${label}`, 'DELETE');
292
+ for (const label of ['needs-info', 'waiting-review', ...(stage === 'refine' || stage === 'implement' ? ['ready'] : [])]) if (issue.labels.some(x => x.name === label)) await api(`${issuePath}/labels/${label}`, 'DELETE');
293
+ if (stage === 'implement' && implementationReview) {
294
+ const pr = await api(`pulls/${implementationReview.number}`);
295
+ if (!pr.draft) await setPullRequestDraft(env.GH_TOKEN, pr, true);
296
+ await projectImplementationPrState(api, issueNumber, true);
297
+ }
277
298
  await summary(`### ${stage} #${issueNumber}\nSource: \`${revision.sha}\`\n\nOwner request verified; reservation acquired.\n`);
278
299
  if (continuation) {
279
300
  const marker = `<!-- lighthouse-reply:${event.comment.id} -->`;
@@ -290,6 +311,13 @@ async function main(command) {
290
311
  }
291
312
  if (command === 'cleanup') { try { await readFile(join(dir, 'reserved')); } catch { return; } }
292
313
  const snapshot = JSON.parse(await readFile(join(dir, 'request.json'), 'utf8'));
314
+ if (command === 'continue-refinement') {
315
+ await readFile(join(dir, 'published'));
316
+ const envelope = JSON.parse(await readFile(join(dir, 'envelope.json'), 'utf8'));
317
+ const sent = await signalRefinementContinuation(api, snapshot, envelope);
318
+ await summary(sent ? 'Implementation continuation requested; the router will verify the completed refinement.' : 'No implementation continuation requested.');
319
+ return;
320
+ }
293
321
  if (snapshot.dispatch) env.LIGHTHOUSE_AUTO_IMPLEMENT = String(snapshot.autoImplement === true);
294
322
  if (command === 'validate') {
295
323
  const value = JSON.parse(await readFile(join(dir, 'envelope.json'), 'utf8'));
@@ -315,7 +343,7 @@ async function main(command) {
315
343
  const marker = `<!-- lighthouse-actions:${snapshot.runId} -->`;
316
344
  const existing = (await comments()).find(c => c.user.type === 'Bot' && c.body.includes(marker));
317
345
  const runUrl = `https://github.com/${repo}/actions/runs/${env.GITHUB_RUN_ID}`;
318
- let proposalLink = '';
346
+ let proposalLink = '', prStateProof = '';
319
347
  if (snapshot.stage === 'propose' && value.result.status === 'passed') {
320
348
  const files = await prepareProposal(issueNumber, value.result.output.files);
321
349
  const recheck = async () => {
@@ -352,24 +380,27 @@ async function main(command) {
352
380
  }
353
381
  if (pulls.length >= 100 || pulls.some(pr => pr.number !== snapshot.implementationReview?.number && pr.head.ref.startsWith(`lighthouse/implement-${issueNumber}-`))) throw Error('Existing implementation PR requires review');
354
382
  };
355
- const draft = value.result.status !== 'passed' || !checksPassed;
383
+ const candidateComplete = value.result.status === 'passed' && checksPassed;
356
384
  const checks = receipt.checks.map(c => `- \`${c.command.join(' ')}\`: exit ${c.exitCode}`).join('\n');
357
- const pr = await createImplementationPullRequest(api, snapshot, changes, `${value.effect.body}\n\nHost checks:\n${checks}`, runUrl, draft, recheck);
385
+ const pr = await createImplementationPullRequest(api, snapshot, changes, value.effect.body, runUrl, true, recheck);
386
+ prStateProof = `<!-- lighthouse-pr-state:${JSON.stringify({ pr: pr.number, head: pr.head.sha, complete: candidateComplete, run: Number(env.GITHUB_RUN_ID) })} -->`;
387
+ await writeFile(join(dir, 'published-pr.json'), JSON.stringify({ number: pr.number, head: pr.head.sha }));
358
388
  try { await dispatchDeliveryChecks(api, snapshot.baseRevision ?? snapshot.sourceRevision, pr); }
359
389
  catch (error) { value.effect.body += '\n\nThe PR was saved, but CI could not start. See the run for details.'; await summary(`CI dispatch failed: ${error.message}`); }
360
390
  if (snapshot.implementationReview) await api(`issues/${pr.number}/comments`, 'POST', { body: `Updated the PR with your feedback. [Checks and evidence](${runUrl}).\n\n${snapshot.implementationReview.receipt}` });
361
- proposalLink = `\n\n[Review the implementation${draft ? ' draft' : ''}](${pr.html_url}). Human acceptance and merge remain separate decisions.`;
391
+ proposalLink = `\n\n[Implementation PR #${pr.number}](${pr.html_url}) is a draft. ${candidateComplete ? 'Required CI will decide when it becomes ready for review.' : 'Address the blocker in the run before review.'}`;
362
392
  await summary(`### Implementation PR\n[Review #${pr.number}](${pr.html_url})\n\nHost checks:\n${checks}`);
363
393
  }
364
394
  }
365
395
  if (snapshot.proposalReview && value.result.status === 'blocked') await api(`issues/${snapshot.proposalReview.number}/comments`, 'POST', { body: `Proposal revision is blocked. ${value.effect.body}\n\n[Execution](${runUrl}). Implementation has not started.` });
366
- const continuationHint = snapshot.proposalReview ? `\n\nContinue the review in [PR #${snapshot.proposalReview.number}](${snapshot.proposalReview.url}).\n<!-- lighthouse-proposal-review:${snapshot.proposalReview.number} -->` : value.result.status === 'blocked' && ['refine', 'propose', 'implement'].includes(snapshot.stage) && !proposalLink ? `\n\nReply here to continue.` : '';
396
+ const continuationHint = snapshot.proposalReview ? `\n\nContinue the review in [PR #${snapshot.proposalReview.number}](${snapshot.proposalReview.url}).\n<!-- lighthouse-proposal-review:${snapshot.proposalReview.number} -->` : value.result.status === 'blocked' && ['triage', 'refine', 'propose', 'implement'].includes(snapshot.stage) && !proposalLink ? `\n\nThe owner can reply here to continue ${snapshot.stage}.` : '';
367
397
  const next = snapshot.stage === 'refine' && value.result.status === 'passed' && env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true' ? publicationIssue.labels.some(x => x.name === 'future') ? '\n\n⏸️ Implementation is paused by `future`.' : '\n\nRefinement complete. Implementation is next; use `future` to pause it.' : '';
368
- const body = `## ${statusText(snapshot.stage, value.result.status === 'blocked' ? 'waiting' : 'completed')}\n\n${snapshot.stage === 'propose' && value.result.status === 'passed' ? 'Proposal ready. Review it in the linked PR; comment there to request changes.' : value.effect.body}${proposalLink}${continuationHint}${next}\n\n[Follow progress](${runUrl})\n\n<details><summary>Execution details</summary>\n\nOutcome: ${value.result.status}\n\n[Workflow run](${runUrl}) · source \`${snapshot.sourceRevision}\`\n\n${marker}\n<!-- lighthouse-stage:${snapshot.stage}:${value.result.status} -->\n</details>`;
398
+ const body = `## ${statusText(snapshot.stage, value.result.status === 'blocked' ? 'waiting' : 'completed')}\n\n${snapshot.stage === 'propose' && value.result.status === 'passed' ? 'Proposal ready. Review it in the linked PR; comment there to request changes.' : value.effect.body}${proposalLink}${continuationHint}${next}\n\n[Follow progress](${runUrl})\n\n<details><summary>Execution details</summary>\n\nOutcome: ${value.result.status}\n\n[Workflow run](${runUrl}) · source \`${snapshot.sourceRevision}\`\n\n${marker}\n${prStateProof}\n<!-- lighthouse-stage:${snapshot.stage}:${value.result.status} -->\n</details>`;
369
399
  const report = existing ?? await api(`${issuePath}/comments`, 'POST', { body });
370
400
  const handoff = snapshot.stage === 'refine' && value.result.status === 'passed' && env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true';
371
- const label = ['propose', 'implement'].includes(snapshot.stage) ? 'waiting-review' : value.result.status === 'passed' ? 'ready' : 'needs-info';
372
- await api(`${issuePath}/labels`, 'POST', { labels: [label] });
401
+ const label = snapshot.stage === 'implement' && prStateProof ? 'pr-draft' : ['propose', 'implement'].includes(snapshot.stage) ? 'waiting-review' : value.result.status === 'passed' ? snapshot.stage === 'triage' ? 'triaged' : 'ready' : 'needs-info';
402
+ if (label === 'pr-draft') await projectImplementationPrState(api, issueNumber, true);
403
+ else await api(`${issuePath}/labels`, 'POST', { labels: [label] });
373
404
  const fresh = await api(issuePath);
374
405
  if (fresh.labels.some(x => x.name === snapshot.stage)) await api(`${issuePath}/labels/${snapshot.stage}`, 'DELETE');
375
406
  if (handoff) {
@@ -377,19 +408,32 @@ async function main(command) {
377
408
  await output('implement', String(!fresh.labels.some(x => x.name === 'future')));
378
409
  }
379
410
  await writeFile(join(dir, 'published'), report.html_url);
380
- await summary(`### Published\n[Issue #${issueNumber}](${report.html_url}) · **${label}**\n\n${['propose', 'implement'].includes(snapshot.stage) ? 'Review the PR or blocker report; no next stage is authorized.' : label === 'needs-info' ? 'Reply to the question here; an owner reply resumes refinement.' : handoff ? 'Refinement complete. Implementation follows automatically unless paused with future.' : 'Refinement complete. Implementation is available when the repository enables the implement worker.'}`);
411
+ await summary(`### Published\n[Issue #${issueNumber}](${report.html_url}) · **${label}**\n\n${['propose', 'implement'].includes(snapshot.stage) ? 'Review the PR or blocker report; no next stage is authorized.' : label === 'needs-info' ? `The owner can answer the question to resume ${snapshot.stage}.` : snapshot.stage === 'triage' ? 'Triage complete. The owner decides whether to request refinement; no next stage has started.' : handoff ? 'Refinement complete. Implementation follows automatically unless paused with future.' : 'Refinement complete. Implementation is available when the repository enables the implement worker.'}`);
381
412
  console.log(`Published ${label}: ${report.html_url}`); return;
382
413
  }
383
414
  if (command === 'cleanup') {
384
415
  try { await readFile(join(dir, 'reserved')); } catch { return; }
385
416
  const issue = await api(issuePath);
386
417
  if (issue.labels.some(x => x.name === 'agent-working')) await api(`${issuePath}/labels/agent-working`, 'DELETE');
387
- try { await readFile(join(dir, 'published')); }
418
+ let published = false;
419
+ try { await readFile(join(dir, 'published')); published = true; }
388
420
  catch {
389
421
  if (snapshot.proposalReview) await api(`issues/${snapshot.proposalReview.number}/comments`, 'POST', { body: `Proposal update did not complete. Your feedback is retained. [See execution](https://github.com/${repo}/actions/runs/${env.GITHUB_RUN_ID}).` });
390
422
  if (snapshot.replyTo) await api(`${issuePath}/labels`, 'POST', { labels: [snapshot.stage === 'refine' ? 'needs-info' : 'waiting-review'] });
391
423
  await summary('### Execution incomplete\nNo completed publication. Inspect the failed step; the pending decision remains unfulfilled.');
392
424
  }
425
+ if (published && snapshot.stage === 'implement') {
426
+ try {
427
+ const saved = JSON.parse(await readFile(join(dir, 'published-pr.json'), 'utf8'));
428
+ const pr = await api(`pulls/${saved.number}`);
429
+ if (pr.state === 'open' && pr.head.sha === saved.head) {
430
+ const profileFile = await api(`contents/.lighthouse/github.json?ref=${snapshot.defaultBranch}`);
431
+ const profile = JSON.parse(Buffer.from(profileFile.content, 'base64').toString());
432
+ const required = profile.requiredChecks.map(check => ({ context: check.context, app: check.integrationId }));
433
+ await reconcileImplementationPr(api, repo, pr, await api(issuePath), await comments(), required, (current, draft) => setPullRequestDraft(env.GH_TOKEN, current, draft), { auto: true });
434
+ }
435
+ } catch (error) { await summary(`PR state will be retried after CI: ${error.message}.`); }
436
+ }
393
437
  console.log('Execution reservation released'); return;
394
438
  }
395
439
  throw Error('Unknown issue workflow command');
@@ -0,0 +1,58 @@
1
+ // Selection is configuration, never authority or proof of provider access.
2
+ export const MODEL_PROFILES = Object.freeze(['plan', 'build', 'fast']);
3
+ export const REASONING_EFFORTS = Object.freeze(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
4
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ const own = (value, key) => Object.hasOwn(value ?? {}, key);
6
+ function keys(value, allowed, label) {
7
+ if (!record(value) || Object.keys(value).some(key => !allowed.includes(key))) throw Error(`Invalid ${label}`);
8
+ }
9
+ function validateSettings(value, operator = false) {
10
+ keys(value, ['schemaVersion', 'profiles', 'workers', 'defaultProfile', ...(operator ? ['enabled'] : [])], 'model profile configuration');
11
+ if (value.schemaVersion !== 1) throw Error('Model profiles require schemaVersion 1');
12
+ if (operator && typeof value.enabled !== 'boolean') throw Error('Operator model profiles require explicit enabled');
13
+ if (own(value, 'defaultProfile') && !MODEL_PROFILES.includes(value.defaultProfile)) throw Error('Invalid default model profile');
14
+ if (own(value, 'profiles')) {
15
+ keys(value.profiles, MODEL_PROFILES, 'profile names');
16
+ for (const profile of Object.values(value.profiles)) {
17
+ keys(profile, ['model', 'reasoningEffort'], 'model profile settings');
18
+ if (own(profile, 'model') && !/^openai\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(profile.model)) throw Error('Profile model must be an OpenAI provider/model identifier without a variant');
19
+ if (own(profile, 'reasoningEffort') && !REASONING_EFFORTS.includes(profile.reasoningEffort)) throw Error('Invalid reasoning effort');
20
+ }
21
+ }
22
+ if (own(value, 'workers')) {
23
+ if (!record(value.workers) || Object.entries(value.workers).some(([worker, profile]) => !/^[a-z][a-z0-9-]{0,63}$/.test(worker) || !MODEL_PROFILES.includes(profile))) throw Error('Invalid worker profile assignments');
24
+ }
25
+ return value;
26
+ }
27
+
28
+ export function resolveModelProfile({ project = null, operator, worker, profile } = {}) {
29
+ if (!record(operator)) throw Error('Operator model configuration required');
30
+ if (project !== null) validateSettings(project);
31
+ const local = operator.modelProfiles;
32
+ if (local !== undefined) validateSettings(local, true);
33
+ if (!local?.enabled) {
34
+ if (profile !== undefined) throw Error('Delegated profiles require operator opt-in');
35
+ if (typeof operator.model !== 'string' || !operator.model.trim() || /[\0\r\n]/.test(operator.model)) throw Error('Legacy operator model required');
36
+ return { mode: 'legacy', profile: null, model: operator.model, reasoningEffort: null, engineModel: operator.model, source: 'operator.model', capabilityEvidence: 'not-checked-legacy' };
37
+ }
38
+ if (profile !== undefined && !MODEL_PROFILES.includes(profile)) throw Error('Invalid requested model profile');
39
+ const choices = [[profile, 'task.profile'], [own(local.workers, worker) ? local.workers[worker] : undefined, 'operator.workers'], [own(project?.workers, worker) ? project.workers[worker] : undefined, 'project.workers'], [local.defaultProfile, 'operator.defaultProfile'], [project?.defaultProfile, 'project.defaultProfile']];
40
+ const choice = choices.find(([name]) => name !== undefined);
41
+ if (!choice) throw Error('No model profile assigned to worker');
42
+ const [name, source] = choice;
43
+ const base = project?.profiles?.[name] ?? {}, override = local.profiles?.[name] ?? {};
44
+ const selected = { ...base, ...override };
45
+ if (!selected.model || !selected.reasoningEffort) throw Error(`Incomplete model profile: ${name}`);
46
+ const capability = operator.modelCapabilities?.[selected.model];
47
+ if (!record(capability) || capability.available !== true || capability.tools !== true || !Array.isArray(capability.reasoningEfforts) || !capability.reasoningEfforts.includes(selected.reasoningEffort)) throw Error(`Model or reasoning unavailable in operator capabilities: ${name}`);
48
+ return { mode: 'profile', profile: name, ...selected, engineModel: `${selected.model}#${selected.reasoningEffort}`, source, fields: { model: own(override, 'model') ? 'operator' : 'project', reasoningEffort: own(override, 'reasoningEffort') ? 'operator' : 'project' }, capabilityEvidence: 'operator-configuration-only' };
49
+ }
50
+
51
+ export function modelExecutionReceipt(selection) {
52
+ return {
53
+ requested: { profile: selection.profile, model: selection.model, reasoningEffort: selection.reasoningEffort },
54
+ resolved: selection,
55
+ providerReported: { model: null, reasoningEffort: null, status: 'unverified', reason: 'The current adapter has no authenticated provider-settings receipt' },
56
+ billing: { route: 'unchanged', actualCost: null, estimatedApiEquivalentCost: null, subscriptionUsage: null },
57
+ };
58
+ }
@@ -9,6 +9,7 @@ import { prepareImplementationWorkspace, copyEvidence, runImplementationChecks }
9
9
  import { validateStageEnvelope } from './host-contract.mjs';
10
10
  import { formatOpenCodeEvent, progressText, boundedEventLines } from './opencode-progress.mjs';
11
11
  import { selectEnvironment, startEnvironment, environmentArgs, environmentReceipt, redactEnvironmentLog } from './execution-environment.mjs';
12
+ import { resolveModelProfile, modelExecutionReceipt } from './model-profiles.mjs';
12
13
 
13
14
  export function completionOutput(envelope, request) {
14
15
  const errors = validateStageEnvelope(envelope, request);
@@ -52,6 +53,16 @@ async function main() {
52
53
  const request = JSON.parse(await readFile(join(runDir, 'request.json'), 'utf8'));
53
54
  // Sequential workers share the runner temp directory; only this stage's outputs may survive.
54
55
  for (const file of ['envelope.json', 'runtime.json', 'changes.json', 'checks.json']) await rm(join(runDir, file), { force: true });
56
+ let projectModels = null;
57
+ try {
58
+ const directory = join(env.GITHUB_WORKSPACE, '.lighthouse'), directoryStat = await lstat(directory);
59
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw Error('Invalid project model configuration directory');
60
+ const path = join(directory, 'models.json'), stat = await lstat(path);
61
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65536) throw Error('Invalid project model configuration file');
62
+ projectModels = JSON.parse(await readFile(path, 'utf8'));
63
+ }
64
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
65
+ const modelSelection = resolveModelProfile({ project: projectModels, operator: config, worker: env.LIGHTHOUSE_WORKER ?? request.stage });
55
66
  const selected = request.stage === 'implement' ? selectEnvironment(config, request.implementation?.environment) : null;
56
67
  if (request.stage === 'implement') {
57
68
  if (selected) config.implementationImage = selected.image;
@@ -91,24 +102,25 @@ async function main() {
91
102
  const context = join(dir, 'context.json');
92
103
  await copyFile(join(runDir, 'context.json'), context); await chmod(context, 0o444);
93
104
  const prompt = join(dir, 'prompt.txt');
94
- const completionBoundary = '\n\n# Completion in this custom engine adapter\nThe trusted host, outside this container, records the gh-aw terminal noop after validating /output/envelope.json. The safeoutputs CLI and /tmp/gh-aw/agent instructions describe host duties in this integration. Their absence inside the engine is expected, not an unavailable product check. Write the envelope and stop; do not install or invoke safeoutputs.\n';
105
+ const completionBoundary = '\n\n# Completion in this custom engine adapter\nThe trusted host, outside this container, records the gh-aw terminal noop after validating /output/envelope.json. The safeoutputs CLI and /tmp/gh-aw/agent instructions describe host duties in this integration. Their absence inside the engine is expected, not an unavailable product check. Read /harness/skills/concise-results/SKILL.md when writing effect.body. Write the envelope and stop; do not install or invoke safeoutputs.\n';
95
106
  await writeFile(prompt, await readFile(env.GH_AW_PROMPT, 'utf8') + completionBoundary, { mode: 0o444 });
96
107
  const harness = resolve(dirname(fileURLToPath(import.meta.url)), '..');
97
108
  const pkg = JSON.parse(await readFile(join(harness, 'package.json'), 'utf8'));
98
- await writeFile(join(runDir, 'runtime.json'), JSON.stringify({ package: { name: pkg.name, version: pkg.version }, harnessRevision: execFileSync('git', ['-C', harness, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), image: config.image, environment: environmentReceipt(selected), model: config.model, engine: 'OpenCode 2.1.0', isolation: 'Docker; no AWF egress filtering', skills: await readdir(join(harness, 'skills')), sourceRevision: request.sourceRevision, seed: request.implementation?.policy.seed ?? null, seedDigest: request.seedDigest ?? null }));
109
+ await writeFile(join(runDir, 'runtime.json'), JSON.stringify({ package: { name: pkg.name, version: pkg.version }, harnessRevision: execFileSync('git', ['-C', harness, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), image: config.image, environment: environmentReceipt(selected), model: modelSelection.model, modelExecution: modelExecutionReceipt(modelSelection), engine: 'OpenCode 2.1.0', isolation: 'Docker; no AWF egress filtering', skills: await readdir(join(harness, 'skills')), sourceRevision: request.sourceRevision, seed: request.implementation?.policy.seed ?? null, seedDigest: request.seedDigest ?? null }));
99
110
  const args = ['run', '--rm', '--init', '--name', name, '--user', `${config.uid}:${config.uid}`, '--cpus', '2', '--memory', implementing ? '4g' : '2g', '--pids-limit', implementing ? '256' : '128', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--read-only',
100
111
  '--tmpfs', '/tmp:rw,exec,size=1g,mode=1777', '--tmpfs', `/home/engine/.cache:rw,size=1g,uid=${config.uid},gid=${config.uid}`, '--tmpfs', `/home/engine/.config:rw,size=16m,uid=${config.uid},gid=${config.uid}`, '--tmpfs', `/home/engine/.local/state:rw,size=64m,uid=${config.uid},gid=${config.uid}`,
101
112
  '--mount', `type=bind,src=${source},dst=/workspace${implementing ? '' : ',readonly'}`, '--mount', `type=bind,src=${output},dst=/output`, '--mount', `type=bind,src=${context},dst=/input/context.json,readonly`, '--mount', `type=bind,src=${prompt},dst=/input/prompt.txt,readonly`, '--mount', `type=bind,src=${harness},dst=/harness,readonly`,
102
- '--mount', `type=volume,src=${config.authVolume},dst=/home/engine/.local/share/opencode`, '--env', `ENGINE_SESSION=ses_${request.runId}`, '--env', `ENGINE_MODEL=${config.model}`, '--env', `ENGINE_STAGE=${request.stage}`, '--env', 'NPM_CONFIG_CACHE=/tmp/npm-cache', '--env', 'PLAYWRIGHT_BROWSERS_PATH=/workspace/.cache/ms-playwright', ...environmentArgs(runtime), config.image];
113
+ '--mount', `type=volume,src=${config.authVolume},dst=/home/engine/.local/share/opencode`, '--env', `ENGINE_SESSION=ses_${request.runId}`, '--env', `ENGINE_MODEL=${modelSelection.engineModel}`, '--env', `ENGINE_STAGE=${request.stage}`, '--env', 'NPM_CONFIG_CACHE=/tmp/npm-cache', '--env', 'PLAYWRIGHT_BROWSERS_PATH=/workspace/.cache/ms-playwright', ...environmentArgs(runtime), config.image];
103
114
  const eventsPath = join(env.RUNNER_TEMP, 'gh-aw', `opencode-events-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}.jsonl`);
104
115
  await mkdir(dirname(eventsPath), { recursive: true });
105
116
  await writeFile(eventsPath, '', { mode: 0o600 });
106
117
  const started = Date.now();
107
- let lastVisible = started;
118
+ let lastVisible = started, engineError = false;
108
119
  const redact = text => redactLog(redactEnvironmentLog(runtime, text));
109
120
  const report = text => { lastVisible = Date.now(); console.log(redact(text)); };
110
121
  const eventLine = line => {
111
122
  const redacted = redact(line);
123
+ try { if (JSON.parse(redacted).type === 'error') engineError = true; } catch {}
112
124
  appendFileSync(eventsPath, redacted + '\n');
113
125
  const visible = formatOpenCodeEvent(redacted);
114
126
  if (visible) report(visible);
@@ -137,6 +149,7 @@ async function main() {
137
149
  child.on('error', no); child.on('close', code => code === 0 && !cancelled ? ok() : no(Error('OpenCode failed or was cancelled')));
138
150
  timer = setTimeout(cancel, implementing ? 1800000 : 600000);
139
151
  });
152
+ if (engineError) throw Error('OpenCode reported an execution error; no model fallback attempted');
140
153
  const result = join(output, 'envelope.json'), stat = await lstat(result);
141
154
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128000) throw Error('Invalid engine result file');
142
155
  const envelope = JSON.parse(await readFile(result, 'utf8'));
@@ -0,0 +1,106 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { checkImplementationPullRequest } from './proposal-review.mjs';
3
+ import { authorizedCiCallback, projectImplementationPrState, reconcileImplementationPr, scheduledImplementationPulls, setPullRequestDraft } from './pr-state.mjs';
4
+
5
+ const apiFor = (repo, token) => async (path, method = 'GET', body, optional = false) => {
6
+ const response = await fetch(`https://api.github.com/repos/${repo}${path ? `/${path}` : ''}`, {
7
+ method, headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', ...(body ? { 'Content-Type': 'application/json' } : {}) },
8
+ ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(30000),
9
+ });
10
+ if (optional && response.status === 404) return null;
11
+ if (!response.ok) throw Error(`GitHub ${method} ${path}: ${response.status}`);
12
+ return response.status === 204 ? null : response.json();
13
+ };
14
+ const commentsFor = async (api, issue) => {
15
+ const comments = [];
16
+ for (let page = 1; page <= 10; page++) {
17
+ const batch = await api(`issues/${issue}/comments?per_page=100&page=${page}`);
18
+ comments.push(...batch);
19
+ if (batch.length < 100) return comments;
20
+ }
21
+ throw Error('Issue comment history exceeds bound');
22
+ };
23
+ const requiredChecks = profile => {
24
+ const checks = profile.requiredChecks;
25
+ if (!Array.isArray(checks) || !checks.length || checks.length > 20 || checks.some(item => !item.context || item.integrationId !== null && !Number.isSafeInteger(item.integrationId))) throw Error('Invalid required-check profile');
26
+ return checks.map(item => ({ context: item.context, app: item.integrationId }));
27
+ };
28
+ async function main() {
29
+ const env = process.env, repo = env.GITHUB_REPOSITORY, owner = env.GITHUB_REPOSITORY_OWNER;
30
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo ?? '') || !env.GH_TOKEN) throw Error('Missing repository or token');
31
+ const api = apiFor(repo, env.GH_TOKEN);
32
+ const event = JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8'));
33
+ const metadata = await api('');
34
+ const config = await api(`contents/.lighthouse/github.json?ref=${metadata.default_branch}`, 'GET', undefined, true);
35
+ if (!config) return;
36
+ const profile = JSON.parse(Buffer.from(config.content, 'base64').toString());
37
+ if (profile.repository !== repo) throw Error('Repository profile differs from the event');
38
+ let candidates = [];
39
+ const ciCallback = env.GITHUB_EVENT_NAME === 'workflow_dispatch' && env.LIGHTHOUSE_CI_RECONCILE === 'true';
40
+ if (env.GITHUB_EVENT_NAME === 'pull_request') {
41
+ if (env.GITHUB_ACTOR !== owner || !['ready_for_review', 'converted_to_draft', 'closed', 'reopened'].includes(event.action)) return;
42
+ candidates = [event.pull_request?.number];
43
+ } else if (env.GITHUB_EVENT_NAME === 'workflow_run') {
44
+ const id = event.workflow_run?.id;
45
+ if (event.action !== 'completed' || !Number.isSafeInteger(id)) return;
46
+ const run = await api(`actions/runs/${id}`);
47
+ if (run.status !== 'completed' || run.conclusion !== 'success' || run.head_sha !== event.workflow_run.head_sha || run.repository?.full_name !== repo || !profile.completion?.postMergeWorkflows?.some(name => run.path === `.github/workflows/${name}`)) return;
48
+ const pulls = await api(`commits/${run.head_sha}/pulls?per_page=100`);
49
+ if (pulls.length >= 100) throw Error('PR lookup exceeds bound');
50
+ candidates = pulls.filter(pr => pr.head?.sha === run.head_sha).map(pr => pr.number);
51
+ } else if (env.GITHUB_EVENT_NAME === 'schedule') {
52
+ candidates = await scheduledImplementationPulls(api);
53
+ } else if (ciCallback) {
54
+ const id = Number(env.GITHUB_RUN_ID);
55
+ if (!Number.isSafeInteger(id) || !/^[a-f0-9]{40}$/.test(env.GITHUB_SHA ?? '')) throw Error('CI callback lacks a run or head');
56
+ const run = await api(`actions/runs/${id}`);
57
+ if (!authorizedCiCallback(run, { id, sha: env.GITHUB_SHA, repo, workflows: profile.completion?.postMergeWorkflows })) return;
58
+ const pulls = await api(`commits/${run.head_sha}/pulls?per_page=100`);
59
+ if (pulls.length >= 100) throw Error('PR lookup exceeds bound');
60
+ candidates = pulls.filter(pr => pr.head?.sha === run.head_sha).map(pr => pr.number);
61
+ } else return;
62
+ for (const number of candidates) {
63
+ if (!Number.isSafeInteger(number)) continue;
64
+ const pr = await api(`pulls/${number}`);
65
+ if (env.GITHUB_EVENT_NAME === 'pull_request' && pr.head.sha !== event.pull_request.head.sha) continue;
66
+ if (env.GITHUB_EVENT_NAME === 'workflow_run' && pr.head.sha !== event.workflow_run.head_sha) continue;
67
+ if (ciCallback && pr.head.sha !== env.GITHUB_SHA) continue;
68
+ if (pr.base.ref !== metadata.default_branch) continue;
69
+ let target;
70
+ try { target = checkImplementationPullRequest({ ...pr, state: 'open', merged: false }, repo); }
71
+ catch {
72
+ const match = pr.head.ref.match(/^lighthouse\/implement-([1-9][0-9]*)-[a-f0-9]{12}$/);
73
+ if (!match || pr.user?.login !== 'github-actions[bot]' || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo || pr.state !== 'open') continue;
74
+ const issue = await api(`issues/${match[1]}`);
75
+ if (issue.state !== 'open') continue;
76
+ if (!pr.draft) await setPullRequestDraft(env.GH_TOKEN, pr, true);
77
+ await projectImplementationPrState(api, issue.number, true);
78
+ const notice = `<!-- lighthouse-pr-link-changed:${pr.number}:${pr.head.sha} -->`;
79
+ if (!(await commentsFor(api, issue.number)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(notice))) await api(`issues/${issue.number}/comments`, 'POST', { body: `PR #${pr.number} is back in draft because its Lighthouse link changed. Restore the PR description before review. [Open PR](${pr.html_url}).\n\n${notice}` });
80
+ continue;
81
+ }
82
+ const issue = await api(`issues/${target.issue}`);
83
+ if (issue.state !== 'open') continue;
84
+ if (pr.state !== 'open' || pr.merged) {
85
+ for (const name of ['pr-draft', 'pr-ready']) if (issue.labels.some(label => label.name === name)) await api(`issues/${issue.number}/labels/${name}`, 'DELETE', undefined, true);
86
+ if (!pr.merged) {
87
+ await api(`issues/${issue.number}/labels`, 'POST', { labels: ['waiting-review'] });
88
+ const notice = `<!-- lighthouse-pr-closed:${pr.number}:${pr.head.sha} -->`;
89
+ if (!(await commentsFor(api, issue.number)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(notice))) await api(`issues/${issue.number}/comments`, 'POST', { body: `PR #${pr.number} was closed without merging. Reopen it or request a new implementation. [Open PR](${pr.html_url}).\n\n${notice}` });
90
+ }
91
+ continue;
92
+ }
93
+ const comments = await commentsFor(api, target.issue);
94
+ if (event.action === 'converted_to_draft') {
95
+ const hold = `<!-- lighthouse-pr-hold:${pr.number}:${pr.head.sha} -->`;
96
+ if (!comments.some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(hold))) await api(`issues/${target.issue}/comments`, 'POST', { body: `PR #${pr.number} is a draft by owner choice. [Open PR](${pr.html_url}).\n\n${hold}` });
97
+ await projectImplementationPrState(api, target.issue, true);
98
+ continue;
99
+ }
100
+ const hold = comments.some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(`<!-- lighthouse-pr-hold:${pr.number}:${pr.head.sha} -->`));
101
+ const auto = (['workflow_run', 'schedule'].includes(env.GITHUB_EVENT_NAME) || ciCallback) && !hold;
102
+ const result = await reconcileImplementationPr(api, repo, pr, issue, comments, requiredChecks(profile), (current, draft) => setPullRequestDraft(env.GH_TOKEN, current, draft), { auto });
103
+ if (event.action === 'ready_for_review' && result.status === 'draft') await api(`issues/${target.issue}/comments`, 'POST', { body: `PR #${pr.number} remains a draft: ${result.reason}. [Open PR](${pr.html_url}).` });
104
+ }
105
+ }
106
+ main().catch(error => { console.error(`::error::${error.message}`); process.exitCode = 1; });
@@ -0,0 +1,75 @@
1
+ import { checkImplementationPullRequest } from './proposal-review.mjs';
2
+ import { checkCommit } from './completion.mjs';
3
+
4
+ const marker = /<!-- lighthouse-pr-state:(\{[^\n]*\}) -->/;
5
+
6
+ export async function scheduledImplementationPulls(api) {
7
+ const numbers = [];
8
+ for (let page = 1; page <= 10; page++) {
9
+ const pulls = await api(`pulls?state=open&per_page=100&page=${page}`);
10
+ numbers.push(...pulls.filter(pr => /^lighthouse\/implement-[1-9][0-9]*-[a-f0-9]{12}$/.test(pr.head?.ref ?? '') && pr.user?.login === 'github-actions[bot]').map(pr => pr.number));
11
+ if (pulls.length < 100) return numbers;
12
+ }
13
+ throw Error('Open PR history exceeds reconciliation bound');
14
+ }
15
+
16
+ export function authorizedCiCallback(run, { id, sha, repo, workflows }) {
17
+ return Number.isSafeInteger(id) && /^[a-f0-9]{40}$/.test(sha ?? '') && run.id === id && run.event === 'workflow_dispatch' && run.head_sha === sha && run.repository?.full_name === repo && workflows?.some(name => run.path === `.github/workflows/${name}`) === true;
18
+ }
19
+
20
+ export async function setPullRequestDraft(token, pr, draft) {
21
+ if (!pr.node_id) throw Error('PR lacks GraphQL identity');
22
+ const field = draft ? 'convertPullRequestToDraft' : 'markPullRequestReadyForReview';
23
+ const response = await fetch('https://api.github.com/graphql', {
24
+ method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
25
+ body: JSON.stringify({ query: `mutation($id:ID!){${field}(input:{pullRequestId:$id}){pullRequest{id isDraft}}}`, variables: { id: pr.node_id } }),
26
+ signal: AbortSignal.timeout(30000),
27
+ });
28
+ const result = await response.json();
29
+ if (!response.ok || result.errors?.length || result.data?.[field]?.pullRequest?.isDraft !== draft) throw Error('GitHub could not change PR draft state');
30
+ }
31
+
32
+ export function implementationPrProof(comments, pr) {
33
+ for (const comment of [...comments].reverse()) {
34
+ if (comment.user?.login !== 'github-actions[bot]' || comment.user.type !== 'Bot') continue;
35
+ const raw = comment.body?.match(marker)?.[1];
36
+ if (!raw) continue;
37
+ let proof;
38
+ try { proof = JSON.parse(raw); } catch { continue; }
39
+ if (proof.pr === pr.number && proof.head === pr.head.sha && typeof proof.complete === 'boolean' && /^[1-9][0-9]*$/.test(String(proof.run))) return proof;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ export async function projectImplementationPrState(api, issue, draft) {
45
+ const current = await api(`issues/${issue}`);
46
+ if (current.state !== 'open') return;
47
+ const wanted = draft ? 'pr-draft' : 'pr-ready';
48
+ for (const label of ['ready', 'waiting-review', draft ? 'pr-ready' : 'pr-draft']) {
49
+ if (current.labels.some(item => item.name === label)) await api(`issues/${issue}/labels/${label}`, 'DELETE', undefined, true);
50
+ }
51
+ if (!current.labels.some(item => item.name === wanted)) await api(`issues/${issue}/labels`, 'POST', { labels: [wanted] });
52
+ }
53
+
54
+ export async function reconcileImplementationPr(api, repo, pr, issue, comments, requiredChecks, setDraft, { auto = false } = {}) {
55
+ const target = checkImplementationPullRequest(pr, repo);
56
+ if (target.issue !== issue.number || issue.state !== 'open') return { status: 'ignored', reason: 'Issue or PR changed' };
57
+ const proof = implementationPrProof(comments, pr);
58
+ const checks = await checkCommit(api, pr.head.sha, requiredChecks);
59
+ const reserved = issue.labels.some(label => ['agent-working', 'human-working'].includes(label.name));
60
+ const complete = !reserved && proof?.complete === true && checks.length > 0 && checks.every(check => check.status === 'passed');
61
+ if (pr.draft && auto && complete) {
62
+ try { await setDraft(pr, false); }
63
+ catch (error) { if ((await api(`pulls/${pr.number}`)).draft) throw error; }
64
+ await projectImplementationPrState(api, issue.number, false);
65
+ return { status: 'ready', reason: 'Candidate and required checks passed' };
66
+ }
67
+ if (!pr.draft && !complete) {
68
+ try { await setDraft(pr, true); }
69
+ catch (error) { if (!(await api(`pulls/${pr.number}`)).draft) throw error; }
70
+ await projectImplementationPrState(api, issue.number, true);
71
+ return { status: 'draft', reason: reserved ? 'Implementation or owner work is still active' : !proof?.complete ? 'Implementation is incomplete' : 'Required checks have not passed' };
72
+ }
73
+ await projectImplementationPrState(api, issue.number, pr.draft);
74
+ return { status: pr.draft ? 'draft' : 'ready', reason: pr.draft ? 'PR is a draft' : 'Candidate and required checks passed' };
75
+ }