@juangcarmona/lighthouse-sdlc 0.13.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.
Files changed (57) hide show
  1. package/README.md +65 -12
  2. package/capabilities.json +3 -0
  3. package/completion.md +6 -4
  4. package/execution-environments.md +43 -0
  5. package/model-profiles.md +101 -0
  6. package/package.json +9 -1
  7. package/request-triage.md +30 -0
  8. package/schemas/estimate.schema.json +23 -0
  9. package/schemas/execution-environment.schema.json +12 -0
  10. package/schemas/request-analysis.schema.json +37 -0
  11. package/schemas/triage.schema.json +1 -0
  12. package/skills/concise-results/SKILL.md +10 -0
  13. package/skills/implement/SKILL.md +9 -8
  14. package/skills/refine/SKILL.md +1 -1
  15. package/skills/triage/SKILL.md +3 -3
  16. package/src/actions-artifact.mjs +17 -0
  17. package/src/completion-actions.mjs +30 -10
  18. package/src/completion.mjs +20 -1
  19. package/src/delivery-events.mjs +42 -0
  20. package/src/execution-environment.mjs +78 -0
  21. package/src/host-contract.mjs +3 -0
  22. package/src/implementation-runtime.mjs +56 -32
  23. package/src/implementation.mjs +36 -19
  24. package/src/install-workflows.mjs +16 -7
  25. package/src/integration-evidence.mjs +25 -1
  26. package/src/issue-actions.mjs +315 -49
  27. package/src/model-profiles.mjs +58 -0
  28. package/src/opencode-execute.mjs +35 -12
  29. package/src/pr-state-actions.mjs +106 -0
  30. package/src/pr-state.mjs +75 -0
  31. package/src/process-publication.mjs +1 -1
  32. package/src/profile-delegation.mjs +111 -0
  33. package/src/proposal-review.mjs +91 -0
  34. package/src/proposal.mjs +22 -8
  35. package/src/request-triage.mjs +42 -0
  36. package/src/stage-dispatch.mjs +164 -0
  37. package/src/stage-transition.mjs +35 -0
  38. package/src/triage.mjs +4 -0
  39. package/src/worker-maintenance.mjs +83 -0
  40. package/src/worker-recovery.mjs +106 -0
  41. package/src/worker-reports.mjs +150 -0
  42. package/templates/change-request.yml +24 -0
  43. package/workflows/lighthouse-sdlc-apply-review.lock.yml +1335 -0
  44. package/workflows/lighthouse-sdlc-apply-review.md +206 -0
  45. package/workflows/lighthouse-sdlc-implement.lock.yml +51 -90
  46. package/workflows/lighthouse-sdlc-implement.md +40 -11
  47. package/workflows/lighthouse-sdlc-integrate.lock.yml +36 -83
  48. package/workflows/lighthouse-sdlc-integrate.md +25 -2
  49. package/workflows/lighthouse-sdlc-propose.lock.yml +44 -83
  50. package/workflows/lighthouse-sdlc-propose.md +33 -4
  51. package/workflows/lighthouse-sdlc-refine.lock.yml +45 -83
  52. package/workflows/lighthouse-sdlc-refine.md +37 -2
  53. package/workflows/lighthouse-sdlc-review.lock.yml +36 -83
  54. package/workflows/lighthouse-sdlc-review.md +25 -2
  55. package/workflows/lighthouse-sdlc-router.yml +104 -63
  56. package/workflows/lighthouse-sdlc-triage.md +174 -28
  57. package/workflows/lighthouse-worker-maintenance.yml +126 -0
@@ -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
+ }
@@ -1,13 +1,15 @@
1
- // gh-aw engine adapter: one foreground container, no service or orchestration state.
1
+ // gh-aw engine adapter: one foreground container and optional host-owned test services.
2
2
  import { readFile, writeFile, mkdir, mkdtemp, readdir, copyFile, chmod, rm, lstat } from 'node:fs/promises';
3
3
  import { dirname, join, resolve, isAbsolute } from 'node:path';
4
4
  import { openSync, closeSync, appendFileSync } from 'node:fs';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { spawn, execFileSync } from 'node:child_process';
7
7
  import { sourceFiles, collectChanges, materializeCandidate, validateChanges, changesDigest } from './implementation.mjs';
8
- import { writableTree, copyEvidence, runImplementationChecks } from './implementation-runtime.mjs';
8
+ import { prepareImplementationWorkspace, copyEvidence, runImplementationChecks } from './implementation-runtime.mjs';
9
9
  import { validateStageEnvelope } from './host-contract.mjs';
10
10
  import { formatOpenCodeEvent, progressText, boundedEventLines } from './opencode-progress.mjs';
11
+ import { selectEnvironment, startEnvironment, environmentArgs, environmentReceipt, redactEnvironmentLog } from './execution-environment.mjs';
12
+ import { resolveModelProfile, modelExecutionReceipt } from './model-profiles.mjs';
11
13
 
12
14
  export function completionOutput(envelope, request) {
13
15
  const errors = validateStageEnvelope(envelope, request);
@@ -49,7 +51,21 @@ async function main() {
49
51
  if (!/^sha256:[a-f0-9]{64}$/.test(config.image) || !/^[\w-]+$/.test(config.authVolume) || !Number.isInteger(config.uid)) throw Error('Invalid operator engine configuration');
50
52
  const runDir = join(env.RUNNER_TEMP, `lighthouse-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`);
51
53
  const request = JSON.parse(await readFile(join(runDir, 'request.json'), 'utf8'));
54
+ // Sequential workers share the runner temp directory; only this stage's outputs may survive.
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 });
66
+ const selected = request.stage === 'implement' ? selectEnvironment(config, request.implementation?.environment) : null;
52
67
  if (request.stage === 'implement') {
68
+ if (selected) config.implementationImage = selected.image;
53
69
  if (!/^sha256:[a-f0-9]{64}$/.test(config.implementationImage ?? '')) throw Error('Operator must provision and pin an implementation image');
54
70
  config.image = config.implementationImage;
55
71
  }
@@ -61,12 +77,14 @@ async function main() {
61
77
  const implementing = request.stage === 'implement';
62
78
  const name = `lighthouse-engine-${request.runId}`;
63
79
  const docker = args => execFileSync('docker', args, { stdio: 'ignore', timeout: 30000 });
64
- let child, timer, heartbeat, slot, baseline, trackedSource, cancelled = false;
80
+ let child, timer, heartbeat, slot, baseline, trackedSource, runtime, cancelled = false;
65
81
  const cancel = () => { cancelled = true; child?.kill('SIGTERM'); try { docker(['rm', '-f', name, `${name}-checks`]); } catch {} };
66
82
  process.once('SIGTERM', cancel); process.once('SIGINT', cancel);
67
83
  try {
68
84
  console.log('Waiting for the operator host engine slot');
69
85
  slot = acquireEngineSlot(config.lock);
86
+ runtime = await startEnvironment({ selected, name: `${name}-agent`, uid: config.uid });
87
+ if (cancelled) throw Error('Execution cancelled before agent startup');
70
88
  console.log('Engine slot acquired; starting isolated OpenCode');
71
89
  await snapshotSource(env.GITHUB_WORKSPACE, source);
72
90
  baseline = implementing ? await sourceFiles(source) : null;
@@ -79,28 +97,30 @@ async function main() {
79
97
  await rm(source, { recursive: true, force: true }); await mkdir(source);
80
98
  await materializeCandidate(baseline, seed, source);
81
99
  }
82
- await writableTree(source);
100
+ await prepareImplementationWorkspace(source, config);
83
101
  }
84
102
  const context = join(dir, 'context.json');
85
103
  await copyFile(join(runDir, 'context.json'), context); await chmod(context, 0o444);
86
104
  const prompt = join(dir, 'prompt.txt');
87
- 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';
88
106
  await writeFile(prompt, await readFile(env.GH_AW_PROMPT, 'utf8') + completionBoundary, { mode: 0o444 });
89
107
  const harness = resolve(dirname(fileURLToPath(import.meta.url)), '..');
90
108
  const pkg = JSON.parse(await readFile(join(harness, 'package.json'), 'utf8'));
91
- 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, 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 }));
92
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',
93
- '--tmpfs', '/tmp:rw,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}`,
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}`,
94
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`,
95
- '--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', 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];
96
114
  const eventsPath = join(env.RUNNER_TEMP, 'gh-aw', `opencode-events-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}.jsonl`);
97
115
  await mkdir(dirname(eventsPath), { recursive: true });
98
116
  await writeFile(eventsPath, '', { mode: 0o600 });
99
117
  const started = Date.now();
100
- let lastVisible = started;
101
- const report = text => { lastVisible = Date.now(); console.log(redactLog(text)); };
118
+ let lastVisible = started, engineError = false;
119
+ const redact = text => redactLog(redactEnvironmentLog(runtime, text));
120
+ const report = text => { lastVisible = Date.now(); console.log(redact(text)); };
102
121
  const eventLine = line => {
103
- const redacted = redactLog(line);
122
+ const redacted = redact(line);
123
+ try { if (JSON.parse(redacted).type === 'error') engineError = true; } catch {}
104
124
  appendFileSync(eventsPath, redacted + '\n');
105
125
  const visible = formatOpenCodeEvent(redacted);
106
126
  if (visible) report(visible);
@@ -129,6 +149,7 @@ async function main() {
129
149
  child.on('error', no); child.on('close', code => code === 0 && !cancelled ? ok() : no(Error('OpenCode failed or was cancelled')));
130
150
  timer = setTimeout(cancel, implementing ? 1800000 : 600000);
131
151
  });
152
+ if (engineError) throw Error('OpenCode reported an execution error; no model fallback attempted');
132
153
  const result = join(output, 'envelope.json'), stat = await lstat(result);
133
154
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128000) throw Error('Invalid engine result file');
134
155
  const envelope = JSON.parse(await readFile(result, 'utf8'));
@@ -141,7 +162,8 @@ async function main() {
141
162
  const candidate = join(dir, 'candidate'); await mkdir(candidate);
142
163
  await materializeCandidate(baseline, changes, candidate);
143
164
  const checkBaseline = await sourceFiles(candidate);
144
- const receipt = await runImplementationChecks({ config, request, changes, workspace: candidate, output: join(runDir, 'evidence', 'host'), name, redact: redactLog, cancelled: () => cancelled });
165
+ if (runtime.cleanup() === false) throw Error('Agent test service cleanup failed; operator action required');
166
+ const receipt = await runImplementationChecks({ config, request, changes, workspace: candidate, output: join(runDir, 'evidence', 'host'), name, redact: redactLog, cancelled: () => cancelled, selected });
145
167
  if (collectChanges(checkBaseline, await sourceFiles(candidate, checkBaseline), request.implementation.policy).length) { receipt.checks.at(-1).exitCode = 1; receipt.checks.at(-1).reason = 'Checks modified source; candidate not validated'; }
146
168
  await writeFile(join(runDir, 'checks.json'), JSON.stringify(receipt));
147
169
  if (request.implementation.policy.reviewRequired) {
@@ -177,6 +199,7 @@ async function main() {
177
199
  } finally {
178
200
  clearTimeout(timer); clearInterval(heartbeat); process.removeListener('SIGTERM', cancel); process.removeListener('SIGINT', cancel);
179
201
  try { docker(['rm', '-f', name, `${name}-checks`]); } catch {}
202
+ runtime?.cleanup();
180
203
  try { docker(['run', '--rm', '--network', 'none', '--user', `${config.uid}:${config.uid}`, '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--mount', `type=volume,src=${config.authVolume},dst=/home/engine/.local/share/opencode`, config.image, 'cleanup-session', `ses_${request.runId}`]); } catch { console.error('Session cleanup failed; operator inspection required'); }
181
204
  if (slot !== undefined) closeSync(slot);
182
205
  if (implementing) {
@@ -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
+ }
@@ -25,7 +25,7 @@ export function completionBundle(request, envelope, receipt) {
25
25
  'receipt.json': json(receipt),
26
26
  'stage-result.json': json(envelope.result),
27
27
  };
28
- const record = { schemaVersion: 1, id: `completion:${request.runId}`, runId: request.runId, kind: 'stage-result', scope: 'delivery', status: envelope.result.status === 'passed' ? 'passed' : 'pending-review', producer: 'Lighthouse GitHub Actions completion adapter', subject: { type: 'issue', id: observation.issueUrl }, observedAt: request.observedAt, artifacts: Object.entries(files).map(([path, content]) => ({ type: path.endsWith('.md') ? 'report' : 'receipt', path, sha256: digest(content) })), payload: { stage: request.stage, priorIntegration, binding: observation.binding, requiredChecks: observation.checks, observedChecks: observation.observedChecks, finalization: observation.finalization, receipt, checkSource: 'GitHub REST API', workflow: { runId: request.workflowRunId, attempt: request.attempt, url: request.runUrl, harnessRevision: request.harnessRevision } }, nextAction: envelope.result.nextAction };
28
+ const record = { schemaVersion: 1, id: `completion:${request.runId}`, runId: request.runId, kind: 'stage-result', scope: 'delivery', status: envelope.result.status === 'passed' ? 'passed' : 'pending-review', producer: 'Lighthouse GitHub Actions completion adapter', subject: { type: 'issue', id: observation.issueUrl }, observedAt: request.observedAt, artifacts: Object.entries(files).map(([path, content]) => ({ type: path.endsWith('.md') ? 'report' : 'receipt', path, sha256: digest(content) })), payload: { stage: request.stage, priorIntegration, binding: observation.binding, requiredChecks: observation.checks, observedChecks: observation.observedChecks, finalization: observation.finalization, receipt, checkSource: 'GitHub REST API', workflow: { ...(request.dispatchOrigin ? { dispatchOrigin: request.dispatchOrigin } : {}), runId: request.workflowRunId, attempt: request.attempt, url: request.runUrl, harnessRevision: request.harnessRevision } }, nextAction: envelope.result.nextAction };
29
29
  const errors = validateEvidenceRecord(record); if (errors.length) throw Error(JSON.stringify(errors));
30
30
  files['evidence-record.json'] = JSON.stringify(record);
31
31
  const { index, errors: buildErrors } = buildEvidenceIndex([record], { runId: request.runId, repository: observation.binding.repository, revision: receipt.sha ?? observation.binding.head, recordPaths: { [record.id]: 'evidence-record.json' } });
@@ -0,0 +1,111 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { validateLaunchRequest } from './control-plane.mjs';
3
+ import { withinScope } from './implementation.mjs';
4
+ import { resolveModelProfile, modelExecutionReceipt } from './model-profiles.mjs';
5
+
6
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ const name = value => typeof value === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(value);
8
+ const validScope = value => typeof value === 'string' && !/[?*\[\]{}\\]/.test(value) && withinScope(value.replace(/\/$/, ''), { paths: ['*'] });
9
+ const contains = (scope, file) => scope === '*' || (scope.endsWith('/') ? file.startsWith(scope) : file === scope);
10
+ const overlaps = (a, b) => a.replace(/\/$/, '') === b.replace(/\/$/, '') || contains(a, b) || contains(b, a);
11
+ const boundedLimits = limits => record(limits) && Number.isInteger(limits.timeoutSeconds) && limits.timeoutSeconds > 0 && limits.timeoutSeconds <= 1800 && Number.isInteger(limits.maxAttempts) && limits.maxAttempts === 1;
12
+
13
+ export function prepareDelegatedTasks({ tasks, parent, controlPlane, project = null, operator, maxParallel }) {
14
+ if (!Number.isInteger(maxParallel) || maxParallel < 1 || maxParallel > 8) throw Error('Host parallel limit must be 1..8');
15
+ if (!record(parent) || !record(controlPlane?.roles?.[parent.role]) || validateLaunchRequest(parent, controlPlane).length || !boundedLimits(parent.limits)) throw Error('Invalid bounded parent launch contract');
16
+ if (!Array.isArray(tasks) || !tasks.length || tasks.length > 100) throw Error('Delegation requires 1..100 tasks');
17
+ const ids = new Set();
18
+ const prepared = structuredClone(tasks).map(task => {
19
+ if (!record(task) || Object.keys(task).some(key => !['id', 'specialist', 'profile', 'dependencies', 'affectedFiles', 'input', 'tools', 'outputSchema', 'limits', 'stopConditions'].includes(key)) || !name(task.id) || ids.has(task.id) || !name(task.specialist)) throw Error('Invalid or duplicate delegated task');
20
+ ids.add(task.id);
21
+ if (!Array.isArray(task.dependencies) || task.dependencies.some(id => !name(id)) || new Set(task.dependencies).size !== task.dependencies.length) throw Error('Task dependencies must be unique task IDs');
22
+ if (!Array.isArray(task.affectedFiles) || !task.affectedFiles.length || task.affectedFiles.length > 100 || new Set(task.affectedFiles).size !== task.affectedFiles.length || task.affectedFiles.some(file => !validScope(file) || !parent.writeScope.some(scope => contains(scope, file)))) throw Error('Task affected files exceed parent scope or use unsupported paths');
23
+ if (task.tools !== undefined && !Array.isArray(task.tools) || task.stopConditions !== undefined && !Array.isArray(task.stopConditions)) throw Error('Task tools and stop conditions must be arrays');
24
+ if (task.limits !== undefined && (!record(task.limits) || Object.keys(task.limits).some(key => !['timeoutSeconds', 'maxAttempts'].includes(key)))) throw Error('Task limits may only narrow timeout and attempts');
25
+ if (!record(controlPlane.roles[task.specialist])) throw Error('Task specialist needs a declared launch contract');
26
+ const input = JSON.stringify(task.input ?? {});
27
+ if (Buffer.byteLength(input) > 65536) throw Error('Task context exceeds 64 KiB');
28
+ const child = {
29
+ runId: `${parent.runId}-${task.id}-${randomUUID()}`, parentRun: parent.runId, role: task.specialist,
30
+ input: JSON.parse(input), outputSchema: task.outputSchema ?? controlPlane.roles[task.specialist].outputSchema,
31
+ tools: task.tools ?? [...parent.tools], writeScope: [...task.affectedFiles], limits: { ...parent.limits, ...task.limits },
32
+ stopConditions: [...parent.stopConditions, ...(task.stopConditions ?? [])],
33
+ };
34
+ if (!boundedLimits(child.limits) || child.limits.timeoutSeconds > parent.limits.timeoutSeconds || child.tools.some(tool => !parent.tools.includes(tool)) || validateLaunchRequest(child, controlPlane).length) throw Error('Task launch exceeds specialist or parent contract');
35
+ const selection = resolveModelProfile({ project, operator, worker: task.specialist, profile: task.profile });
36
+ if (selection.mode !== 'profile' || !task.profile) throw Error('Delegated task must declare a profile');
37
+ return { id: task.id, dependencies: task.dependencies, affectedFiles: task.affectedFiles, child, selection };
38
+ });
39
+ for (const task of prepared) if (task.dependencies.some(id => !ids.has(id) || id === task.id)) throw Error('Unknown or self task dependency');
40
+ const visited = new Set(), active = new Set(), byId = new Map(prepared.map(task => [task.id, task]));
41
+ function visit(task) {
42
+ if (active.has(task.id)) throw Error('Delegated task dependency cycle');
43
+ if (visited.has(task.id)) return;
44
+ active.add(task.id);
45
+ for (const id of task.dependencies) visit(byId.get(id));
46
+ active.delete(task.id); visited.add(task.id);
47
+ }
48
+ for (const task of prepared) visit(task);
49
+ return prepared;
50
+ }
51
+
52
+ // Callbacks are host-owned: enforce paths/tools, stop on abort, validate output schemas,
53
+ // retain evidence and check actual file changes. This scheduler grants no permissions.
54
+ export async function runDelegatedTasks(options) {
55
+ const { launch, verifyWave, signal } = options;
56
+ if (typeof launch !== 'function' || typeof verifyWave !== 'function') throw Error('Host launch and wave verification callbacks required');
57
+ const tasks = prepareDelegatedTasks(options), results = new Map(), waves = [];
58
+ const deadline = Date.now() + options.parent.limits.timeoutSeconds * 1000;
59
+ const controller = new AbortController();
60
+ const abort = () => controller.abort();
61
+ signal?.addEventListener('abort', abort, { once: true });
62
+ if (signal?.aborted) abort();
63
+ const timer = setTimeout(abort, Math.max(1, deadline - Date.now()));
64
+ const validResult = result => result?.status === 'passed' && Array.isArray(result.evidence) && result.evidence.length > 0;
65
+ async function bounded(callback, seconds) {
66
+ if (controller.signal.aborted) throw Error('Delegation stopped');
67
+ let timeout, onAbort;
68
+ try {
69
+ return await Promise.race([
70
+ Promise.resolve().then(() => callback(controller.signal)),
71
+ new Promise((_, reject) => {
72
+ onAbort = () => reject(Error('Delegation stopped'));
73
+ controller.signal.addEventListener('abort', onAbort, { once: true });
74
+ timeout = setTimeout(() => { controller.abort(); reject(Error('Delegation deadline exceeded')); }, Math.min(seconds * 1000, Math.max(1, deadline - Date.now())));
75
+ }),
76
+ ]);
77
+ } finally { clearTimeout(timeout); controller.signal.removeEventListener('abort', onAbort); }
78
+ }
79
+ try {
80
+ while (results.size < tasks.length && !controller.signal.aborted) {
81
+ // Propagate failures transitively before selecting eligible tasks.
82
+ let changed;
83
+ do {
84
+ changed = false;
85
+ for (const task of tasks) if (!results.has(task.id) && task.dependencies.some(id => results.has(id) && results.get(id).status !== 'passed')) {
86
+ results.set(task.id, { id: task.id, status: 'blocked', reason: 'dependency-failed' }); changed = true;
87
+ }
88
+ } while (changed);
89
+ const wave = [];
90
+ for (const task of tasks) {
91
+ if (results.has(task.id) || task.dependencies.some(id => results.get(id)?.status !== 'passed')) continue;
92
+ if (wave.length < options.maxParallel && !wave.some(other => other.affectedFiles.some(a => task.affectedFiles.some(b => overlaps(a, b))))) wave.push(task);
93
+ }
94
+ if (!wave.length) break;
95
+ const executions = await Promise.all(wave.map(async task => {
96
+ try {
97
+ const outcome = await bounded(signal => launch({ ...structuredClone(task.child), model: structuredClone(task.selection), signal }), task.child.limits.timeoutSeconds);
98
+ return { id: task.id, status: validResult(outcome) ? 'passed' : 'failed', reason: validResult(outcome) ? undefined : 'launch-result-failed', outcome, modelExecution: modelExecutionReceipt(task.selection) };
99
+ } catch { return { id: task.id, status: 'failed', reason: 'launch-failed-or-stopped', modelExecution: modelExecutionReceipt(task.selection) }; }
100
+ }));
101
+ let verification;
102
+ try { verification = await bounded(signal => verifyWave({ tasks: structuredClone(wave), results: structuredClone(executions), signal }), options.parent.limits.timeoutSeconds); }
103
+ catch { verification = { status: 'failed', evidence: [] }; }
104
+ const passed = validResult(verification);
105
+ for (const execution of executions) results.set(execution.id, execution.status === 'passed' && !passed ? { ...execution, status: 'failed', reason: 'wave-verification-failed' } : execution);
106
+ waves.push({ tasks: wave.map(task => task.id), status: passed && executions.every(result => result.status === 'passed') ? 'passed' : 'failed', verification });
107
+ }
108
+ for (const task of tasks) if (!results.has(task.id)) results.set(task.id, { id: task.id, status: 'blocked', reason: controller.signal.aborted ? 'execution-stopped' : 'dependency-failed' });
109
+ return { status: [...results.values()].every(result => result.status === 'passed') ? 'passed' : 'blocked', waves, tasks: tasks.map(task => results.get(task.id)) };
110
+ } finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
111
+ }
@@ -0,0 +1,91 @@
1
+ import { digest } from './host-contract.mjs';
2
+ import { proposalRoot, validateProposalFiles } from './proposal.mjs';
3
+
4
+ export const feedbackKey = review => digest(`${review.number}:${review.kind}:${review.id}:${review.feedbackDigest}`);
5
+ export const feedbackWorker = review => review && !review.approved ? 'apply-review' : undefined;
6
+
7
+ export function reviewTarget(event, eventName) {
8
+ if (eventName === 'issue_comment' && event.issue?.pull_request) return event.issue.number;
9
+ if (['pull_request_review', 'pull_request_review_comment'].includes(eventName)) return event.pull_request?.number;
10
+ return null;
11
+ }
12
+
13
+ export function checkProposal(pr, repo, expected) {
14
+ const match = pr.head?.ref?.match(/^lighthouse\/propose-([1-9][0-9]*)-([a-f0-9]{12})$/);
15
+ const marker = pr.body?.match(/<!-- lighthouse-proposal:([a-f0-9]{64}) -->/)?.[1];
16
+ if (!match || !marker?.startsWith(match[2]) || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo) throw Error('Not an open repository-owned Lighthouse proposal');
17
+ if (expected && (pr.head.sha !== expected.head || pr.head.ref !== expected.branch || Number(match[1]) !== expected.issue || pr.base.ref !== expected.base)) throw Error('Proposal changed during review; no update published');
18
+ return { number: pr.number, issue: Number(match[1]), head: pr.head.sha, branch: pr.head.ref, base: pr.base.ref, url: pr.html_url };
19
+ }
20
+
21
+ export function checkImplementationPullRequest(pr, repo, expected) {
22
+ const match = pr.head?.ref?.match(/^lighthouse\/implement-([1-9][0-9]*)-([a-f0-9]{12})$/);
23
+ const marker = pr.body?.match(/<!-- lighthouse-implementation:([a-f0-9]{64}) -->/)?.[1];
24
+ if (!match || !marker?.startsWith(match[2]) || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo) throw Error('Not an open repository-owned implementation PR');
25
+ if (expected && (pr.head.sha !== expected.head || pr.head.ref !== expected.branch || Number(match[1]) !== expected.issue || pr.base.ref !== expected.base)) throw Error('Implementation changed during review');
26
+ return { number: pr.number, issue: Number(match[1]), head: pr.head.sha, branch: pr.head.ref, base: pr.base.ref, url: pr.html_url };
27
+ }
28
+
29
+ export async function loadProposalReview(api, repo, owner, event, eventName, implementation = false) {
30
+ const number = reviewTarget(event, eventName);
31
+ if (!number) return null;
32
+ const pr = await api(`pulls/${number}`), target = (implementation ? checkImplementationPullRequest : checkProposal)(pr, repo);
33
+ const kind = eventName === 'pull_request_review' ? 'review' : eventName === 'pull_request_review_comment' ? 'inline' : 'comment';
34
+ const id = kind === 'review' ? event.review?.id : event.comment?.id;
35
+ if (!Number.isSafeInteger(id) || id < 1) throw Error('Missing feedback identity');
36
+ const path = kind === 'review' ? `pulls/${number}/reviews/${id}` : kind === 'inline' ? `pulls/comments/${id}` : `issues/comments/${id}`;
37
+ const feedback = await api(path);
38
+ if (feedback.user?.login !== owner || feedback.user.type === 'Bot') throw Error('Feedback is not from the repository owner');
39
+ if (kind === 'comment' && feedback.issue_url !== `https://api.github.com/repos/${repo}/issues/${number}` || kind === 'inline' && feedback.pull_request_url !== `https://api.github.com/repos/${repo}/pulls/${number}`) throw Error('Feedback belongs to another PR');
40
+ if (kind !== 'comment' && feedback.commit_id !== target.head) throw Error('Feedback targets an older proposal commit; review the current diff');
41
+ const approved = kind === 'review' && feedback.state?.toLowerCase() === 'approved';
42
+ if (kind === 'review' && !['approved', 'commented', 'changes_requested'].includes(feedback.state?.toLowerCase())) throw Error('Review is not submitted feedback');
43
+ const inline = kind === 'review' ? await api(`pulls/${number}/reviews/${id}/comments?per_page=100`) : [];
44
+ if (inline.length >= 100) throw Error('Review exceeds bounded feedback collection');
45
+ const body = [feedback.body, ...inline.map(c => `${c.path}:${c.line ?? c.original_line}\n${c.body}`)].filter(Boolean).join('\n\n');
46
+ if (!approved && !body.trim()) throw Error('No review feedback to apply');
47
+ return { ...target, kind, id, path, feedback: body, feedbackDigest: digest(JSON.stringify(feedback)), receipt: `<!-- lighthouse-review:${kind}:${id}:${digest(JSON.stringify(feedback))} -->`, url: pr.html_url, feedbackUrl: feedback.html_url, approved };
48
+ }
49
+
50
+ export async function reviewThread(api, number, owner) {
51
+ const messages = [];
52
+ for (const path of [`issues/${number}/comments`, `pulls/${number}/comments`, `pulls/${number}/reviews`]) {
53
+ const rows = await api(`${path}?per_page=100`);
54
+ if (rows.length >= 100) throw Error('Proposal discussion exceeds bounded collection');
55
+ for (const c of rows) if (c.user?.login === owner && c.user.type !== 'Bot' && (!c.state || ['commented', 'changes_requested'].includes(c.state.toLowerCase()))) messages.push({ id: c.id, body: c.body, path: c.path, url: c.html_url, updated: c.updated_at, submitted: c.submitted_at });
56
+ }
57
+ return messages;
58
+ }
59
+
60
+ export async function loadProposalFiles(api, review) {
61
+ const changed = await api(`pulls/${review.number}/files?per_page=100`);
62
+ const root = proposalRoot(review.issue);
63
+ if (changed.length >= 100 || changed.some(f => !f.filename.startsWith(`${root}/`) || f.status === 'removed' || f.status === 'renamed')) throw Error('Proposal PR includes changes outside the proposal');
64
+ const files = [];
65
+ for (const f of changed) {
66
+ if (f.filename === `${root}/.openspec.yaml`) continue;
67
+ const value = await api(`contents/${f.filename}?ref=${review.head}`);
68
+ if (value.type !== 'file' || value.encoding !== 'base64' || value.size > 96000) throw Error('Unsupported proposal file');
69
+ files.push({ path: f.filename, content: Buffer.from(value.content, 'base64').toString('utf8') });
70
+ }
71
+ return validateProposalFiles(files, review.issue);
72
+ }
73
+
74
+ export async function checkReviewCurrent(api, repo, review, implementation = false) {
75
+ (implementation ? checkImplementationPullRequest : checkProposal)(await api(`pulls/${review.number}`), repo, review);
76
+ if (digest(JSON.stringify(await api(review.path))) !== review.feedbackDigest) throw Error('Review feedback changed; no update published');
77
+ }
78
+
79
+ export async function updateProposal(api, repo, snapshot, files, beforeWrite) {
80
+ const review = snapshot.proposalReview;
81
+ const commit = await api(`git/commits/${review.head}`);
82
+ const old = await loadProposalFiles(api, review);
83
+ const removed = old.filter(f => !files.some(n => n.path === f.path)).map(f => ({ path: f.path, mode: '100644', type: 'blob', sha: null }));
84
+ const tree = await api('git/trees', 'POST', { base_tree: commit.tree.sha, tree: [...files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })), ...removed] });
85
+ const created = await api('git/commits', 'POST', { message: `docs: address proposal review for #${snapshot.issue}`, tree: tree.sha, parents: [review.head] });
86
+ await beforeWrite();
87
+ await checkReviewCurrent(api, repo, review);
88
+ // Non-forced update rejects a concurrent writer after the final head check.
89
+ await api(`git/refs/heads/${review.branch}`, 'PATCH', { sha: created.sha, force: false });
90
+ return { number: review.number, html_url: review.url };
91
+ }