@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
@@ -0,0 +1,155 @@
1
+ import { readFile, writeFile, mkdir, readdir, lstat, mkdtemp, rm } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { spawnSync, execFileSync } from 'node:child_process';
5
+ import { createHash } from 'node:crypto';
6
+ import { proposalRoot, changeName } from './proposal.mjs';
7
+ import { validateEnvironment } from './execution-environment.mjs';
8
+
9
+ const hash = value => createHash('sha256').update(value).digest('hex');
10
+ const safePath = path => typeof path === 'string' && path.length < 300 && !path.startsWith('/') && !path.includes('\\') && !path.split('/').some(p => !p || p === '.' || p === '..' || p === '.git');
11
+ const protectedPath = path => /^(?:\.lighthouse|\.agents|\.product|\.git|\.opencode)(?:\/|$)/.test(path) || /(?:^|\/)(?:\.env(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519)$/.test(path) || path.startsWith('.github/') || /(?:^|\/)(?:AGENTS|CLAUDE)\.md$/.test(path);
12
+ export const changesDigest = changes => hash(JSON.stringify(changes));
13
+ export function validateImplementationPolicy(policy, issue) {
14
+ if (!policy || Object.keys(policy).filter(k => !['seed', 'proposal'].includes(k)).sort().join(',') !== 'checks,paths,reviewRequired,scope' || typeof policy.reviewRequired !== 'boolean' || ('proposal' in policy && (!Number.isSafeInteger(policy.proposal) || policy.proposal < 1)) || typeof policy.scope !== 'string' || !policy.scope.trim() || policy.scope.length > 4000) throw Error('Invalid implementation policy');
15
+ if (!Array.isArray(policy.paths) || !policy.paths.length || policy.paths.length > 40 || policy.paths.some(p => p !== '*' && (!safePath(p.replace(/\/$/, '')) || protectedPath(p) || p.startsWith('openspec/') && p !== `${proposalRoot(issue)}/tasks.md` && p !== `${proposalRoot(issue)}/` || p.startsWith('.github/') && !/^\.github\/workflows\/[\w-]+\.ya?ml$/.test(p)))) throw Error('Implementation policy needs safe paths');
16
+ if (!Array.isArray(policy.checks) || !policy.checks.length || policy.checks.length > 12 || policy.checks.some(c => !Array.isArray(c) || !c.length || c.length > 30 || c.some(a => typeof a !== 'string' || !a || a.length > 1000 || /[\0\r\n]/.test(a)))) throw Error('Implementation policy needs bounded command argument arrays');
17
+ if ('seed' in policy && (!policy.seed || typeof policy.seed !== 'object' || Object.keys(policy.seed).sort().join(',') !== 'base,head' || !/^[a-f0-9]{40}$/.test(policy.seed.base ?? '') || !/^[a-f0-9]{40}$/.test(policy.seed.head ?? ''))) throw Error('Seed must identify immutable base and head commits');
18
+ return policy;
19
+ }
20
+ export const withinScope = (path, policy) => safePath(path) && !protectedPath(path) && policy.paths.some(p => p === '*' || (p.endsWith('/') ? path.startsWith(p) : path === p));
21
+
22
+ export async function loadImplementation(api, issue, owner, revision, defaultBranch) {
23
+ const config = await api(`contents/.lighthouse/implement.json?ref=${revision}`, 'GET', undefined, true);
24
+ const configured = config ? JSON.parse(Buffer.from(config.content, 'base64').toString()) : {};
25
+ let policy = configured[issue] ?? configured.defaults;
26
+ if (!policy) {
27
+ const manifest = await api(`contents/package.json?ref=${revision}`, 'GET', undefined, true);
28
+ const pkg = manifest ? JSON.parse(Buffer.from(manifest.content, 'base64').toString()) : {};
29
+ if (!pkg.scripts?.verify) throw Error('Configure repository implementation checks in .lighthouse/implement.json defaults, or provide npm run verify');
30
+ policy = { scope: 'Implement only this issue and its acceptance criteria.', paths: ['*'], checks: [['npm', 'ci', '--ignore-scripts'], ['npm', 'run', 'verify']], reviewRequired: false };
31
+ }
32
+ const planCheck = ['npx', '--yes', '--package=@fission-ai/openspec@1.15.0', 'openspec', 'validate', changeName(issue), '--strict', '--no-interactive'];
33
+ policy = validateImplementationPolicy(policy, issue);
34
+ policy = { ...policy, checks: [...policy.checks, planCheck] };
35
+ const environmentFile = await api(`contents/.lighthouse/environment.json?ref=${revision}`, 'GET', undefined, true);
36
+ const environment = environmentFile ? validateEnvironment(JSON.parse(Buffer.from(environmentFile.content, 'base64').toString())) : null;
37
+ return { policy, proposal: { root: proposalRoot(issue) }, ...(environment ? { environment } : {}) };
38
+ }
39
+
40
+ // A reviewed immutable seed recovers a failed attempt without updating its branch.
41
+ export async function loadImplementationSeed(api, policy, revision) {
42
+ if (!policy.seed) return null;
43
+ const { base, head } = policy.seed;
44
+ const commit = await api(`git/commits/${head}`);
45
+ if (commit.parents?.length !== 1 || commit.parents[0].sha !== base) throw Error('Seed must be one commit directly based on the declared revision');
46
+ const trees = [];
47
+ for (const sha of [base, head, revision]) {
48
+ const tree = await api(`git/trees/${sha}?recursive=1`);
49
+ if (tree.truncated || !Array.isArray(tree.tree) || tree.tree.length > 10000) throw Error('Seed tree exceeds bounds');
50
+ trees.push(new Map(tree.tree.filter(f => f.type !== 'tree').map(f => [f.path, f])));
51
+ }
52
+ const [before, after, current] = trees, changes = [];
53
+ let bytes = 0;
54
+ for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
55
+ const old = before.get(path), next = after.get(path), live = current.get(path);
56
+ if (old?.sha === next?.sha && old?.mode === next?.mode) continue;
57
+ if (!withinScope(path, policy) || old?.sha !== live?.sha || old?.mode !== live?.mode) throw Error('Seed conflicts with current source or write scope');
58
+ if ([old, next].filter(Boolean).some(f => f.type !== 'blob' || !['100644', '100755'].includes(f.mode))) throw Error('Seed contains a link or non-regular change');
59
+ if (changes.length >= 512) throw Error('Seed exceeds changed-file bound');
60
+ let content = null;
61
+ if (next) {
62
+ const blob = await api(`git/blobs/${next.sha}`);
63
+ if (blob.encoding !== 'base64' || blob.size > 4 * 1024 * 1024) throw Error('Seed blob exceeds bounds');
64
+ content = blob.content.replaceAll('\n', '');
65
+ bytes += Buffer.byteLength(content, 'base64');
66
+ if (bytes > 16 * 1024 * 1024) throw Error('Seed exceeds byte bounds');
67
+ }
68
+ changes.push({ path, mode: next?.mode ?? old.mode, content });
69
+ }
70
+ return validateChanges(changes, policy);
71
+ }
72
+
73
+ // Honor Git ignores for new output without hiding tracked edits or following source links.
74
+ export async function sourceFiles(root, baseline) {
75
+ const metadata = baseline ? await mkdtemp(join(tmpdir(), 'lighthouse-ignore-')) : null;
76
+ const gitEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_COUNT: '0' };
77
+ if (metadata) execFileSync('git', ['init', '--bare', '-q', metadata], { env: gitEnv });
78
+ const trackedDirectories = new Set();
79
+ for (const path of baseline?.keys() ?? []) { let parent = dirname(path); while (parent !== '.') { trackedDirectories.add(parent); parent = dirname(parent); } }
80
+ const files = new Map(); let bytes = 0;
81
+ async function visit(dir, relative = '') {
82
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
83
+ if (entry.name === '.git') continue;
84
+ const path = relative ? `${relative}/${entry.name}` : entry.name, target = join(dir, entry.name);
85
+ if (metadata && !baseline.has(path) && !trackedDirectories.has(path)) {
86
+ const ignored = spawnSync('git', ['--git-dir', metadata, '--work-tree', root, 'check-ignore', '--no-index', '-z', '--stdin'], { input: path + '\0', env: gitEnv, timeout: 10000, maxBuffer: 4096 });
87
+ if (ignored.status === 0) continue;
88
+ if (ignored.status !== 1) throw Error('Unable to resolve ignored implementation output');
89
+ }
90
+ const stat = await lstat(target);
91
+ if (!safePath(path) || stat.isSymbolicLink() || !stat.isDirectory() && !stat.isFile()) throw Error(`Unsafe implementation file: ${path}`);
92
+ if (stat.isDirectory()) await visit(target, path);
93
+ else {
94
+ bytes += stat.size;
95
+ if (bytes > 64 * 1024 * 1024 || files.size >= 5000) throw Error('Implementation source exceeds bounds');
96
+ files.set(path, { content: await readFile(target), mode: stat.mode & 0o111 ? '100755' : '100644' });
97
+ }
98
+ }
99
+ }
100
+ try { await visit(root); return files; } finally { if (metadata) await rm(metadata, { recursive: true, force: true }); }
101
+ }
102
+ export function collectChanges(before, after, policy) {
103
+ const changes = [];
104
+ for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
105
+ const old = before.get(path), current = after.get(path);
106
+ if (old && current && old.content.equals(current.content) && old.mode === current.mode) continue;
107
+ changes.push({ path, mode: current?.mode ?? old?.mode ?? '100644', content: current ? current.content.toString('base64') : null });
108
+ }
109
+ return validateChanges(changes, policy);
110
+ }
111
+ export function validateChanges(changes, policy) {
112
+ if (!Array.isArray(changes) || changes.length > 512) throw Error('Implementation exceeds 512 changed files');
113
+ const seen = new Set(); let bytes = 0;
114
+ for (const file of changes) {
115
+ if (!file || Object.keys(file).sort().join(',') !== 'content,mode,path' || !withinScope(file.path, policy) || seen.has(file.path) || !['100644', '100755'].includes(file.mode) || file.content !== null && (typeof file.content !== 'string' || Buffer.from(file.content, 'base64').toString('base64') !== file.content)) throw Error('Invalid or out-of-scope implementation change');
116
+ seen.add(file.path);
117
+ const buffer = Buffer.from(file.content ?? '', 'base64'); bytes += buffer.length;
118
+ if (buffer.length > 4 * 1024 * 1024 || bytes > 16 * 1024 * 1024) throw Error('Implementation exceeds byte bounds');
119
+ if (/\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{15,}|-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----/.test(buffer.toString())) throw Error(`Credential-shaped content rejected in ${file.path}`);
120
+ }
121
+ return changes;
122
+ }
123
+ export async function materializeCandidate(before, changes, destination) {
124
+ const files = new Map(before);
125
+ for (const file of changes) if (file.content === null) files.delete(file.path); else files.set(file.path, { content: Buffer.from(file.content, 'base64'), mode: file.mode });
126
+ for (const [path, file] of files) { await mkdir(dirname(join(destination, path)), { recursive: true, mode: 0o777 }); await writeFile(join(destination, path), file.content, { mode: file.mode === '100755' ? 0o777 : 0o666 }); }
127
+ }
128
+ export function validateCheckReceipt(receipt, request, changes) {
129
+ if (receipt?.runId !== request.runId || receipt.sourceRevision !== request.sourceRevision || receipt.changesDigest !== changesDigest(changes) || !Array.isArray(receipt.checks) || receipt.checks.length !== request.implementation.policy.checks.length) throw Error('Implementation check receipt identity mismatch');
130
+ for (let i = 0; i < receipt.checks.length; i++) if (JSON.stringify(receipt.checks[i].command) !== JSON.stringify(request.implementation.policy.checks[i]) || !Number.isInteger(receipt.checks[i].exitCode)) throw Error('Implementation check receipt command mismatch');
131
+ if (request.implementation.environment) {
132
+ const declaration = validateEnvironment(request.implementation.environment);
133
+ if (!/^sha256:[a-f0-9]{64}$/.test(receipt.image ?? '') || receipt.environment?.image !== receipt.image || receipt.environment?.profile !== declaration.profile || !Array.isArray(receipt.environment.services) || receipt.environment.services.length !== declaration.services.length || receipt.environment.services.some(s => s.type !== 'postgresql' || !/^sha256:[a-f0-9]{64}$/.test(s.image ?? ''))) throw Error('Implementation environment receipt mismatch');
134
+ }
135
+ return receipt.serviceCleanup !== false && receipt.checks.every(c => c.exitCode === 0);
136
+ }
137
+ export async function createImplementationPullRequest(api, snapshot, changes, body, runUrl, draft, beforeWrite) {
138
+ validateChanges(changes, snapshot.implementation.policy);
139
+ if (!changes.length) throw Error('No implementation changes to publish');
140
+ const commit = await api(`git/commits/${snapshot.sourceRevision}`), entries = [];
141
+ for (const file of changes) {
142
+ const sha = file.content === null ? null : (await api('git/blobs', 'POST', { content: file.content, encoding: 'base64' })).sha;
143
+ entries.push({ path: file.path, mode: file.mode, type: 'blob', sha });
144
+ }
145
+ const tree = await api('git/trees', 'POST', { base_tree: commit.tree.sha, tree: entries });
146
+ const created = await api('git/commits', 'POST', { message: `feat: implement issue #${snapshot.issue}`, tree: tree.sha, parents: [snapshot.sourceRevision] });
147
+ await beforeWrite();
148
+ if (snapshot.implementationReview) {
149
+ await api(`git/refs/heads/${snapshot.implementationReview.branch}`, 'PATCH', { sha: created.sha, force: false });
150
+ return { number: snapshot.implementationReview.number, html_url: snapshot.implementationReview.url, head: { sha: created.sha, ref: snapshot.implementationReview.branch } };
151
+ }
152
+ const branch = `lighthouse/implement-${snapshot.issue}-${snapshot.runId.slice(0, 12)}`;
153
+ await api('git/refs', 'POST', { ref: `refs/heads/${branch}`, sha: created.sha });
154
+ return api('pulls', 'POST', { title: snapshot.title.slice(0, 180), head: branch, base: snapshot.defaultBranch, draft, body: `${body}\n\nCloses #${snapshot.issue}. Planning artifacts and implementation are reviewed together in this PR.\n\n[Checks, screenshots and retrospective](${runUrl}). Comment here to request changes, or approve when ready.\n\n<!-- lighthouse-implementation:${snapshot.runId} -->` });
155
+ }
@@ -3,12 +3,21 @@ import { resolve, join, dirname } from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
 
5
5
  const [target, revision, ...flags] = process.argv.slice(2);
6
- if (!target || !/^[a-f0-9]{40}$/.test(revision ?? '') || flags.some(x => !['--apply', '--replace-reviewed'].includes(x))) throw Error('Usage: install-workflows <repository> <immutable-lighthouse-commit> [--apply] [--replace-reviewed]');
6
+ if (!target || !/^[a-f0-9]{40}$/.test(revision ?? '') || flags.some(x => !['--apply', '--replace-reviewed', '--with-propose', '--with-implement', '--with-completion', '--with-maintenance'].includes(x))) throw Error('Usage: install-workflows <repository> <immutable-lighthouse-commit> [--apply] [--replace-reviewed] [--with-propose] [--with-implement] [--with-completion] [--with-maintenance]');
7
7
  const apply = flags.includes('--apply'), replace = flags.includes('--replace-reviewed');
8
- const files = ['lighthouse-sdlc-router.yml', 'lighthouse-sdlc-refine.md', 'shared/lighthouse-opencode.md'];
8
+ const completion = flags.includes('--with-completion');
9
+ const implement = flags.includes('--with-implement') || completion;
10
+ const propose = flags.includes('--with-propose') || implement;
11
+ const files = [...(flags.includes('--with-maintenance') ? ['lighthouse-worker-maintenance.yml'] : []), 'lighthouse-sdlc-router.yml', 'lighthouse-sdlc-refine.md', 'shared/lighthouse-opencode.md', ...(propose ? ['lighthouse-sdlc-propose.md', 'lighthouse-sdlc-apply-review.md'] : []), ...(implement ? ['lighthouse-sdlc-implement.md'] : []), ...(completion ? ['lighthouse-sdlc-integrate.md', 'lighthouse-sdlc-review.md'] : [])];
9
12
  const root = resolve(target), planned = [];
10
13
  for (const name of files) {
11
- const content = (await readFile(new URL(`../workflows/${name}`, import.meta.url), 'utf8')).replaceAll('__LIGHTHOUSE_REF__', revision);
14
+ let content = (await readFile(new URL(`../workflows/${name}`, import.meta.url), 'utf8')).replaceAll('__LIGHTHOUSE_REF__', revision);
15
+ if (name === 'lighthouse-sdlc-router.yml') {
16
+ const enabled = ['refine', ...(propose ? ['propose'] : []), ...(implement ? ['implement'] : []), ...(completion ? ['integrate', 'review'] : [])];
17
+ content = content.replace('LIGHTHOUSE_ENABLED_STAGES: refine,propose,implement,integrate,review', `LIGHTHOUSE_ENABLED_STAGES: ${enabled.join(',')}`).replace('options: [refine, propose, implement, integrate, review]', `options: [${enabled.join(', ')}]`);
18
+ if (!implement) content = content.replace('LIGHTHOUSE_AUTO_IMPLEMENT: true', 'LIGHTHOUSE_AUTO_IMPLEMENT: false');
19
+ if (!completion) content = content.replaceAll('LIGHTHOUSE_COMPLETION_ENABLED: true', 'LIGHTHOUSE_COMPLETION_ENABLED: false');
20
+ }
12
21
  const path = join(root, '.github/workflows', name);
13
22
  const exists = await access(path).then(() => true, () => false);
14
23
  if (exists && await readFile(path, 'utf8') !== content && !replace) throw Error(`Existing workflow differs: ${path}; review the diff before --replace-reviewed`);
@@ -19,6 +28,6 @@ if (apply) {
19
28
  const compiler = execFileSync('gh', ['aw', '--version'], { encoding: 'utf8' }).trim();
20
29
  if (!/\bv?0\.93\.2\b/.test(compiler)) throw Error(`gh-aw v0.93.2 is required; found ${compiler}`);
21
30
  for (const { path, content } of planned) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, content); }
22
- execFileSync('gh', ['aw', 'compile', 'lighthouse-sdlc-refine'], { cwd: root, stdio: 'inherit' });
31
+ for (const stage of completion ? ['refine', 'propose', 'implement', 'integrate', 'review', 'apply-review'] : implement ? ['refine', 'propose', 'implement', 'apply-review'] : propose ? ['refine', 'propose', 'apply-review'] : ['refine']) execFileSync('gh', ['aw', 'compile', `lighthouse-sdlc-${stage}`], { cwd: root, stdio: 'inherit' });
23
32
  }
24
- console.log('Enabled worker: refine. Enable LIGHTHOUSE_AUTOMATION_ENABLED only after provisioning and reviewing the runner. Unselected workflows are never installed or deleted implicitly.');
33
+ console.log(`Enabled workers: ${completion ? 'refine, propose, implement, integrate, review, apply-review' : implement ? 'refine, propose, implement, apply-review' : propose ? 'refine, propose, apply-review' : 'refine'}. Enable LIGHTHOUSE_AUTOMATION_ENABLED only after provisioning and reviewing the runner. Unselected workflows are never installed or deleted implicitly.`);
@@ -0,0 +1,80 @@
1
+ import { verifyDispatchOrigin, stagePath } from './stage-dispatch.mjs';
2
+ import { authorizeCompletion } from './completion.mjs';
3
+ import { createHash } from 'node:crypto';
4
+ import { fingerprint, sha } from './completion.mjs';
5
+ import { validateEvidenceRecord, validateEvidenceIndex } from './evidence.mjs';
6
+
7
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
8
+
9
+ // Read retained integration evidence, not bot prose or the current review's future publication.
10
+ export async function readIntegrationEvidence(api, completion, pinned = null) {
11
+ const binding = completion.binding, owner = binding.repository.split('/')[0];
12
+ if (!completion.merged) return null;
13
+ const commit = pinned?.commit ?? (await api(`git/ref/heads/${completion.profile.evidenceBranch}`)).object.sha;
14
+ if (!sha(commit)) throw Error('Invalid retained evidence commit');
15
+ const tree = await api(`git/trees/${commit}?recursive=1`);
16
+ if (tree.truncated || tree.tree.length > 10000) throw Error('Retained evidence tree exceeds bounds');
17
+ const paths = new Map(tree.tree.map(f => [f.path, f]));
18
+ let bytes = 0;
19
+ const read = async path => {
20
+ const entry = paths.get(path);
21
+ if (entry?.type !== 'blob' || entry.mode !== '100644') throw Error('Retained evidence must be a regular file');
22
+ const parts = path.split('/');
23
+ for (let i = 1; i < parts.length; i++) if (paths.get(parts.slice(0, i).join('/'))?.type !== 'tree') throw Error('Invalid retained evidence ancestor');
24
+ const blob = await api(`git/blobs/${entry.sha}`);
25
+ if (blob.encoding !== 'base64' || blob.size > 1024 * 1024) throw Error('Retained evidence blob exceeds bounds');
26
+ const content = Buffer.from(blob.content, 'base64'); bytes += content.length;
27
+ if (content.length !== blob.size || bytes > 4 * 1024 * 1024 || createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex') !== entry.sha) throw Error('Retained evidence bytes do not match GitHub identity or bounds');
28
+ return content.toString();
29
+ };
30
+ const pattern = new RegExp(`^docs/process/\\d{8}-${binding.issue}-[a-z0-9-]+/runs/[1-9][0-9]*/attempt-[0-9]+/post-integration/evidence-record\\.json$`);
31
+ const candidates = [...paths.keys()].filter(p => pattern.test(p) && (!pinned || p === pinned.path)).sort().reverse();
32
+ if (candidates.length > 100) throw Error('Too many retained integration candidates');
33
+ for (const path of candidates) {
34
+ const raw = await read(path), record = JSON.parse(raw), payload = record.payload;
35
+ if (payload?.stage !== 'integrate' || record.status !== 'passed') continue;
36
+ if (['repository', 'issue', 'pr', 'head', 'intent', 'policy', 'prIntent', 'files'].some(k => payload.binding?.[k] !== binding[k])) continue;
37
+ if (validateEvidenceRecord(record).length) throw Error('Invalid retained integration record');
38
+ const prefix = path.slice(0, -'evidence-record.json'.length), index = JSON.parse(await read(prefix + 'evidence-index.json'));
39
+ if (validateEvidenceIndex(index).length || index.repository !== binding.repository || index.revision !== completion.merge || !index.records.some(r => r.id === record.id && r.path === 'evidence-record.json' && r.sha256 === digest(raw))) throw Error('Retained integration index does not bind the record');
40
+ const names = ['receipt.json', 'stage-result.json', 'report.md', 'retrospective.md'], files = {};
41
+ if (record.artifacts.length !== names.length) throw Error('Incomplete retained integration artifacts');
42
+ for (const name of names) {
43
+ const artifact = record.artifacts.find(a => a.path === name);
44
+ files[name] = await read(prefix + name);
45
+ if (!artifact || artifact.sha256 !== digest(files[name])) throw Error('Retained integration artifact hash mismatch');
46
+ }
47
+ const receipt = JSON.parse(files['receipt.json']), result = JSON.parse(files['stage-result.json']);
48
+ if (fingerprint(receipt) !== fingerprint(payload.receipt) || receipt.state !== 'merged' || receipt.sha !== completion.merge || receipt.acceptedHead !== binding.head || receipt.authorization?.merge !== true || receipt.authorization.head !== binding.head || receipt.authorization.issue !== binding.issue || receipt.authorization.pr !== binding.pr || result.stage !== 'integrate' || result.status !== 'passed') throw Error('Retained integration receipt does not match the accepted delivery');
49
+ const workflow = payload.workflow;
50
+ if (!/^[1-9][0-9]*$/.test(workflow?.runId ?? '') || !/^[1-9][0-9]*$/.test(workflow?.attempt ?? '')) throw Error('Invalid retained workflow identity');
51
+ if (!path.includes(`/runs/${workflow.runId}/attempt-${String(workflow.attempt).padStart(4, '0')}/`)) throw Error('Retained workflow identity differs from its path');
52
+ const run = await api(`actions/runs/${workflow.runId}`);
53
+ let originEvent, originName;
54
+ if (workflow.dispatchOrigin) {
55
+ const origin = workflow.dispatchOrigin;
56
+ const parent = await api(`actions/runs/${origin.receipt.routerRunId}/attempts/${origin.receipt.routerAttempt}`);
57
+ verifyDispatchOrigin(origin.request, origin.receipt, parent, run, binding.repository, owner, binding.issue);
58
+ if (origin.request.stage !== 'integrate' || origin.request.sourceRevision !== payload.binding.base || String(origin.receipt.workerRunId) !== String(run.id)) throw Error('Retained integration dispatch mismatch');
59
+ originEvent = origin.request.event; originName = origin.request.eventName;
60
+ }
61
+ let manual = run.event === 'workflow_dispatch' && run.actor?.login === owner && run.triggering_actor?.login === owner && run.head_sha === payload.binding.base;
62
+ if (originEvent) {
63
+ manual = false;
64
+ if (originName === 'workflow_dispatch' && !originEvent.inputs?.after_run) {
65
+ const authorization = authorizeCompletion(originEvent, { GITHUB_EVENT_NAME: originName, GITHUB_ACTOR: owner, GITHUB_TRIGGERING_ACTOR: owner, GITHUB_REPOSITORY_OWNER: owner }, 'integrate');
66
+ manual = authorization.merge && authorization.issue === binding.issue && authorization.pr === binding.pr && authorization.head === binding.head;
67
+ }
68
+ }
69
+ let approved = false;
70
+ const approval = receipt.authorization.approval;
71
+ if ((['pull_request_review', 'workflow_run'].includes(originName ?? run.event) || originEvent?.inputs?.after_run) && approval?.owner === owner && approval.head === binding.head && Number.isSafeInteger(approval.id)) {
72
+ const review = await api(`pulls/${binding.pr}/reviews/${approval.id}`);
73
+ approved = review.user?.login === owner && review.state === 'APPROVED' && review.commit_id === binding.head;
74
+ }
75
+ if (run.repository?.full_name !== binding.repository || !(manual || approved) || run.path !== (workflow.dispatchOrigin ? stagePath('integrate') : '.github/workflows/lighthouse-sdlc-router.yml') || String(run.run_attempt) !== String(workflow.attempt) || run.status !== 'completed' || run.conclusion !== 'success') throw Error('Retained integration workflow is not authenticated and successful');
76
+ return { evidenceId: `integration:${workflow.runId}:${workflow.attempt}`, commit, path, recordSha256: digest(raw), url: `https://github.com/${binding.repository}/blob/${commit}/${prefix}report.md`, receipt, workflow: { ...workflow, conclusion: run.conclusion, actor: run.actor.login }, report: files['report.md'] };
77
+ }
78
+ if (pinned) throw Error('Previously supplied integration evidence is unavailable or stale');
79
+ return null;
80
+ }