@juangcarmona/lighthouse-sdlc 0.12.0 → 0.14.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 (47) hide show
  1. package/README.md +86 -3
  2. package/completion.md +50 -0
  3. package/execution-environments.md +43 -0
  4. package/package.json +3 -1
  5. package/schemas/execution-environment.schema.json +12 -0
  6. package/skills/implement/SKILL.md +9 -8
  7. package/skills/integrate/SKILL.md +7 -1
  8. package/skills/propose/SKILL.md +1 -1
  9. package/skills/review/SKILL.md +7 -1
  10. package/src/actions-artifact.mjs +17 -0
  11. package/src/completion-actions.mjs +134 -0
  12. package/src/completion.mjs +188 -0
  13. package/src/delivery-events.mjs +42 -0
  14. package/src/execution-environment.mjs +78 -0
  15. package/src/github-profile.mjs +9 -1
  16. package/src/host-contract.mjs +7 -3
  17. package/src/implementation-runtime.mjs +90 -0
  18. package/src/implementation.mjs +155 -0
  19. package/src/install-workflows.mjs +14 -5
  20. package/src/integration-evidence.mjs +80 -0
  21. package/src/issue-actions.mjs +325 -38
  22. package/src/observe-protection.mjs +24 -0
  23. package/src/opencode-execute.mjs +128 -20
  24. package/src/opencode-progress.mjs +48 -0
  25. package/src/process-publication.mjs +90 -0
  26. package/src/proposal-review.mjs +91 -0
  27. package/src/proposal.mjs +110 -0
  28. package/src/stage-dispatch.mjs +148 -0
  29. package/src/stage-transition.mjs +25 -0
  30. package/src/worker-maintenance.mjs +83 -0
  31. package/src/worker-recovery.mjs +106 -0
  32. package/src/worker-reports.mjs +150 -0
  33. package/workflows/lighthouse-sdlc-apply-review.lock.yml +1335 -0
  34. package/workflows/lighthouse-sdlc-apply-review.md +205 -0
  35. package/workflows/lighthouse-sdlc-implement.lock.yml +285 -1158
  36. package/workflows/lighthouse-sdlc-implement.md +193 -28
  37. package/workflows/lighthouse-sdlc-integrate.lock.yml +288 -1154
  38. package/workflows/lighthouse-sdlc-integrate.md +197 -28
  39. package/workflows/lighthouse-sdlc-propose.lock.yml +274 -1157
  40. package/workflows/lighthouse-sdlc-propose.md +183 -28
  41. package/workflows/lighthouse-sdlc-refine.lock.yml +263 -90
  42. package/workflows/lighthouse-sdlc-refine.md +45 -4
  43. package/workflows/lighthouse-sdlc-review.lock.yml +288 -1154
  44. package/workflows/lighthouse-sdlc-review.md +197 -28
  45. package/workflows/lighthouse-sdlc-router.yml +91 -17
  46. package/workflows/lighthouse-worker-maintenance.yml +126 -0
  47. package/workflows/shared/lighthouse-opencode.md +4 -2
@@ -1,10 +1,21 @@
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
- import { openSync, closeSync } from 'node:fs';
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
+ import { sourceFiles, collectChanges, materializeCandidate, validateChanges, changesDigest } from './implementation.mjs';
8
+ import { prepareImplementationWorkspace, copyEvidence, runImplementationChecks } from './implementation-runtime.mjs';
9
+ import { validateStageEnvelope } from './host-contract.mjs';
10
+ import { formatOpenCodeEvent, progressText, boundedEventLines } from './opencode-progress.mjs';
11
+ import { selectEnvironment, startEnvironment, environmentArgs, environmentReceipt, redactEnvironmentLog } from './execution-environment.mjs';
7
12
 
13
+ export function completionOutput(envelope, request) {
14
+ const errors = validateStageEnvelope(envelope, request);
15
+ if (errors.length) throw Error(errors.join('\n'));
16
+ if (!['passed', 'blocked'].includes(envelope.result.status)) throw Error('Stage did not complete');
17
+ return { type: 'noop', message: `Lighthouse ${request.stage} result validated: ${envelope.result.status}. GitHub publication belongs to the separate publisher job.` };
18
+ }
8
19
  export function redactLog(text) {
9
20
  return text.replace(/Bearer\s+[^\s"']+/gi, 'Bearer [REDACTED]')
10
21
  .replace(/\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{15,}/g, '[REDACTED]')
@@ -21,7 +32,7 @@ export async function snapshotSource(root, destination) {
21
32
  bytes += stat.size;
22
33
  if (bytes > 32 * 1024 * 1024) throw Error('Source snapshot exceeds byte bound');
23
34
  const target = join(destination, file);
24
- await mkdir(dirname(target), { recursive: true }); await copyFile(source, target); await chmod(target, 0o444);
35
+ await mkdir(dirname(target), { recursive: true }); await copyFile(source, target); await chmod(target, stat.mode & 0o111 ? 0o555 : 0o444);
25
36
  }
26
37
  }
27
38
  export function acquireEngineSlot(path, seconds = 240) {
@@ -39,52 +50,149 @@ async function main() {
39
50
  if (!/^sha256:[a-f0-9]{64}$/.test(config.image) || !/^[\w-]+$/.test(config.authVolume) || !Number.isInteger(config.uid)) throw Error('Invalid operator engine configuration');
40
51
  const runDir = join(env.RUNNER_TEMP, `lighthouse-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`);
41
52
  const request = JSON.parse(await readFile(join(runDir, 'request.json'), 'utf8'));
53
+ // Sequential workers share the runner temp directory; only this stage's outputs may survive.
54
+ for (const file of ['envelope.json', 'runtime.json', 'changes.json', 'checks.json']) await rm(join(runDir, file), { force: true });
55
+ const selected = request.stage === 'implement' ? selectEnvironment(config, request.implementation?.environment) : null;
56
+ if (request.stage === 'implement') {
57
+ if (selected) config.implementationImage = selected.image;
58
+ if (!/^sha256:[a-f0-9]{64}$/.test(config.implementationImage ?? '')) throw Error('Operator must provision and pin an implementation image');
59
+ config.image = config.implementationImage;
60
+ }
42
61
  if (!/^[a-f0-9]{64}$/.test(request.runId)) throw Error('Invalid execution identity');
43
62
  const dir = await mkdtemp(join(env.RUNNER_TEMP, 'lighthouse-engine-'));
44
63
  await chmod(dir, 0o755);
45
64
  const source = join(dir, 'source'), output = join(dir, 'output');
46
65
  await mkdir(source); await mkdir(output); await chmod(output, 0o777);
66
+ const implementing = request.stage === 'implement';
47
67
  const name = `lighthouse-engine-${request.runId}`;
48
68
  const docker = args => execFileSync('docker', args, { stdio: 'ignore', timeout: 30000 });
49
- let child, timer, slot, cancelled = false;
50
- const cancel = () => { cancelled = true; child?.kill('SIGTERM'); try { docker(['rm', '-f', name]); } catch {} };
69
+ let child, timer, heartbeat, slot, baseline, trackedSource, runtime, cancelled = false;
70
+ const cancel = () => { cancelled = true; child?.kill('SIGTERM'); try { docker(['rm', '-f', name, `${name}-checks`]); } catch {} };
51
71
  process.once('SIGTERM', cancel); process.once('SIGINT', cancel);
52
72
  try {
53
73
  console.log('Waiting for the operator host engine slot');
54
74
  slot = acquireEngineSlot(config.lock);
75
+ runtime = await startEnvironment({ selected, name: `${name}-agent`, uid: config.uid });
76
+ if (cancelled) throw Error('Execution cancelled before agent startup');
55
77
  console.log('Engine slot acquired; starting isolated OpenCode');
56
78
  await snapshotSource(env.GITHUB_WORKSPACE, source);
79
+ baseline = implementing ? await sourceFiles(source) : null;
80
+ trackedSource = baseline ? new Map(baseline) : null;
81
+ if (implementing) {
82
+ if (request.seedDigest) {
83
+ const seed = validateChanges(JSON.parse(await readFile(join(runDir, 'seed.json'), 'utf8')), request.implementation.policy);
84
+ if (changesDigest(seed) !== request.seedDigest) throw Error('Seed artifact identity mismatch');
85
+ for (const file of seed) if (file.content !== null) trackedSource.set(file.path, {});
86
+ await rm(source, { recursive: true, force: true }); await mkdir(source);
87
+ await materializeCandidate(baseline, seed, source);
88
+ }
89
+ await prepareImplementationWorkspace(source, config);
90
+ }
57
91
  const context = join(dir, 'context.json');
58
92
  await copyFile(join(runDir, 'context.json'), context); await chmod(context, 0o444);
59
93
  const prompt = join(dir, 'prompt.txt');
60
- await writeFile(prompt, await readFile(env.GH_AW_PROMPT), { mode: 0o444 });
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';
95
+ await writeFile(prompt, await readFile(env.GH_AW_PROMPT, 'utf8') + completionBoundary, { mode: 0o444 });
61
96
  const harness = resolve(dirname(fileURLToPath(import.meta.url)), '..');
62
97
  const pkg = JSON.parse(await readFile(join(harness, 'package.json'), 'utf8'));
63
- 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 }));
64
- const args = ['run', '--rm', '--name', name, '--user', `${config.uid}:${config.uid}`, '--cpus', '2', '--memory', '2g', '--pids-limit', '128', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--read-only',
65
- '--tmpfs', '/tmp:rw,size=256m,mode=1777', '--tmpfs', `/home/engine/.cache:rw,size=256m,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}`,
66
- '--mount', `type=bind,src=${source},dst=/workspace,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`,
67
- '--mount', `type=volume,src=${config.authVolume},dst=/home/engine/.local/share/opencode`, '--env', `ENGINE_SESSION=ses_${request.runId}`, '--env', `ENGINE_MODEL=${config.model}`, config.image];
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 }));
99
+ 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
+ '--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
+ '--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];
103
+ const eventsPath = join(env.RUNNER_TEMP, 'gh-aw', `opencode-events-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}.jsonl`);
104
+ await mkdir(dirname(eventsPath), { recursive: true });
105
+ await writeFile(eventsPath, '', { mode: 0o600 });
106
+ const started = Date.now();
107
+ let lastVisible = started;
108
+ const redact = text => redactLog(redactEnvironmentLog(runtime, text));
109
+ const report = text => { lastVisible = Date.now(); console.log(redact(text)); };
110
+ const eventLine = line => {
111
+ const redacted = redact(line);
112
+ appendFileSync(eventsPath, redacted + '\n');
113
+ const visible = formatOpenCodeEvent(redacted);
114
+ if (visible) report(visible);
115
+ };
116
+ report(progressText('Run', `${request.stage} for issue #${request.issue}. Public progress and tool activity appear below; structured events are retained as an artifact.`));
117
+ heartbeat = setInterval(() => {
118
+ if (Date.now() - lastVisible >= 30000) report(progressText('Run', `OpenCode is still running (${Math.floor((Date.now() - started) / 1000)}s elapsed). No new public update; waiting for the next event.`));
119
+ }, 10000);
68
120
  await new Promise((ok, no) => {
69
121
  child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
122
+ const consume = line => {
123
+ try {
124
+ eventLine(line);
125
+ } catch (error) { no(error); cancel(); }
126
+ };
70
127
  // Keep line boundaries across chunks so credential shapes cannot bypass redaction.
71
128
  for (const stream of [child.stdout, child.stderr]) {
72
- let pending = ''; stream.setEncoding('utf8');
73
- stream.on('data', chunk => { pending += chunk; let end; while ((end = pending.indexOf('\n')) >= 0) { process.stdout.write(redactLog(pending.slice(0, end + 1))); pending = pending.slice(end + 1); } if (pending.length > 1024 * 1024) { no(Error('Engine log line exceeds bound')); cancel(); } });
74
- stream.on('end', () => { if (pending) process.stdout.write(redactLog(pending) + '\n'); });
129
+ const lines = boundedEventLines(consume, () => {
130
+ eventLine(JSON.stringify({ type: 'diagnostic_omitted', reason: 'Event exceeded the 8 MiB character limit' }));
131
+ report(progressText('Run', 'One oversized diagnostic event was omitted. Execution continues; candidate files are collected separately.'));
132
+ });
133
+ stream.setEncoding('utf8');
134
+ stream.on('data', chunk => { if (!cancelled) lines.write(chunk); });
135
+ stream.on('end', () => { if (!cancelled) lines.end(); });
75
136
  }
76
- child.on('error', no); child.on('exit', code => code === 0 && !cancelled ? ok() : no(Error('OpenCode failed or was cancelled')));
77
- timer = setTimeout(cancel, 600000);
137
+ child.on('error', no); child.on('close', code => code === 0 && !cancelled ? ok() : no(Error('OpenCode failed or was cancelled')));
138
+ timer = setTimeout(cancel, implementing ? 1800000 : 600000);
78
139
  });
79
140
  const result = join(output, 'envelope.json'), stat = await lstat(result);
80
141
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128000) throw Error('Invalid engine result file');
81
- await writeFile(join(runDir, 'envelope.json'), JSON.stringify(JSON.parse(await readFile(result, 'utf8'))));
142
+ const envelope = JSON.parse(await readFile(result, 'utf8'));
143
+ clearTimeout(timer);
144
+ completionOutput(envelope, request);
145
+ if (implementing) {
146
+ const changes = collectChanges(baseline, await sourceFiles(source, trackedSource), request.implementation.policy);
147
+ await writeFile(join(runDir, 'changes.json'), JSON.stringify(changes));
148
+ await copyEvidence(join(output, 'evidence'), join(runDir, 'evidence', 'agent'));
149
+ const candidate = join(dir, 'candidate'); await mkdir(candidate);
150
+ await materializeCandidate(baseline, changes, candidate);
151
+ const checkBaseline = await sourceFiles(candidate);
152
+ if (runtime.cleanup() === false) throw Error('Agent test service cleanup failed; operator action required');
153
+ const receipt = await runImplementationChecks({ config, request, changes, workspace: candidate, output: join(runDir, 'evidence', 'host'), name, redact: redactLog, cancelled: () => cancelled, selected });
154
+ 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'; }
155
+ await writeFile(join(runDir, 'checks.json'), JSON.stringify(receipt));
156
+ if (request.implementation.policy.reviewRequired) {
157
+ envelope.result.status = 'blocked';
158
+ envelope.result.pendingReview.push('Repository policy requires owner acceptance of this candidate; engine assertions cannot grant it.');
159
+ }
160
+ if (!changes.length && envelope.result.status === 'passed') { envelope.result.status = 'blocked'; envelope.result.missingEvidence.push('No implementation changes were returned'); }
161
+ if (receipt.checks.some(c => c.exitCode !== 0)) {
162
+ envelope.result.status = 'blocked';
163
+ envelope.result.missingEvidence.push('The independent host checks did not all pass; inspect checks.json and host logs.');
164
+ envelope.effect.body += '\n\nIndependent host checks failed. This candidate requires corrections before completion.';
165
+ }
166
+ if (cancelled) throw Error('Implementation cancelled before publication');
167
+ }
168
+ const completion = completionOutput(envelope, request);
169
+ report(progressText('Result', redactLog(envelope.effect.body)));
170
+ report(progressText('Run', `Validated outcome: ${envelope.result.status}. The publisher will recheck authority before updating the issue.`));
171
+ await writeFile(join(runDir, 'envelope.json'), JSON.stringify(envelope));
172
+ // gh-aw requires a terminal safe output even when a separate job owns publication.
173
+ await mkdir(dirname(env.GH_AW_SAFE_OUTPUTS), { recursive: true });
174
+ await writeFile(env.GH_AW_SAFE_OUTPUTS, JSON.stringify(completion) + '\n');
175
+ } catch (error) {
176
+ if (implementing && baseline) {
177
+ try {
178
+ try { docker(['rm', '-f', name, `${name}-checks`]); } catch {}
179
+ const changes = collectChanges(baseline, await sourceFiles(source, trackedSource), request.implementation.policy);
180
+ await writeFile(join(runDir, 'changes.json'), JSON.stringify(changes));
181
+ await copyEvidence(join(output, 'evidence'), join(runDir, 'evidence', 'agent'));
182
+ console.error(progressText('Recovery', 'Retained bounded candidate changes and available evidence. This failed run cannot publish a PR.'));
183
+ } catch (recoveryError) { console.error(progressText('Recovery', redactLog(`Candidate could not be retained: ${recoveryError.message}`))); }
184
+ }
185
+ throw error;
82
186
  } finally {
83
- clearTimeout(timer); process.removeListener('SIGTERM', cancel); process.removeListener('SIGINT', cancel);
84
- try { docker(['rm', '-f', name]); } catch {}
187
+ clearTimeout(timer); clearInterval(heartbeat); process.removeListener('SIGTERM', cancel); process.removeListener('SIGINT', cancel);
188
+ try { docker(['rm', '-f', name, `${name}-checks`]); } catch {}
189
+ runtime?.cleanup();
85
190
  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'); }
86
191
  if (slot !== undefined) closeSync(slot);
192
+ if (implementing) {
193
+ try { docker(['run', '--rm', '--network', 'none', '--user', '0:0', '--mount', `type=bind,src=${dir},dst=/cleanup`, '--entrypoint', 'chown', config.image, '-R', `${process.getuid()}:${process.getgid()}`, '/cleanup']); } catch { console.error('Workspace ownership cleanup failed; operator inspection required'); }
194
+ }
87
195
  await rm(dir, { recursive: true, force: true });
88
196
  }
89
197
  }
90
- if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(error => { console.error(redactLog(error.message)); process.exitCode = 1; });
198
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(error => { console.error(progressText('Engine error', redactLog(error.message))); process.exitCode = 1; });
@@ -0,0 +1,48 @@
1
+ // Human-facing projection; the redacted event stream remains the diagnostic record.
2
+ export function progressText(label, text) {
3
+ return String(text).replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '').replaceAll('##[', '# #[ ').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n').map(line => `[${label}] ${line}`).join('\n');
4
+ }
5
+
6
+ export function formatOpenCodeEvent(line) {
7
+ let event;
8
+ try { event = JSON.parse(line); }
9
+ catch {
10
+ if (/\b(error|warn|fatal)\b/i.test(line)) return progressText('Engine', line.slice(0, 500));
11
+ return null;
12
+ }
13
+ if (!event || typeof event !== 'object') return null;
14
+ const part = event.part ?? {};
15
+ if (event.type === 'text' && typeof part.text === 'string') return progressText('OpenCode', part.text);
16
+ if (event.type === 'tool_use') {
17
+ const state = part.state ?? {}, input = state.input ?? {};
18
+ const patchPaths = typeof input.patchText === 'string' ? [...input.patchText.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)].map(m => m[1]) : [];
19
+ const target = input.path ?? input.filePath ?? input.file_path ?? (part.tool === 'shell' ? input.command?.split('\n')[0] : patchPaths.length ? patchPaths.slice(0, 3).join(', ') + (patchPaths.length > 3 ? ` (+${patchPaths.length - 3} files)` : '') : undefined);
20
+ const description = `${part.tool ?? 'tool'}${typeof target === 'string' ? ` ${target}` : ''}`.slice(0, 240);
21
+ if (state.status === 'completed') return progressText('Tool', `${description} — completed`);
22
+ if (state.status === 'error') return progressText('Tool error', `${description}: ${String(state.error ?? 'see diagnostic events').slice(0, 500)}`);
23
+ if (state.status === 'running') return progressText('Tool', `${description} — running`);
24
+ }
25
+ if (event.type === 'error') return progressText('Engine error', String(event.error?.data?.message ?? event.error?.message ?? 'OpenCode reported an error; see diagnostic events.').slice(0, 500));
26
+ return null;
27
+ }
28
+
29
+ // Bound diagnostics independently of the execution; a large patch event is not a failed task.
30
+ export function boundedEventLines(consume, omitted, limit = 8 * 1024 * 1024) {
31
+ let pending = '', dropping = false;
32
+ const finish = () => { if (dropping) omitted(); else if (pending) consume(pending); pending = ''; dropping = false; };
33
+ return {
34
+ write(chunk) {
35
+ let start = 0;
36
+ while (start < chunk.length) {
37
+ const end = chunk.indexOf('\n', start), piece = chunk.slice(start, end < 0 ? undefined : end);
38
+ if (!dropping) {
39
+ if (pending.length + piece.length > limit) { pending = ''; dropping = true; }
40
+ else pending += piece;
41
+ }
42
+ if (end < 0) break;
43
+ finish(); start = end + 1;
44
+ }
45
+ },
46
+ end: finish,
47
+ };
48
+ }
@@ -0,0 +1,90 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { evidenceProtection } from './completion.mjs';
3
+ import { buildEvidenceIndex, validateEvidenceRecord, validateEvidenceIndex } from './evidence.mjs';
4
+
5
+ const digest = value => createHash('sha256').update(value).digest('hex');
6
+ const blobSha = content => createHash('sha1').update(`blob ${Buffer.byteLength(content)}\0`).update(content).digest('hex');
7
+ const json = value => JSON.stringify(value, null, 2) + '\n';
8
+ export function validatePublishable(files) {
9
+ let bytes = 0;
10
+ if (!files || Object.keys(files).length > 32) throw Error('Evidence file count exceeds bounds');
11
+ for (const [path, content] of Object.entries(files)) {
12
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(path) || path.split('/').some(p => !p || ['.', '..', '.git'].includes(p)) || typeof content !== 'string') throw Error('Unsafe evidence path or payload');
13
+ bytes += Buffer.byteLength(content);
14
+ if (Buffer.byteLength(content) > 1024 * 1024 || bytes > 4 * 1024 * 1024) throw Error('Evidence payload exceeds bounds');
15
+ if (/\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{15,}|-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----|Bearer\s+[A-Za-z0-9_.-]{10,}|\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|https?:\/\/[^\s<>"\)]+[?][^\s<>"\)]*(?:token|signature|credential|sig|X-Amz-)[=]/i.test(content)) throw Error('Credential-shaped or signed content is not publishable');
16
+ }
17
+ return files;
18
+ }
19
+ export function completionBundle(request, envelope, receipt) {
20
+ const observation = request.completion;
21
+ const priorIntegration = request.priorIntegration ? Object.fromEntries(['evidenceId', 'commit', 'path', 'recordSha256', 'url', 'workflow'].map(key => [key, request.priorIntegration[key]])) : null;
22
+ const files = {
23
+ 'report.md': `# ${request.stage} result\n\n[Work item](${observation.issueUrl}) · [PR](${observation.prUrl}) · [Actions run](${request.runUrl})\n\nAccepted head: \`${observation.binding.head}\`. Base observed: \`${observation.binding.base}\`.\n\nOutcome: ${receipt.state}. Merged revision: ${receipt.sha ?? 'not merged'}.\n\n${envelope.effect.body}\n\n## Verification\n\n${observation.checks.map(c => `- ${c.context}: ${c.state ?? c.status} at \`${c.revision}\`.`).join('\n')}\n\n## Post-merge checks\n\n${receipt.postMerge?.map(c => `- ${c.workflow}: ${c.state ?? c.status}${c.url ? ` ([run](${c.url}))` : ''}.`).join('\n') || 'Not performed by this invocation.'}\n\n## Limits\n\nThe agent assessment is separate from authenticated GitHub check results. This bundle retains reports and receipts; raw sessions and earlier screenshots remain in their original evidence locations. No additional test or visual acceptance is implied.\n`,
24
+ 'retrospective.md': envelope.result.output?.retrospective ?? '# Retrospective\n\nNo retrospective finding was established in this stage.\n',
25
+ 'receipt.json': json(receipt),
26
+ 'stage-result.json': json(envelope.result),
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: { ...(request.dispatchOrigin ? { dispatchOrigin: request.dispatchOrigin } : {}), runId: request.workflowRunId, attempt: request.attempt, url: request.runUrl, harnessRevision: request.harnessRevision } }, nextAction: envelope.result.nextAction };
29
+ const errors = validateEvidenceRecord(record); if (errors.length) throw Error(JSON.stringify(errors));
30
+ files['evidence-record.json'] = JSON.stringify(record);
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' } });
32
+ if (buildErrors.length) throw Error(JSON.stringify(buildErrors));
33
+ const indexErrors = validateEvidenceIndex(index); if (indexErrors.length) throw Error(JSON.stringify(indexErrors));
34
+ files['evidence-index.json'] = json(index);
35
+ return validatePublishable(files);
36
+ }
37
+ export async function publishProcessBundle(api, request, files, beforeWrite = async () => {}) {
38
+ validatePublishable(files);
39
+ const branch = request.completion.profile.evidenceBranch, issue = request.issue;
40
+ if (!Number.isSafeInteger(issue) || issue < 1 || !/^\d+$/.test(request.workflowRunId) || !/^\d+$/.test(request.attempt) || !['integrate', 'review'].includes(request.stage)) throw Error('Invalid publication identity');
41
+ const date = request.observedAt.slice(0, 10).replaceAll('-', '');
42
+ if (!/^\d{8}$/.test(date)) throw Error('Invalid publication date');
43
+ const slug = request.title.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 64).replace(/-$/, '') || 'change';
44
+ const phase = request.stage === 'review' || request.completion.merged ? 'post-integration' : 'pr-ready';
45
+ const relative = `runs/${request.workflowRunId}/attempt-${request.attempt.padStart(4, '0')}/${phase}`;
46
+ for (let attempt = 0; attempt < 3; attempt++) {
47
+ await evidenceProtection(api, branch, request.completion.profile.completion.protectionObservations);
48
+ const ref = await api(`git/ref/heads/${branch}`), tip = ref.object.sha;
49
+ const tree = await api(`git/trees/${tip}?recursive=1`);
50
+ if (tree.truncated || tree.tree.length > 10000) throw Error('Evidence tree exceeds bounds');
51
+ const paths = new Map(tree.tree.map(f => [f.path, f]));
52
+ const roots = tree.tree.filter(f => f.type === 'tree' && new RegExp(`^docs/process/\\d{8}-${issue}-[a-z0-9-]+$`).test(f.path)).map(f => f.path);
53
+ if (roots.length > 1) throw Error('Ambiguous work-item evidence mapping');
54
+ const root = roots[0] ?? `docs/process/${date}-${issue}-${slug}`, prefix = `${root}/${relative}`;
55
+ for (const path of [prefix, root]) {
56
+ const parts = path.split('/');
57
+ for (let i = 1; i <= parts.length; i++) {
58
+ const ancestor = paths.get(parts.slice(0, i).join('/'));
59
+ if (ancestor && ancestor.type !== 'tree') throw Error('Evidence ancestor must be a directory');
60
+ }
61
+ }
62
+ const desired = Object.fromEntries(Object.entries(files).map(([name, content]) => [`${prefix}/${name}`, content]));
63
+ const existing = [...paths.keys()].filter(p => p.startsWith(`${prefix}/`) && paths.get(p).type !== 'tree');
64
+ const url = commit => `https://github.com/${request.completion.binding.repository}/blob/${commit}/${prefix}/report.md`;
65
+ if (existing.length) {
66
+ if (existing.length !== Object.keys(desired).length || existing.some(p => paths.get(p).mode !== '100644' || paths.get(p).type !== 'blob' || paths.get(p).sha !== blobSha(desired[p] ?? ''))) throw Error('Conflicting immutable evidence bundle');
67
+ return { commit: tip, url: url(tip), duplicate: true };
68
+ }
69
+ const indexPath = `${root}/README.md`, prior = paths.get(indexPath);
70
+ if (prior && (prior.type !== 'blob' || prior.mode !== '100644')) throw Error('Evidence index must be a regular file');
71
+ const old = prior ? await api(`git/blobs/${prior.sha}`) : null;
72
+ if (old && (old.encoding !== 'base64' || old.size > 1024 * 1024)) throw Error('Evidence index exceeds bounds');
73
+ const content = old ? Buffer.from(old.content, 'base64').toString() : `# Process evidence for issue #${issue}\n\n[Work item](${request.completion.issueUrl}). Publication dates use UTC. Earlier attempts remain available.\n`;
74
+ desired[indexPath] = `${content.trimEnd()}\n\n- [${request.stage}, run ${request.workflowRunId}, attempt ${request.attempt}](${relative}/report.md)\n`;
75
+ validatePublishable(desired);
76
+ const entries = [];
77
+ for (const [path, content] of Object.entries(desired)) entries.push({ path, mode: '100644', type: 'blob', sha: (await api('git/blobs', 'POST', { content, encoding: 'utf-8' })).sha });
78
+ const nextTree = await api('git/trees', 'POST', { base_tree: tree.sha, tree: entries });
79
+ const commit = await api('git/commits', 'POST', { message: `docs: retain ${request.stage} evidence for #${issue}`, tree: nextTree.sha, parents: [tip] });
80
+ await beforeWrite(); await evidenceProtection(api, branch, request.completion.profile.completion.protectionObservations);
81
+ try {
82
+ await api(`git/refs/heads/${branch}`, 'PATCH', { sha: commit.sha, force: false });
83
+ return { commit: commit.sha, url: url(commit.sha), duplicate: false };
84
+ } catch (error) {
85
+ // Only a changed remote tip justifies rebuilding; the next pass also detects a lost successful response.
86
+ if ((await api(`git/ref/heads/${branch}`)).object.sha === tip) throw error;
87
+ }
88
+ }
89
+ throw Error('Evidence branch remained busy; retry the same publication');
90
+ }
@@ -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
+ }
@@ -0,0 +1,110 @@
1
+ import { loadDispatch, checkDispatchClaim } from './stage-dispatch.mjs';
2
+ import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+
7
+ export const changeName = issue => {
8
+ if (!Number.isSafeInteger(issue) || issue < 1) throw Error('Invalid proposal issue');
9
+ return `issue-${issue}-proposal`;
10
+ };
11
+ export const proposalRoot = issue => `openspec/changes/${changeName(issue)}`;
12
+
13
+ export function validateProposalFiles(files, issue) {
14
+ const root = proposalRoot(issue), seen = new Set();
15
+ if (!Array.isArray(files) || files.length < 4 || files.length > 24) throw Error('Proposal needs 4-24 bounded Markdown files');
16
+ let bytes = 0;
17
+ for (const file of files) {
18
+ if (!file || Object.keys(file).sort().join(',') !== 'content,path' || typeof file.path !== 'string' || typeof file.content !== 'string') throw Error('Invalid proposal file');
19
+ const relative = file.path.slice(root.length + 1);
20
+ if (!file.path.startsWith(`${root}/`) || !/^(proposal\.md|design\.md|tasks\.md|specs\/[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*\/spec\.md)$/.test(relative)) throw Error('Proposal write outside authorized Markdown paths');
21
+ if (seen.has(file.path) || !file.content.trim() || file.content.includes('\0')) throw Error('Duplicate or empty proposal file');
22
+ seen.add(file.path); bytes += Buffer.byteLength(file.content);
23
+ if (bytes > 96000) throw Error('Proposal exceeds 96000 bytes');
24
+ }
25
+ for (const name of ['proposal.md', 'design.md', 'tasks.md']) if (!seen.has(`${root}/${name}`)) throw Error(`Missing ${name}`);
26
+ if (![...seen].some(path => path.startsWith(`${root}/specs/`))) throw Error('Missing specification');
27
+ const tasks = files.find(file => file.path === `${root}/tasks.md`).content;
28
+ if (!/^- \[ \] /m.test(tasks) || /^\s*[-*] \[[xX]\]/m.test(tasks)) throw Error('Proposal tasks must be pending implementation');
29
+ return files;
30
+ }
31
+
32
+ const openspec = (cwd, args) => execFileSync('npx', ['--yes', '--package', '@fission-ai/openspec@1.15.0', 'openspec', ...args], { cwd, encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024, env: { ...process.env, OPENSPEC_TELEMETRY: '0', CI: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] });
33
+
34
+ // Use the provider's scaffold, instructions and validator; never maintain a copied schema.
35
+ export async function prepareProposal(issue, files, provider = openspec) {
36
+ if (files) validateProposalFiles(files, issue);
37
+ const dir = await mkdtemp(join(tmpdir(), 'lighthouse-proposal-'));
38
+ const name = changeName(issue);
39
+ try {
40
+ provider(dir, ['new', 'change', name, '--schema', 'spec-driven', '--json']);
41
+ if (!files) {
42
+ const instructions = {};
43
+ for (const artifact of ['proposal', 'specs', 'design', 'tasks']) {
44
+ const value = JSON.parse(provider(dir, ['instructions', artifact, '--change', name, '--json']));
45
+ instructions[artifact] = { outputPath: value.outputPath, instruction: value.instruction, template: value.template };
46
+ }
47
+ return { provider: '@fission-ai/openspec@1.15.0', schema: 'spec-driven', root: proposalRoot(issue), instructions };
48
+ }
49
+ for (const file of files) { const path = join(dir, file.path); await mkdir(dirname(path), { recursive: true }); await writeFile(path, file.content); }
50
+ provider(dir, ['validate', name, '--strict', '--no-interactive']);
51
+ const status = JSON.parse(provider(dir, ['status', '--change', name, '--json']));
52
+ if (!status.isPlanningComplete) throw Error('OpenSpec planning artifacts are incomplete');
53
+ const metadata = `${proposalRoot(issue)}/.openspec.yaml`;
54
+ return [...files, { path: metadata, content: await readFile(join(dir, metadata), 'utf8') }];
55
+ } finally { await rm(dir, { recursive: true, force: true }); }
56
+ }
57
+
58
+ export function checkRefinement(predecessor, envelope, run, current, marker, dispatched = false) {
59
+ if (run.status !== 'completed' || run.conclusion !== 'success' || (dispatched ? run.path !== '.github/workflows/lighthouse-sdlc-refine.lock.yml' : run.event !== 'issues' && run.event !== 'issue_comment' && run.event !== 'workflow_dispatch' || run.path !== '.github/workflows/lighthouse-sdlc-router.yml' || run.actor?.login !== current.owner)) throw Error('Predecessor is not a successful owner workflow');
60
+ if (String(run.id) !== String(predecessor.workflowRunId) || run.head_sha !== (dispatched ? predecessor.workflowRevision : predecessor.sourceRevision) || predecessor.stage !== 'refine' || predecessor.issue !== current.issue || envelope.result?.stage !== 'refine' || envelope.result?.status !== 'passed' || envelope.result?.gate !== 'ready' || envelope.result?.runId !== predecessor.runId || marker !== predecessor.runId) throw Error('Refinement evidence identity mismatch');
61
+ if (predecessor.sourceRevision !== current.sourceRevision || predecessor.taskDigest !== current.taskDigest) throw Error('Refinement is stale; run refine against the current source and owner intent first');
62
+ }
63
+
64
+ export async function loadStageReport(api, repo, report, current) {
65
+ const marker = report?.body.match(/<!-- lighthouse-actions:([a-f0-9]{64}) -->/)?.[1];
66
+ const runId = report?.body.match(/\[Workflow run\]\(https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)\)/)?.[1];
67
+ if (!marker || !runId) throw Error('No authenticated refinement report; run refine first');
68
+ const run = await api(`actions/runs/${runId}`);
69
+ const artifacts = await api(`actions/runs/${runId}/artifacts?per_page=100`);
70
+ const dir = await mkdtemp(join(tmpdir(), 'lighthouse-predecessor-'));
71
+ try {
72
+ const values = {};
73
+ for (const [kind, file] of [['input', 'request.json'], ['result', 'envelope.json']]) {
74
+ const artifact = artifacts.artifacts.find(a => a.name === `lighthouse-${/<!-- lighthouse-stage:implement:/.test(report.body) ? 'implement-' : ''}${kind}-${current.issue}-${run.run_attempt}` && !a.expired);
75
+ if (!artifact || artifact.size_in_bytes > 2 * 1024 * 1024) throw Error('Refinement artifact unavailable or too large; run refine first');
76
+ const archive = join(dir, `${kind}.zip`);
77
+ const bytes = execFileSync('gh', ['api', `repos/${repo}/actions/artifacts/${artifact.id}/zip`], { maxBuffer: 2 * 1024 * 1024, timeout: 30000 });
78
+ await writeFile(archive, bytes);
79
+ values[kind] = JSON.parse(execFileSync('python3', ['-c', 'import sys,zipfile; z=zipfile.ZipFile(sys.argv[1]); matches=[i for i in z.infolist() if i.filename==sys.argv[2]]; assert len(matches)==1 and matches[0].file_size<=128000, "Invalid artifact member"; data=z.open(matches[0]).read(128001); assert len(data)<=128000; sys.stdout.buffer.write(data)', archive, file], { encoding: 'utf8', maxBuffer: 128000, timeout: 10000 }));
80
+ }
81
+ const request = values.input;
82
+ if (request.dispatch) {
83
+ if (!['refine', 'propose', 'implement'].includes(request.stage) || !report.body.includes(`<!-- lighthouse-stage:${request.stage}:`)) throw Error('Unexpected dispatched stage');
84
+ const dispatch = await loadDispatch(api, repo, current.owner, current.issue, request.dispatch.commentId, run);
85
+ if (dispatch.request.stage !== request.stage || dispatch.request.id !== request.dispatch.requestId || dispatch.request.sourceRevision !== request.sourceRevision || dispatch.request.workflowRevision !== request.workflowRevision) throw Error('Dispatched report identity mismatch');
86
+ await checkDispatchClaim(api, repo, current.issue, request.dispatch, run.id);
87
+ } else if (!['issues', 'issue_comment', 'workflow_dispatch'].includes(run.event) || run.path !== '.github/workflows/lighthouse-sdlc-router.yml' || run.actor?.login !== current.owner || run.head_sha !== request.sourceRevision) throw Error('Stage report identity mismatch');
88
+ if (run.status !== 'completed' || run.conclusion !== 'success' || String(run.id) !== String(request.workflowRunId) || request.issue !== current.issue || marker !== request.runId) throw Error('Stage report identity mismatch');
89
+ return { request: values.input, envelope: values.result, report: report.html_url, run, marker };
90
+ } finally { await rm(dir, { recursive: true, force: true }); }
91
+ }
92
+
93
+ export async function loadRefinement(api, repo, reports, current) {
94
+ const report = [...reports].reverse().find(c => c.user.login === 'github-actions[bot]' && (/<!-- lighthouse-stage:refine:passed -->/.test(c.body) || /^## Refinement:/m.test(c.body)) && /<!-- lighthouse-actions:[a-f0-9]{64} -->/.test(c.body));
95
+ const evidence = await loadStageReport(api, repo, report, current);
96
+ checkRefinement(evidence.request, evidence.envelope, evidence.run, current, evidence.marker, !!evidence.request.dispatch);
97
+ return { request: evidence.request, envelope: evidence.envelope, report: evidence.report };
98
+ }
99
+
100
+ export async function createProposalPullRequest(api, snapshot, files, body, runUrl, beforeWrite) {
101
+ const branch = `lighthouse/propose-${snapshot.issue}-${snapshot.runId.slice(0, 12)}`;
102
+ // A fresh immutable branch per run avoids updating a human's or another run's work.
103
+ const commit = await api(`git/commits/${snapshot.sourceRevision}`);
104
+ const tree = await api('git/trees', 'POST', { base_tree: commit.tree.sha, tree: files.map(file => ({ path: file.path, mode: '100644', type: 'blob', content: file.content })) });
105
+ const created = await api('git/commits', 'POST', { message: `docs: propose issue #${snapshot.issue}`, tree: tree.sha, parents: [snapshot.sourceRevision] });
106
+ await beforeWrite();
107
+ await api('git/refs', 'POST', { ref: `refs/heads/${branch}`, sha: created.sha });
108
+ const pr = await api('pulls', 'POST', { title: `Proposal for #${snapshot.issue}: ${snapshot.title.slice(0, 160)}`, head: branch, base: snapshot.defaultBranch, body: `${body}\n\nRelated to #${snapshot.issue}. Proposal only; implementation requires a separate owner decision.\n\nOpenSpec 1.15.0 strict validation passed. [Execution evidence](${runUrl}).\n\n<!-- lighthouse-proposal:${snapshot.runId} -->` });
109
+ return pr;
110
+ }