@juangcarmona/lighthouse-sdlc 0.11.0 → 0.13.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 (34) hide show
  1. package/README.md +45 -9
  2. package/completion.md +48 -0
  3. package/host-contract.md +2 -0
  4. package/package.json +2 -1
  5. package/skills/integrate/SKILL.md +7 -1
  6. package/skills/propose/SKILL.md +1 -1
  7. package/skills/refine/SKILL.md +1 -1
  8. package/skills/review/SKILL.md +7 -1
  9. package/src/completion-actions.mjs +114 -0
  10. package/src/completion.mjs +169 -0
  11. package/src/github-profile.mjs +9 -1
  12. package/src/host-contract.mjs +8 -4
  13. package/src/implementation-runtime.mjs +66 -0
  14. package/src/implementation.mjs +143 -0
  15. package/src/install-workflows.mjs +30 -0
  16. package/src/integration-evidence.mjs +56 -0
  17. package/src/issue-actions.mjs +189 -0
  18. package/src/observe-protection.mjs +24 -0
  19. package/src/opencode-execute.mjs +188 -0
  20. package/src/opencode-progress.mjs +48 -0
  21. package/src/process-publication.mjs +90 -0
  22. package/src/proposal.mjs +96 -0
  23. package/workflows/lighthouse-sdlc-implement.lock.yml +251 -1085
  24. package/workflows/lighthouse-sdlc-implement.md +165 -29
  25. package/workflows/lighthouse-sdlc-integrate.lock.yml +257 -1076
  26. package/workflows/lighthouse-sdlc-integrate.md +175 -29
  27. package/workflows/lighthouse-sdlc-propose.lock.yml +241 -1085
  28. package/workflows/lighthouse-sdlc-propose.md +155 -29
  29. package/workflows/lighthouse-sdlc-refine.lock.yml +239 -1085
  30. package/workflows/lighthouse-sdlc-refine.md +151 -29
  31. package/workflows/lighthouse-sdlc-review.lock.yml +257 -1076
  32. package/workflows/lighthouse-sdlc-review.md +175 -29
  33. package/workflows/lighthouse-sdlc-router.yml +84 -145
  34. package/workflows/shared/lighthouse-opencode.md +22 -0
@@ -0,0 +1,188 @@
1
+ // gh-aw engine adapter: one foreground container, no service or orchestration state.
2
+ import { readFile, writeFile, mkdir, mkdtemp, readdir, copyFile, chmod, rm, lstat } from 'node:fs/promises';
3
+ import { dirname, join, resolve, isAbsolute } from 'node:path';
4
+ import { openSync, closeSync, appendFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawn, execFileSync } from 'node:child_process';
7
+ import { sourceFiles, collectChanges, materializeCandidate, validateChanges, changesDigest } from './implementation.mjs';
8
+ import { writableTree, copyEvidence, runImplementationChecks } from './implementation-runtime.mjs';
9
+ import { validateStageEnvelope } from './host-contract.mjs';
10
+ import { formatOpenCodeEvent, progressText, boundedEventLines } from './opencode-progress.mjs';
11
+
12
+ export function completionOutput(envelope, request) {
13
+ const errors = validateStageEnvelope(envelope, request);
14
+ if (errors.length) throw Error(errors.join('\n'));
15
+ if (!['passed', 'blocked'].includes(envelope.result.status)) throw Error('Stage did not complete');
16
+ return { type: 'noop', message: `Lighthouse ${request.stage} result validated: ${envelope.result.status}. GitHub publication belongs to the separate publisher job.` };
17
+ }
18
+ export function redactLog(text) {
19
+ return text.replace(/Bearer\s+[^\s"']+/gi, 'Bearer [REDACTED]')
20
+ .replace(/\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{15,}/g, '[REDACTED]')
21
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[REDACTED]');
22
+ }
23
+ export async function snapshotSource(root, destination) {
24
+ const files = execFileSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8' }).split('\0').filter(Boolean);
25
+ if (files.length > 5000) throw Error('Source snapshot exceeds file bound');
26
+ let bytes = 0;
27
+ for (const file of files) {
28
+ if (file.split('/').some(part => part === '..' || part === '.git') || file.startsWith('/')) throw Error('Unsafe source path');
29
+ const source = join(root, file), stat = await lstat(source);
30
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
31
+ bytes += stat.size;
32
+ if (bytes > 32 * 1024 * 1024) throw Error('Source snapshot exceeds byte bound');
33
+ const target = join(destination, file);
34
+ await mkdir(dirname(target), { recursive: true }); await copyFile(source, target); await chmod(target, stat.mode & 0o111 ? 0o555 : 0o444);
35
+ }
36
+ }
37
+ export function acquireEngineSlot(path, seconds = 240) {
38
+ if (!isAbsolute(path ?? '')) throw Error('Operator engine lock must be an absolute path');
39
+ const fd = openSync(path, 'a', 0o600);
40
+ try {
41
+ // Linux flock belongs to the shared open file description, held until closeSync.
42
+ execFileSync('flock', ['--exclusive', '--wait', String(seconds), '3'], { stdio: ['ignore', 'inherit', 'inherit', fd] });
43
+ return fd;
44
+ } catch { closeSync(fd); throw Error('Host engine capacity unavailable'); }
45
+ }
46
+ async function main() {
47
+ const env = process.env;
48
+ const config = JSON.parse(await readFile(env.LIGHTHOUSE_ENGINE_CONFIG, 'utf8'));
49
+ if (!/^sha256:[a-f0-9]{64}$/.test(config.image) || !/^[\w-]+$/.test(config.authVolume) || !Number.isInteger(config.uid)) throw Error('Invalid operator engine configuration');
50
+ const runDir = join(env.RUNNER_TEMP, `lighthouse-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`);
51
+ const request = JSON.parse(await readFile(join(runDir, 'request.json'), 'utf8'));
52
+ if (request.stage === 'implement') {
53
+ if (!/^sha256:[a-f0-9]{64}$/.test(config.implementationImage ?? '')) throw Error('Operator must provision and pin an implementation image');
54
+ config.image = config.implementationImage;
55
+ }
56
+ if (!/^[a-f0-9]{64}$/.test(request.runId)) throw Error('Invalid execution identity');
57
+ const dir = await mkdtemp(join(env.RUNNER_TEMP, 'lighthouse-engine-'));
58
+ await chmod(dir, 0o755);
59
+ const source = join(dir, 'source'), output = join(dir, 'output');
60
+ await mkdir(source); await mkdir(output); await chmod(output, 0o777);
61
+ const implementing = request.stage === 'implement';
62
+ const name = `lighthouse-engine-${request.runId}`;
63
+ const docker = args => execFileSync('docker', args, { stdio: 'ignore', timeout: 30000 });
64
+ let child, timer, heartbeat, slot, baseline, trackedSource, cancelled = false;
65
+ const cancel = () => { cancelled = true; child?.kill('SIGTERM'); try { docker(['rm', '-f', name, `${name}-checks`]); } catch {} };
66
+ process.once('SIGTERM', cancel); process.once('SIGINT', cancel);
67
+ try {
68
+ console.log('Waiting for the operator host engine slot');
69
+ slot = acquireEngineSlot(config.lock);
70
+ console.log('Engine slot acquired; starting isolated OpenCode');
71
+ await snapshotSource(env.GITHUB_WORKSPACE, source);
72
+ baseline = implementing ? await sourceFiles(source) : null;
73
+ trackedSource = baseline ? new Map(baseline) : null;
74
+ if (implementing) {
75
+ if (request.seedDigest) {
76
+ const seed = validateChanges(JSON.parse(await readFile(join(runDir, 'seed.json'), 'utf8')), request.implementation.policy);
77
+ if (changesDigest(seed) !== request.seedDigest) throw Error('Seed artifact identity mismatch');
78
+ for (const file of seed) if (file.content !== null) trackedSource.set(file.path, {});
79
+ await rm(source, { recursive: true, force: true }); await mkdir(source);
80
+ await materializeCandidate(baseline, seed, source);
81
+ }
82
+ await writableTree(source);
83
+ }
84
+ const context = join(dir, 'context.json');
85
+ await copyFile(join(runDir, 'context.json'), context); await chmod(context, 0o444);
86
+ 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';
88
+ await writeFile(prompt, await readFile(env.GH_AW_PROMPT, 'utf8') + completionBoundary, { mode: 0o444 });
89
+ const harness = resolve(dirname(fileURLToPath(import.meta.url)), '..');
90
+ 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 }));
92
+ 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}`,
94
+ '--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];
96
+ const eventsPath = join(env.RUNNER_TEMP, 'gh-aw', `opencode-events-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}.jsonl`);
97
+ await mkdir(dirname(eventsPath), { recursive: true });
98
+ await writeFile(eventsPath, '', { mode: 0o600 });
99
+ const started = Date.now();
100
+ let lastVisible = started;
101
+ const report = text => { lastVisible = Date.now(); console.log(redactLog(text)); };
102
+ const eventLine = line => {
103
+ const redacted = redactLog(line);
104
+ appendFileSync(eventsPath, redacted + '\n');
105
+ const visible = formatOpenCodeEvent(redacted);
106
+ if (visible) report(visible);
107
+ };
108
+ report(progressText('Run', `${request.stage} for issue #${request.issue}. Public progress and tool activity appear below; structured events are retained as an artifact.`));
109
+ heartbeat = setInterval(() => {
110
+ 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.`));
111
+ }, 10000);
112
+ await new Promise((ok, no) => {
113
+ child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
114
+ const consume = line => {
115
+ try {
116
+ eventLine(line);
117
+ } catch (error) { no(error); cancel(); }
118
+ };
119
+ // Keep line boundaries across chunks so credential shapes cannot bypass redaction.
120
+ for (const stream of [child.stdout, child.stderr]) {
121
+ const lines = boundedEventLines(consume, () => {
122
+ eventLine(JSON.stringify({ type: 'diagnostic_omitted', reason: 'Event exceeded the 8 MiB character limit' }));
123
+ report(progressText('Run', 'One oversized diagnostic event was omitted. Execution continues; candidate files are collected separately.'));
124
+ });
125
+ stream.setEncoding('utf8');
126
+ stream.on('data', chunk => { if (!cancelled) lines.write(chunk); });
127
+ stream.on('end', () => { if (!cancelled) lines.end(); });
128
+ }
129
+ child.on('error', no); child.on('close', code => code === 0 && !cancelled ? ok() : no(Error('OpenCode failed or was cancelled')));
130
+ timer = setTimeout(cancel, implementing ? 1800000 : 600000);
131
+ });
132
+ const result = join(output, 'envelope.json'), stat = await lstat(result);
133
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128000) throw Error('Invalid engine result file');
134
+ const envelope = JSON.parse(await readFile(result, 'utf8'));
135
+ clearTimeout(timer);
136
+ completionOutput(envelope, request);
137
+ if (implementing) {
138
+ const changes = collectChanges(baseline, await sourceFiles(source, trackedSource), request.implementation.policy);
139
+ await writeFile(join(runDir, 'changes.json'), JSON.stringify(changes));
140
+ await copyEvidence(join(output, 'evidence'), join(runDir, 'evidence', 'agent'));
141
+ const candidate = join(dir, 'candidate'); await mkdir(candidate);
142
+ await materializeCandidate(baseline, changes, candidate);
143
+ 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 });
145
+ 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
+ await writeFile(join(runDir, 'checks.json'), JSON.stringify(receipt));
147
+ if (request.implementation.policy.reviewRequired) {
148
+ envelope.result.status = 'blocked';
149
+ envelope.result.pendingReview.push('Repository policy requires owner acceptance of this candidate; engine assertions cannot grant it.');
150
+ }
151
+ if (!changes.length && envelope.result.status === 'passed') { envelope.result.status = 'blocked'; envelope.result.missingEvidence.push('No implementation changes were returned'); }
152
+ if (receipt.checks.some(c => c.exitCode !== 0)) {
153
+ envelope.result.status = 'blocked';
154
+ envelope.result.missingEvidence.push('The independent host checks did not all pass; inspect checks.json and host logs.');
155
+ envelope.effect.body += '\n\nIndependent host checks failed. This candidate requires corrections before completion.';
156
+ }
157
+ if (cancelled) throw Error('Implementation cancelled before publication');
158
+ }
159
+ const completion = completionOutput(envelope, request);
160
+ report(progressText('Result', redactLog(envelope.effect.body)));
161
+ report(progressText('Run', `Validated outcome: ${envelope.result.status}. The publisher will recheck authority before updating the issue.`));
162
+ await writeFile(join(runDir, 'envelope.json'), JSON.stringify(envelope));
163
+ // gh-aw requires a terminal safe output even when a separate job owns publication.
164
+ await mkdir(dirname(env.GH_AW_SAFE_OUTPUTS), { recursive: true });
165
+ await writeFile(env.GH_AW_SAFE_OUTPUTS, JSON.stringify(completion) + '\n');
166
+ } catch (error) {
167
+ if (implementing && baseline) {
168
+ try {
169
+ try { docker(['rm', '-f', name, `${name}-checks`]); } catch {}
170
+ const changes = collectChanges(baseline, await sourceFiles(source, trackedSource), request.implementation.policy);
171
+ await writeFile(join(runDir, 'changes.json'), JSON.stringify(changes));
172
+ await copyEvidence(join(output, 'evidence'), join(runDir, 'evidence', 'agent'));
173
+ console.error(progressText('Recovery', 'Retained bounded candidate changes and available evidence. This failed run cannot publish a PR.'));
174
+ } catch (recoveryError) { console.error(progressText('Recovery', redactLog(`Candidate could not be retained: ${recoveryError.message}`))); }
175
+ }
176
+ throw error;
177
+ } finally {
178
+ clearTimeout(timer); clearInterval(heartbeat); process.removeListener('SIGTERM', cancel); process.removeListener('SIGINT', cancel);
179
+ try { docker(['rm', '-f', name, `${name}-checks`]); } catch {}
180
+ 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
+ if (slot !== undefined) closeSync(slot);
182
+ if (implementing) {
183
+ 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'); }
184
+ }
185
+ await rm(dir, { recursive: true, force: true });
186
+ }
187
+ }
188
+ 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: { 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,96 @@
1
+ import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+
6
+ export const changeName = issue => {
7
+ if (!Number.isSafeInteger(issue) || issue < 1) throw Error('Invalid proposal issue');
8
+ return `issue-${issue}-proposal`;
9
+ };
10
+ export const proposalRoot = issue => `openspec/changes/${changeName(issue)}`;
11
+
12
+ export function validateProposalFiles(files, issue) {
13
+ const root = proposalRoot(issue), seen = new Set();
14
+ if (!Array.isArray(files) || files.length < 4 || files.length > 24) throw Error('Proposal needs 4-24 bounded Markdown files');
15
+ let bytes = 0;
16
+ for (const file of files) {
17
+ if (!file || Object.keys(file).sort().join(',') !== 'content,path' || typeof file.path !== 'string' || typeof file.content !== 'string') throw Error('Invalid proposal file');
18
+ const relative = file.path.slice(root.length + 1);
19
+ 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');
20
+ if (seen.has(file.path) || !file.content.trim() || file.content.includes('\0')) throw Error('Duplicate or empty proposal file');
21
+ seen.add(file.path); bytes += Buffer.byteLength(file.content);
22
+ if (bytes > 96000) throw Error('Proposal exceeds 96000 bytes');
23
+ }
24
+ for (const name of ['proposal.md', 'design.md', 'tasks.md']) if (!seen.has(`${root}/${name}`)) throw Error(`Missing ${name}`);
25
+ if (![...seen].some(path => path.startsWith(`${root}/specs/`))) throw Error('Missing specification');
26
+ const tasks = files.find(file => file.path === `${root}/tasks.md`).content;
27
+ if (!/^- \[ \] /m.test(tasks) || /^\s*[-*] \[[xX]\]/m.test(tasks)) throw Error('Proposal tasks must be pending implementation');
28
+ return files;
29
+ }
30
+
31
+ 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'] });
32
+
33
+ // Use the provider's scaffold, instructions and validator; never maintain a copied schema.
34
+ export async function prepareProposal(issue, files, provider = openspec) {
35
+ if (files) validateProposalFiles(files, issue);
36
+ const dir = await mkdtemp(join(tmpdir(), 'lighthouse-proposal-'));
37
+ const name = changeName(issue);
38
+ try {
39
+ provider(dir, ['new', 'change', name, '--schema', 'spec-driven', '--json']);
40
+ if (!files) {
41
+ const instructions = {};
42
+ for (const artifact of ['proposal', 'specs', 'design', 'tasks']) {
43
+ const value = JSON.parse(provider(dir, ['instructions', artifact, '--change', name, '--json']));
44
+ instructions[artifact] = { outputPath: value.outputPath, instruction: value.instruction, template: value.template };
45
+ }
46
+ return { provider: '@fission-ai/openspec@1.15.0', schema: 'spec-driven', root: proposalRoot(issue), instructions };
47
+ }
48
+ for (const file of files) { const path = join(dir, file.path); await mkdir(dirname(path), { recursive: true }); await writeFile(path, file.content); }
49
+ provider(dir, ['validate', name, '--strict', '--no-interactive']);
50
+ const status = JSON.parse(provider(dir, ['status', '--change', name, '--json']));
51
+ if (!status.isPlanningComplete) throw Error('OpenSpec planning artifacts are incomplete');
52
+ const metadata = `${proposalRoot(issue)}/.openspec.yaml`;
53
+ return [...files, { path: metadata, content: await readFile(join(dir, metadata), 'utf8') }];
54
+ } finally { await rm(dir, { recursive: true, force: true }); }
55
+ }
56
+
57
+ export function checkRefinement(predecessor, envelope, run, current, marker) {
58
+ if (run.status !== 'completed' || run.conclusion !== 'success' || 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');
59
+ if (String(run.id) !== String(predecessor.workflowRunId) || run.head_sha !== 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');
60
+ if (predecessor.sourceRevision !== current.sourceRevision || predecessor.taskDigest !== current.taskDigest) throw Error('Refinement is stale; run refine against the current source and owner intent first');
61
+ }
62
+
63
+ export async function loadRefinement(api, repo, reports, current) {
64
+ const report = [...reports].reverse().find(c => c.user.login === 'github-actions[bot]' && /<!-- lighthouse-actions:[a-f0-9]{64} -->/.test(c.body));
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-${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
+ checkRefinement(values.input, values.result, run, current, marker);
82
+ return { request: values.input, envelope: values.result, report: report.html_url };
83
+ } finally { await rm(dir, { recursive: true, force: true }); }
84
+ }
85
+
86
+ export async function createProposalPullRequest(api, snapshot, files, body, runUrl, beforeWrite) {
87
+ const branch = `lighthouse/propose-${snapshot.issue}-${snapshot.runId.slice(0, 12)}`;
88
+ // A fresh immutable branch per run avoids updating a human's or another run's work.
89
+ const commit = await api(`git/commits/${snapshot.sourceRevision}`);
90
+ 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 })) });
91
+ const created = await api('git/commits', 'POST', { message: `docs: propose issue #${snapshot.issue}`, tree: tree.sha, parents: [snapshot.sourceRevision] });
92
+ await beforeWrite();
93
+ await api('git/refs', 'POST', { ref: `refs/heads/${branch}`, sha: created.sha });
94
+ 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} -->` });
95
+ return pr;
96
+ }