@juangcarmona/lighthouse-sdlc 0.15.1 → 0.16.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 (38) hide show
  1. package/README.md +6 -1
  2. package/execution-environments.md +23 -0
  3. package/github-actions.md +2 -0
  4. package/meta-commands/ff.md +2 -2
  5. package/meta-commands/hotfix.md +3 -1
  6. package/package.json +8 -3
  7. package/schemas/product-change.schema.json +118 -0
  8. package/skills/implement/SKILL.md +9 -1
  9. package/skills/independent-review/SKILL.md +13 -0
  10. package/skills/product-change/SKILL.md +18 -0
  11. package/src/completion.mjs +5 -3
  12. package/src/contracts.mjs +2 -0
  13. package/src/execution-resources.mjs +52 -0
  14. package/src/host-contract.mjs +11 -3
  15. package/src/implementation-runtime.mjs +37 -13
  16. package/src/implementation.mjs +2 -2
  17. package/src/install-workflows.mjs +17 -5
  18. package/src/integration-evidence.mjs +4 -1
  19. package/src/issue-actions.mjs +125 -18
  20. package/src/managed-command.mjs +82 -0
  21. package/src/opencode-execute.mjs +34 -19
  22. package/src/product-change-actions.mjs +308 -0
  23. package/src/product-change.mjs +277 -0
  24. package/src/proposal-review.mjs +1 -1
  25. package/src/proposal.mjs +4 -4
  26. package/src/sdlc.mjs +1 -1
  27. package/src/stage-dispatch.mjs +11 -3
  28. package/src/unattended-actions.mjs +287 -0
  29. package/src/unattended-runtime.mjs +115 -0
  30. package/src/unattended.mjs +161 -0
  31. package/templates/unattended.json +37 -0
  32. package/unattended.md +40 -0
  33. package/workflows/lighthouse-sdlc-apply-product-change.lock.yml +1838 -0
  34. package/workflows/lighthouse-sdlc-apply-product-change.md +183 -0
  35. package/workflows/lighthouse-sdlc-product-change.lock.yml +1329 -0
  36. package/workflows/lighthouse-sdlc-product-change.md +195 -0
  37. package/workflows/lighthouse-sdlc-router.yml +12 -2
  38. package/workflows/lighthouse-sdlc-unattended.yml +67 -0
@@ -1,15 +1,16 @@
1
1
  // gh-aw engine adapter: one foreground container and optional host-owned test services.
2
- import { readFile, writeFile, mkdir, mkdtemp, readdir, copyFile, chmod, rm, lstat } from 'node:fs/promises';
2
+ import { readFile, writeFile, appendFile, mkdir, mkdtemp, readdir, copyFile, chmod, rm, lstat } from 'node:fs/promises';
3
3
  import { dirname, join, resolve, isAbsolute } from 'node:path';
4
4
  import { openSync, closeSync, appendFileSync } from 'node:fs';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { spawn, execFileSync } from 'node:child_process';
7
7
  import { sourceFiles, collectChanges, materializeCandidate, validateChanges, changesDigest } from './implementation.mjs';
8
- import { prepareImplementationWorkspace, copyEvidence, runImplementationChecks } from './implementation-runtime.mjs';
8
+ import { prepareImplementationWorkspace, copyEvidence, runImplementationChecks, restoreWorkspaceOwnership } from './implementation-runtime.mjs';
9
9
  import { validateStageEnvelope } from './host-contract.mjs';
10
10
  import { formatOpenCodeEvent, progressText, boundedEventLines } from './opencode-progress.mjs';
11
11
  import { selectEnvironment, startEnvironment, environmentArgs, environmentReceipt, redactEnvironmentLog } from './execution-environment.mjs';
12
12
  import { resolveModelProfile, modelExecutionReceipt } from './model-profiles.mjs';
13
+ import { executionLimits, resourceArgs, imageCommand, executionReceipt, executionFailure } from './execution-resources.mjs';
13
14
 
14
15
  export function completionOutput(envelope, request) {
15
16
  const errors = validateStageEnvelope(envelope, request);
@@ -52,7 +53,8 @@ async function main() {
52
53
  const runDir = join(env.RUNNER_TEMP, `lighthouse-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`);
53
54
  const request = JSON.parse(await readFile(join(runDir, 'request.json'), 'utf8'));
54
55
  // Sequential workers share the runner temp directory; only this stage's outputs may survive.
55
- for (const file of ['envelope.json', 'runtime.json', 'changes.json', 'checks.json']) await rm(join(runDir, file), { force: true });
56
+ for (const file of ['envelope.json', 'runtime.json', 'changes.json', 'checks.json', 'engine-error.json']) await rm(join(runDir, file), { force: true });
57
+ const limits = executionLimits(config, request.stage === 'implement');
56
58
  let projectModels = null;
57
59
  try {
58
60
  const directory = join(env.GITHUB_WORKSPACE, '.lighthouse'), directoryStat = await lstat(directory);
@@ -62,7 +64,7 @@ async function main() {
62
64
  projectModels = JSON.parse(await readFile(path, 'utf8'));
63
65
  }
64
66
  catch (error) { if (error.code !== 'ENOENT') throw error; }
65
- const modelSelection = resolveModelProfile({ project: projectModels, operator: config, worker: env.LIGHTHOUSE_WORKER ?? request.stage });
67
+ const modelSelection = resolveModelProfile({ project: projectModels, operator: config, worker: env.LIGHTHOUSE_WORKER ?? request.stage, ...(env.LIGHTHOUSE_MODEL_PROFILE ? { profile: env.LIGHTHOUSE_MODEL_PROFILE } : {}) });
66
68
  const selected = request.stage === 'implement' ? selectEnvironment(config, request.implementation?.environment) : null;
67
69
  if (request.stage === 'implement') {
68
70
  if (selected) config.implementationImage = selected.image;
@@ -77,9 +79,10 @@ async function main() {
77
79
  const implementing = request.stage === 'implement';
78
80
  const name = `lighthouse-engine-${request.runId}`;
79
81
  const docker = args => execFileSync('docker', args, { stdio: 'ignore', timeout: 30000 });
80
- let child, timer, heartbeat, slot, baseline, trackedSource, runtime, cancelled = false;
81
- const cancel = () => { cancelled = true; child?.kill('SIGTERM'); try { docker(['rm', '-f', name, `${name}-checks`]); } catch {} };
82
- process.once('SIGTERM', cancel); process.once('SIGINT', cancel);
82
+ let child, timer, heartbeat, slot, baseline, trackedSource, runtime, cancelled = false, stopReason = null;
83
+ const cancel = (reason = 'cancelled') => { cancelled = true; stopReason ??= reason; child?.kill('SIGTERM'); try { docker(['rm', '-f', name, `${name}-checks`]); } catch {} };
84
+ const onSignal = () => cancel();
85
+ process.once('SIGTERM', onSignal); process.once('SIGINT', onSignal);
83
86
  try {
84
87
  console.log('Waiting for the operator host engine slot');
85
88
  slot = acquireEngineSlot(config.lock);
@@ -97,7 +100,7 @@ async function main() {
97
100
  await rm(source, { recursive: true, force: true }); await mkdir(source);
98
101
  await materializeCandidate(baseline, seed, source);
99
102
  }
100
- await prepareImplementationWorkspace(source, config);
103
+ await prepareImplementationWorkspace(source, config, request.issue);
101
104
  }
102
105
  const context = join(dir, 'context.json');
103
106
  await copyFile(join(runDir, 'context.json'), context); await chmod(context, 0o444);
@@ -106,11 +109,13 @@ async function main() {
106
109
  await writeFile(prompt, await readFile(env.GH_AW_PROMPT, 'utf8') + completionBoundary, { mode: 0o444 });
107
110
  const harness = resolve(dirname(fileURLToPath(import.meta.url)), '..');
108
111
  const pkg = JSON.parse(await readFile(join(harness, 'package.json'), 'utf8'));
109
- await writeFile(join(runDir, 'runtime.json'), JSON.stringify({ package: { name: pkg.name, version: pkg.version }, harnessRevision: execFileSync('git', ['-C', harness, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), image: config.image, environment: environmentReceipt(selected), model: modelSelection.model, modelExecution: modelExecutionReceipt(modelSelection), engine: 'OpenCode 2.1.0', isolation: 'Docker; no AWF egress filtering', skills: await readdir(join(harness, 'skills')), sourceRevision: request.sourceRevision, seed: request.implementation?.policy.seed ?? null, seedDigest: request.seedDigest ?? null }));
110
- const args = ['run', '--rm', '--init', '--name', name, '--user', `${config.uid}:${config.uid}`, '--cpus', '2', '--memory', implementing ? '4g' : '2g', '--pids-limit', implementing ? '256' : '128', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--read-only',
112
+ const runtimeReceipt = { package: { name: pkg.name, version: pkg.version }, harnessRevision: execFileSync('git', ['-C', harness, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), image: config.image, limits, environment: environmentReceipt(selected), model: modelSelection.model, modelExecution: modelExecutionReceipt(modelSelection), engine: 'OpenCode 2.1.0', isolation: 'Docker; no AWF egress filtering', skills: await readdir(join(harness, 'skills')), sourceRevision: request.sourceRevision, seed: request.implementation?.policy.seed ?? null, seedDigest: request.seedDigest ?? null };
113
+ await writeFile(join(runDir, 'runtime.json'), JSON.stringify(runtimeReceipt));
114
+ const command = imageCommand(config.image);
115
+ const args = ['run', '--rm', '--init', '--name', name, '--user', `${config.uid}:${config.uid}`, ...resourceArgs(limits), '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--read-only',
111
116
  '--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}`,
112
117
  '--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`,
113
- '--mount', `type=volume,src=${config.authVolume},dst=/home/engine/.local/share/opencode`, '--env', `ENGINE_SESSION=ses_${request.runId}`, '--env', `ENGINE_MODEL=${modelSelection.engineModel}`, '--env', `ENGINE_STAGE=${request.stage}`, '--env', 'NPM_CONFIG_CACHE=/tmp/npm-cache', '--env', 'PLAYWRIGHT_BROWSERS_PATH=/workspace/.cache/ms-playwright', ...environmentArgs(runtime), config.image];
118
+ '--mount', `type=volume,src=${config.authVolume},dst=/home/engine/.local/share/opencode`, '--env', `ENGINE_SESSION=ses_${request.runId}`, '--env', `ENGINE_MODEL=${modelSelection.engineModel}`, '--env', `ENGINE_STAGE=${request.stage}`, '--env', 'NPM_CONFIG_CACHE=/tmp/npm-cache', '--env', 'PLAYWRIGHT_BROWSERS_PATH=/workspace/.cache/ms-playwright', ...environmentArgs(runtime), '--entrypoint', 'node', config.image, '/harness/src/managed-command.mjs', 'run', '/output/execution', 'engine', '--', ...command];
114
119
  const eventsPath = join(env.RUNNER_TEMP, 'gh-aw', `opencode-events-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}.jsonl`);
115
120
  await mkdir(dirname(eventsPath), { recursive: true });
116
121
  await writeFile(eventsPath, '', { mode: 0o600 });
@@ -129,7 +134,7 @@ async function main() {
129
134
  heartbeat = setInterval(() => {
130
135
  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.`));
131
136
  }, 10000);
132
- await new Promise((ok, no) => {
137
+ const exit = await new Promise((ok, no) => {
133
138
  child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
134
139
  const consume = line => {
135
140
  try {
@@ -146,9 +151,12 @@ async function main() {
146
151
  stream.on('data', chunk => { if (!cancelled) lines.write(chunk); });
147
152
  stream.on('end', () => { if (!cancelled) lines.end(); });
148
153
  }
149
- child.on('error', no); child.on('close', code => code === 0 && !cancelled ? ok() : no(Error('OpenCode failed or was cancelled')));
150
- timer = setTimeout(cancel, implementing ? 1800000 : 600000);
154
+ child.on('error', () => ok({ code: 127, signal: null })); child.on('close', (code, signal) => ok({ code, signal }));
155
+ timer = setTimeout(() => cancel('timeout'), implementing ? 1800000 : 600000);
151
156
  });
157
+ runtimeReceipt.execution = await executionReceipt(join(output, 'execution/engine.json'), exit, stopReason);
158
+ await writeFile(join(runDir, 'runtime.json'), JSON.stringify(runtimeReceipt));
159
+ if (exit.code !== 0 || cancelled || runtimeReceipt.execution.status !== 'passed') throw Error(executionFailure(runtimeReceipt.execution));
152
160
  if (engineError) throw Error('OpenCode reported an execution error; no model fallback attempted');
153
161
  const result = join(output, 'envelope.json'), stat = await lstat(result);
154
162
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128000) throw Error('Invalid engine result file');
@@ -197,15 +205,22 @@ async function main() {
197
205
  }
198
206
  throw error;
199
207
  } finally {
200
- clearTimeout(timer); clearInterval(heartbeat); process.removeListener('SIGTERM', cancel); process.removeListener('SIGINT', cancel);
208
+ clearTimeout(timer); clearInterval(heartbeat); process.removeListener('SIGTERM', onSignal); process.removeListener('SIGINT', onSignal);
201
209
  try { docker(['rm', '-f', name, `${name}-checks`]); } catch {}
202
210
  runtime?.cleanup();
203
211
  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'); }
204
212
  if (slot !== undefined) closeSync(slot);
205
- if (implementing) {
206
- 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'); }
207
- }
213
+ // Read-only stages also create engine-owned command receipt directories.
214
+ try { restoreWorkspaceOwnership(dir, config); } catch { console.error('Workspace ownership cleanup failed; operator inspection required'); }
208
215
  await rm(dir, { recursive: true, force: true });
209
216
  }
210
217
  }
211
- 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; });
218
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(async error => {
219
+ const message = redactLog(error.message);
220
+ console.error(progressText('Engine error', message));
221
+ try { await writeFile(join(process.env.RUNNER_TEMP, `lighthouse-${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`, 'engine-error.json'), JSON.stringify({ schemaVersion: 1, message: message.slice(0, 3000) })); } catch {}
222
+ if (process.env.GITHUB_STEP_SUMMARY) {
223
+ try { await appendFile(process.env.GITHUB_STEP_SUMMARY, `\n### Execution failed\n\n${message.replaceAll('<', '&lt;').replaceAll('>', '&gt;')}\n\nInspect runtime.json and the retained engine events before retrying.\n`); } catch {}
224
+ }
225
+ process.exitCode = 1;
226
+ });
@@ -0,0 +1,308 @@
1
+ import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { createHash } from 'node:crypto';
6
+ import { validateProductChangeFiles, validateProductCheckpoint, validateProductWorkspace, loadProductBridge, productReviewBinding, parseProductDecision, verifyProductDecision, authorizeProductApply, createProductApplyReceipt, productModelDigest, productModelDirectory } from './product-change.mjs';
7
+ import { checkImplementationPullRequest } from './proposal-review.mjs';
8
+
9
+ const marker = '<!-- lighthouse-product-change:';
10
+ export async function validateProductCandidate(root, changeName, files, bridge) {
11
+ const summary = validateProductChangeFiles(files, { changeName });
12
+ const temp = await mkdtemp(join(tmpdir(), 'lighthouse-product-change-'));
13
+ try {
14
+ const productRoot = dirname(await productModelDirectory(root));
15
+ for (const name of [productRoot, '.product/config.yaml', 'openspec/config.yaml', 'openspec/schemas']) {
16
+ const source = join(root, name);
17
+ try {
18
+ const stat = await import('node:fs/promises').then(fs => fs.lstat(source));
19
+ if (stat.isDirectory()) {
20
+ await mkdir(dirname(join(temp, name)), { recursive: true });
21
+ await cp(source, join(temp, name), { recursive: true, errorOnExist: true, force: false });
22
+ }
23
+ else {
24
+ const destination = join(temp, name);
25
+ await mkdir(dirname(destination), { recursive: true });
26
+ await writeFile(destination, await readFile(source));
27
+ }
28
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
29
+ }
30
+ for (const file of files) {
31
+ const destination = join(temp, file.path);
32
+ await mkdir(dirname(destination), { recursive: true });
33
+ await writeFile(destination, file.content, { flag: 'wx' });
34
+ }
35
+ execFileSync('npx', ['--yes', '--package', '@fission-ai/openspec@1.15.0', 'openspec', 'validate', changeName, '--strict', '--no-interactive'], { cwd: temp, encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024, env: { ...process.env, OPENSPEC_TELEMETRY: '0', CI: 'true' } });
36
+ const checked = await validateProductWorkspace(temp, changeName, bridge);
37
+ return { ...summary, baselineDigest: await productModelDigest(root), modelRoot: await productModelDirectory(root), openSpec: 'passed', provider: 'passed', diagnosticCount: checked.diagnostics?.length ?? 0 };
38
+ } finally { await rm(temp, { recursive: true, force: true }); }
39
+ }
40
+
41
+ export async function loadProductCandidateFromPullRequest(api, repository, pr, changeName) {
42
+ if (pr?.state !== 'open' || pr.merged || pr.user?.login !== 'github-actions[bot]' || pr.user?.type !== 'Bot' || pr.head?.repo?.full_name !== repository || pr.base?.repo?.full_name !== repository) throw Error('Product Change review needs an open bot-owned PR in this repository');
43
+ const changed = await api(`pulls/${pr.number}/files?per_page=100`);
44
+ if (!Array.isArray(changed) || changed.length >= 100 || changed.some(file => file.status === 'removed' || file.status === 'renamed')) throw Error('Product Change PR file list exceeds its bound or contains removals');
45
+ const files = [];
46
+ for (const item of changed) {
47
+ if (!item.filename.startsWith(`openspec/changes/${changeName}/`)) continue;
48
+ const value = await api(`contents/${item.filename}?ref=${pr.head.sha}`);
49
+ if (value.type !== 'file' || value.encoding !== 'base64' || value.size > 65536) throw Error(`Product Change file cannot be reviewed: ${item.filename}`);
50
+ files.push({ path: item.filename, content: Buffer.from(value.content, 'base64').toString('utf8') });
51
+ }
52
+ const checked = validateProductChangeFiles(files, { changeName });
53
+ return { files, ...checked };
54
+ }
55
+
56
+ export async function productModelDigestFromApi(api, revision) {
57
+ const tree = await api(`git/trees/${revision}?recursive=1`);
58
+ if (tree.truncated || !Array.isArray(tree.tree) || tree.tree.length > 10000) throw Error('Accepted product model tree exceeds the API bound');
59
+ let modelRoot = 'docs/product/model';
60
+ const config = await api(`contents/.product/config.yaml?ref=${revision}`, 'GET', undefined, true);
61
+ if (config) {
62
+ if (config.type !== 'file' || config.encoding !== 'base64' || config.size > 65536) throw Error('ProductShape config is not a bounded regular file');
63
+ const value = Buffer.from(config.content, 'base64').toString('utf8').match(/^product-root:\s*(.*?)\s*$/m)?.[1]?.replace(/^(?:"(.*)"|'(.*)')$/, (_match, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted);
64
+ if (value) modelRoot = `${value}/model`;
65
+ }
66
+ if (!safeRelative(modelRoot) || modelRoot.split('/').some(part => part.startsWith('.'))) throw Error('ProductShape product-root is unsafe');
67
+ const entries = tree.tree.filter(item => item.path.startsWith(`${modelRoot}/`) && item.type === 'blob').sort((a, b) => a.path.localeCompare(b.path));
68
+ if (!entries.length || entries.length > 512) throw Error('Accepted product model file list is empty or exceeds its bound');
69
+ const rows = [];
70
+ let bytes = 0;
71
+ for (const entry of entries) {
72
+ if (entry.mode !== '100644' && entry.mode !== '100755' || entry.size > 65536) throw Error(`Accepted product model path is not a bounded regular file: ${entry.path}`);
73
+ const blob = await api(`git/blobs/${entry.sha}`);
74
+ if (blob.encoding !== 'base64' || blob.size > 65536) throw Error(`Accepted product artifact exceeds its bound: ${entry.path}`);
75
+ const content = Buffer.from(blob.content, 'base64');
76
+ bytes += content.length;
77
+ if (bytes > 8 * 1024 * 1024) throw Error('Accepted product model exceeds its aggregate bound');
78
+ rows.push(`${entry.path.slice(`${modelRoot}/`.length)}\0${createHash('sha256').update(content).digest('hex')}\n`);
79
+ }
80
+ return { digest: createHash('sha256').update(rows.join('')).digest('hex'), modelRoot };
81
+ }
82
+
83
+ function reviewText({ repository, snapshot, head, changeId, changeName, files, candidate, validation, runUrl, approvalKey }) {
84
+ const manifest = files.find(file => file.path.endsWith('/product-change/change.md'))?.content ?? '';
85
+ const sections = [...manifest.matchAll(/^## (.+?)\n([\s\S]*?)(?=^## |\s*$)/gm)].map(match => `### ${match[1]}\n${match[2].trim()}`).join('\n\n');
86
+ const artifacts = files.filter(file => file.path.includes('/proposed/')).map(file => `- [${file.path}](https://github.com/${repository}/blob/${head}/${file.path})`).join('\n');
87
+ const areas = candidate.affectedAreas?.map(area => `- ${area}`).join('\n') || '- See the change manifest.';
88
+ const decisions = candidate.decisions?.map(decision => `- ${decision}`).join('\n') || '- Review the proposed behavior in the change manifest.';
89
+ return `## Product decision: ${changeId}\n\nThis product proposal was prepared for [issue #${snapshot.issue}](https://github.com/${repository}/issues/${snapshot.issue}). Review the problem, proposed outcome and affected product areas before deciding.\n\n### Affected product areas\n${areas}\n\n### Decisions and trade-offs\n${decisions}\n\n### Product Change\n${sections}\n\n### Proposed artifacts\n${artifacts || 'No artifact links were produced.'}\n\nValidation: ProductShape overlay and OpenSpec strict validation passed (${validation.provider}, ${validation.openSpec}). [Review the branch diff](https://github.com/${repository}/compare/${snapshot.defaultBranch}...${head}).\n\n### Decide\nApprove this exact product delta with:\n\n\`/product-change approve ${changeName} ${approvalKey}\`\n\nTo request a revision, use this as the first line of a new comment and add the requested change on the next line:\n\n/product-change request-changes ${changeName} ${approvalKey}\nDescribe the change\n\nApproval authorizes trusted ProductShape apply. This idea entry does not grant implementation permission. PR merge, release and deployment remain separate decisions. [Preparation run](${runUrl}).`;
90
+ }
91
+
92
+ export async function publishProductChangePullRequest(api, repository, snapshot, candidate, { validation, runUrl, beforeWrite }) {
93
+ const files = validateProductChangeFiles(candidate.files, { changeName: candidate.changeName });
94
+ if (files.changeId !== candidate.changeId || files.digest !== candidate.proposalDigest || validation.provider !== 'passed' || validation.openSpec !== 'passed') throw Error('Product Change candidate validation evidence is stale');
95
+ const pulls = await api('pulls?state=open&per_page=100');
96
+ if (pulls.length >= 100) throw Error('Open pull request list exceeds bounds');
97
+ const issuePulls = pulls.filter(pr => pr.head?.ref?.startsWith(`lighthouse/implement-${snapshot.issue}-`) || pr.head?.ref?.startsWith(`lighthouse/product-change-${snapshot.issue}-`));
98
+ if (issuePulls.length > 1) throw Error('Issue has multiple eligible delivery PRs; owner review is required');
99
+ let pr = issuePulls[0] ? await api(`pulls/${issuePulls[0].number}`) : null;
100
+ let base, branch, parent;
101
+ if (pr) {
102
+ if (pr.head.ref.startsWith(`lighthouse/implement-${snapshot.issue}-`) && pr.body?.includes('<!-- lighthouse-product-change:')) {
103
+ if (pr.user?.login !== 'github-actions[bot]' || pr.user?.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head?.repo?.full_name !== repository) throw Error('Existing Product Change PR is not a current bot-owned PR');
104
+ } else if (pr.head.ref.startsWith(`lighthouse/implement-${snapshot.issue}-`)) checkImplementationPullRequest(pr, repository);
105
+ else if (pr.user?.login !== 'github-actions[bot]' || pr.user?.type !== 'Bot' || pr.state !== 'open' || pr.merged || !pr.body?.includes('<!-- lighthouse-product-change:')) throw Error('Existing issue PR is not an eligible Lighthouse Product Change draft');
106
+ if (pr.base?.ref !== snapshot.defaultBranch || pr.head?.repo?.full_name !== repository) throw Error('Existing issue PR targets an unexpected repository or base');
107
+ if (!pr.draft) throw Error('Product Change cannot modify a PR already submitted for review');
108
+ branch = pr.head.ref; parent = pr.head.sha;
109
+ base = await api(`git/commits/${parent}`);
110
+ } else {
111
+ branch = `lighthouse/implement-${snapshot.issue}-${snapshot.runId.slice(0, 12)}`;
112
+ parent = snapshot.sourceRevision;
113
+ base = await api(`git/commits/${parent}`);
114
+ }
115
+ const tree = await api('git/trees', 'POST', { base_tree: base.tree.sha, tree: candidate.files.map(file => ({ path: file.path, mode: '100644', type: 'blob', content: file.content })) });
116
+ const commit = await api('git/commits', 'POST', { message: `docs: prepare product change for #${snapshot.issue}`, tree: tree.sha, parents: [parent] });
117
+ await beforeWrite();
118
+ if (pr) {
119
+ if ((await api(`pulls/${pr.number}`)).head.sha !== parent) throw Error('Issue PR changed before Product Change publication');
120
+ await api(`git/refs/heads/${branch}`, 'PATCH', { sha: commit.sha, force: false });
121
+ pr = await api(`pulls/${pr.number}`);
122
+ } else {
123
+ const existingBranch = await api(`git/ref/heads/${branch}`, 'GET', undefined, true);
124
+ if (existingBranch) throw Error('Product Change branch already exists without its reviewed PR; reconcile before retry');
125
+ await api('git/refs', 'POST', { ref: `refs/heads/${branch}`, sha: commit.sha });
126
+ const title = `Product change for #${snapshot.issue}: ${snapshot.title.slice(0, 150)}`;
127
+ pr = await api('pulls', 'POST', { title, head: branch, base: snapshot.defaultBranch, draft: true, body: `Product Change ${candidate.changeId} prepared for #${snapshot.issue}.\n\n${runUrl}\n\n<!-- lighthouse-product-change:${JSON.stringify({ schemaVersion: 1, issue: snapshot.issue, changeName: candidate.changeName, changeId: candidate.changeId, proposalDigest: candidate.proposalDigest })} -->` });
128
+ }
129
+ const repositoryInfo = await api('');
130
+ const policyDigest = snapshot.policyDigest;
131
+ const binding = productReviewBinding({ repository, issue: snapshot.issue, pullRequest: pr.number, changeName: candidate.changeName, changeId: candidate.changeId, proposalDigest: candidate.proposalDigest, baselineDigest: candidate.baselineDigest, policyDigest, authorityDigest: snapshot.authorizationDigest, head: pr.head.sha });
132
+ const checkpoint = {
133
+ schemaVersion: 1, kind: 'checkpoint',
134
+ identity: { repository, issue: snapshot.issue, parentRunId: String(snapshot.dispatch?.routerRunId ?? snapshot.workflowRunId), authorizationDigest: snapshot.authorizationDigest, policyDigest, workflowRevision: snapshot.workflowRevision, head: pr.head.sha },
135
+ requestDigest: snapshot.dispatch?.digest ?? snapshot.taskDigest,
136
+ changeName: candidate.changeName, changeId: candidate.changeId, proposalDigest: candidate.proposalDigest, baselineDigest: candidate.baselineDigest,
137
+ state: 'waiting-product-decision', effectIds: [`pr:${pr.number}:${pr.head.sha}`]
138
+ };
139
+ const checkpointErrors = validateProductCheckpoint(checkpoint);
140
+ if (checkpointErrors.length) throw Error(checkpointErrors.join('; '));
141
+ const body = reviewText({ repository, snapshot, head: pr.head.sha, changeId: candidate.changeId, changeName: candidate.changeName, files: candidate.files, candidate, validation, runUrl, approvalKey: binding.key });
142
+ const reviewMarker = `${marker}review:${JSON.stringify(binding)} -->`;
143
+ const checkpointMarker = `${marker}checkpoint:${JSON.stringify(checkpoint)} -->`;
144
+ let prBody = pr.body ?? '';
145
+ prBody = prBody.includes('<!-- lighthouse-product-change-review:')
146
+ ? prBody.replace(/<!-- lighthouse-product-change-review:\{[^\n]*\} -->/g, reviewMarker)
147
+ : `${prBody}\n\n${reviewMarker}`;
148
+ prBody = prBody.includes('<!-- lighthouse-product-change-checkpoint:')
149
+ ? prBody.replace(/<!-- lighthouse-product-change-checkpoint:\{[^\n]*\} -->/g, checkpointMarker)
150
+ : `${prBody}\n${checkpointMarker}`;
151
+ await api(`pulls/${pr.number}`, 'PATCH', { body: prBody });
152
+ const previous = await api(`issues/${pr.number}/comments?per_page=100`);
153
+ const notice = previous.find(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(`Product decision: ${candidate.changeId}`));
154
+ if (notice) await api(`issues/comments/${notice.id}`, 'PATCH', { body });
155
+ else await api(`issues/${pr.number}/comments`, 'POST', { body });
156
+ const issueComments = await api(`issues/${snapshot.issue}/comments?per_page=100`);
157
+ const issueNotice = issueComments.find(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(`<!-- lighthouse-product-change-link:${candidate.proposalDigest} -->`));
158
+ const issueBody = `A draft Product Change is ready in [PR #${pr.number}](${pr.html_url}). Review the proposed behavior, affected product areas, decisions, trade-offs and validation evidence there. Approve the identified product proposal or request changes in that PR. Approval starts trusted ProductShape apply; this idea entry does not start implementation. PR merge remains separate.\n\n<!-- lighthouse-product-change-link:${candidate.proposalDigest} -->`;
159
+ if (issueNotice) await api(`issues/comments/${issueNotice.id}`, 'PATCH', { body: issueBody });
160
+ else await api(`issues/${snapshot.issue}/comments`, 'POST', { body: issueBody });
161
+ return { pullRequest: pr, binding, checkpoint, body, repositoryDefaultBranch: repositoryInfo.default_branch };
162
+ }
163
+
164
+ export async function applyApprovedProductChange({ root, changeName, checkpoint, approval, currentBinding, currentIdentity, bridge, headAfter, resultingModelDigest, reportBeforeWrite, publish }) {
165
+ const intent = authorizeProductApply({ checkpoint, approval, currentBinding, currentIdentity });
166
+ const provider = bridge ?? await loadProductBridge();
167
+ const manifest = join(root, 'openspec', 'changes', changeName, 'product-change', 'change.md');
168
+ const original = await readFile(manifest, 'utf8');
169
+ if (!/^status:\s*draft\s*$/m.test(original)) throw Error('Only a draft Product Change can enter the authenticated apply path');
170
+ await writeFile(manifest, original.replace(/^status:\s*draft\s*$/m, 'status: approved'));
171
+ await validateProductWorkspace(root, changeName, provider);
172
+ const preview = await provider.applyOpenSpecProductChange(root, changeName, { dryRun: true });
173
+ if (preview.outcome !== 'dry-run' || preview.plan?.changeId && preview.plan.changeId !== checkpoint.changeId) throw Error('ProductShape dry-run does not match the reviewed change');
174
+ const applied = await provider.applyOpenSpecProductChange(root, changeName, { reportBeforeWrite });
175
+ if (applied.outcome !== 'applied' || applied.change?.id !== checkpoint.changeId) throw Error('ProductShape apply was refused or returned a different change');
176
+ if ((applied.resultingModel?.diagnostics ?? []).some(item => item.severity === 'error')) throw Error('ProductShape resulting model has validation errors');
177
+ const computedDigest = await productModelDigest(root);
178
+ if (resultingModelDigest && resultingModelDigest !== computedDigest) throw Error('Product model changed while the apply receipt was being prepared');
179
+ const receipt = createProductApplyReceipt({ intent, approval, checkpoint, headAfter, resultingModelDigest: computedDigest, validated: true });
180
+ if (typeof publish !== 'function') throw Error('Trusted publisher is required for Product Change apply');
181
+ await publish(receipt, applied, { manifest, affectedCitations: applied.affectedCitations ?? [], resultingModelDigest: computedDigest });
182
+ return { receipt, applied, affectedCitations: applied.affectedCitations ?? [], resultingModelDigest: computedDigest };
183
+ }
184
+
185
+ export async function collectProductApplyFiles(root, changeName, baseRevision) {
186
+ const prefix = `openspec/changes/${changeName}/product-change/change.md`;
187
+ const modelRoot = await productModelDirectory(root);
188
+ const paths = execFileSync('git', ['diff', '--name-only', '--no-renames', baseRevision, '--', modelRoot, prefix], { cwd: root, encoding: 'utf8', maxBuffer: 128000 }).trim().split('\n').filter(Boolean).sort();
189
+ if (!paths.includes(prefix) || paths.length > 520 || paths.some(path => path !== prefix && !path.startsWith(`${modelRoot}/`))) throw Error('Trusted apply wrote outside the Product Change manifest and accepted model');
190
+ const files = [];
191
+ let bytes = 0;
192
+ for (const path of paths) {
193
+ const absolute = join(root, path);
194
+ const stat = await import('node:fs/promises').then(fs => fs.lstat(absolute));
195
+ if (!stat.isFile() || stat.isSymbolicLink()) throw Error(`Trusted apply produced a non-regular path: ${path}`);
196
+ if (stat.size > 65536) throw Error(`Trusted apply artifact exceeds its bound: ${path}`);
197
+ const content = await readFile(absolute, 'utf8');
198
+ bytes += Buffer.byteLength(content);
199
+ if (bytes > 8 * 1024 * 1024) throw Error('Trusted apply output exceeds its aggregate bound');
200
+ files.push({ path, content });
201
+ }
202
+ const manifest = files.find(file => file.path === prefix)?.content ?? '';
203
+ if (!/^status:\s*applied\s*$/m.test(manifest)) throw Error('Provider did not record the applied Product Change status');
204
+ return { files, modelRoot };
205
+ }
206
+
207
+ export function validateProductApplyFiles(files, changeName, modelRoot = 'docs/product/model') {
208
+ if (!safeRelative(modelRoot) || modelRoot.split('/').some(part => part.startsWith('.'))) throw Error('Product apply model root is unsafe');
209
+ const manifestPath = `openspec/changes/${changeName}/product-change/change.md`;
210
+ if (!Array.isArray(files) || !files.length || files.length > 520) throw Error('Product apply output file list is empty or exceeds its bound');
211
+ const seen = new Set(); let bytes = 0;
212
+ for (const file of files) {
213
+ if (!file || Object.keys(file).sort().join(',') !== 'content,path' || typeof file.path !== 'string' || typeof file.content !== 'string' || seen.has(file.path)) throw Error('Invalid or duplicated Product apply output file');
214
+ if (file.path !== manifestPath && !file.path.startsWith(`${modelRoot}/`)) throw Error(`Product apply output escaped the accepted model: ${file.path}`);
215
+ if (!safeRelative(file.path) || file.content.includes('\0')) throw Error(`Unsafe Product apply output: ${file.path}`);
216
+ const size = Buffer.byteLength(file.content);
217
+ if (size > 65536) throw Error(`Product apply output exceeds its file bound: ${file.path}`);
218
+ bytes += size;
219
+ if (bytes > 8 * 1024 * 1024) throw Error('Product apply output exceeds its aggregate bound');
220
+ seen.add(file.path);
221
+ }
222
+ const manifest = files.find(file => file.path === manifestPath)?.content ?? '';
223
+ if (!/^status:\s*applied\s*$/m.test(manifest)) throw Error('Product apply output does not retain the provider applied status');
224
+ return files;
225
+ }
226
+
227
+ const safeRelative = path => !path.startsWith('/') && !path.includes('\\') && !path.split('/').some(part => !part || part === '.' || part === '..');
228
+
229
+ export async function publishProductApply(api, repository, owner, dispatch, result, beforeWrite) {
230
+ const approvalInfo = dispatch.request.productApproval;
231
+ if (dispatch.request.worker !== 'apply-product-change' || !approvalInfo || !result || result.head !== approvalInfo.head) throw Error('Product apply result does not match its authenticated dispatch');
232
+ const files = validateProductApplyFiles(result.files, approvalInfo.changeName, result.modelRoot);
233
+ const fresh = await loadProductApproval(api, { eventName: dispatch.request.eventName, event: dispatch.request.event, owner, repository, root: process.cwd() });
234
+ if (!fresh || fresh.head !== approvalInfo.head || fresh.pullRequest !== approvalInfo.pullRequest || fresh.approval.reviewKey !== approvalInfo.reviewKey || fresh.checkpoint.changeId !== result.receipt.changeId) throw Error('Product approval or reviewed branch changed before publication');
235
+ if (result.receipt.resultingModelDigest !== result.resultingModelDigest || result.receipt.baselineDigest !== fresh.checkpoint.baselineDigest || result.receipt.proposalDigest !== fresh.checkpoint.proposalDigest) throw Error('Product apply receipt does not match the approved candidate');
236
+ const pr = await api(`pulls/${fresh.pullRequest}`);
237
+ if (pr.head.sha !== fresh.head || pr.state !== 'open' || pr.merged || pr.head.repo?.full_name !== repository) throw Error('Product PR head changed before apply publication');
238
+ const commit = await api(`git/commits/${fresh.head}`);
239
+ 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 })) });
240
+ const created = await api('git/commits', 'POST', { message: `docs: apply product change ${fresh.checkpoint.changeId}`, tree: tree.sha, parents: [fresh.head] });
241
+ await beforeWrite(fresh);
242
+ await api(`git/refs/heads/${pr.head.ref}`, 'PATCH', { sha: created.sha, force: false });
243
+ const updated = await api(`pulls/${fresh.pullRequest}`);
244
+ const receipt = { ...result.receipt, headAfter: updated.head.sha };
245
+ let prBody = updated.body ?? '';
246
+ const checkpoint = { ...fresh.checkpoint, state: 'applied', identity: { ...fresh.checkpoint.identity, head: updated.head.sha }, effectIds: [...new Set([...fresh.checkpoint.effectIds, `apply:${receipt.intentId}:${updated.head.sha}`])].slice(0, 32) };
247
+ validateProductCheckpoint(checkpoint);
248
+ prBody = prBody.replace(/<!-- lighthouse-product-change-checkpoint:\{[^\n]*\} -->/g, `${marker}checkpoint:${JSON.stringify(checkpoint)} -->`);
249
+ await api(`pulls/${fresh.pullRequest}`, 'PATCH', { body: prBody });
250
+ const issueBody = `Product Change ${receipt.changeId} was applied to [PR #${fresh.pullRequest}](${updated.html_url}). The issue branch contains the provider-validated product model. Merge is still separate.\n\nAffected citations:\n${(result.affectedCitations ?? []).map(item => `- \`${item.document ?? item.file ?? item.id ?? 'consumer'}\`: ${item.status ?? 'refresh required'}`).join('\n') || '- None reported.'}\n\n<!-- lighthouse-product-apply:${JSON.stringify(receipt)} -->`;
251
+ const comments = await api(`issues/${fresh.issue}/comments?per_page=100`);
252
+ const prior = comments.find(comment => comment.user?.login === 'github-actions[bot]' && [...(comment.body ?? '').matchAll(/<!-- lighthouse-product-apply:(\{[^\n]*\}) -->/g)].some(match => JSON.parse(match[1]).intentId === receipt.intentId));
253
+ if (prior) await api(`issues/comments/${prior.id}`, 'PATCH', { body: issueBody });
254
+ else await api(`issues/${fresh.issue}/comments`, 'POST', { body: issueBody });
255
+ return { pullRequest: updated, receipt, checkpoint };
256
+ }
257
+
258
+ export function productApprovalFromGitHub({ eventName, event, owner, repository, currentBinding }) {
259
+ let source, actor, actorType, body, decisionId, submitted = true;
260
+ if (eventName === 'issue_comment' && event.action === 'created' && event.issue?.pull_request) {
261
+ source = 'pull-request-comment'; actor = event.comment?.user?.login; actorType = event.comment?.user?.type; body = event.comment?.body; decisionId = String(event.comment?.id);
262
+ } else if (eventName === 'pull_request_review' && event.action === 'submitted') {
263
+ source = 'pull-request-review'; actor = event.review?.user?.login; actorType = event.review?.user?.type; body = event.review?.body; decisionId = String(event.review?.id);
264
+ } else if (eventName === 'issue_comment' && event.action === 'created') {
265
+ source = 'issue-comment'; actor = event.comment?.user?.login; actorType = event.comment?.user?.type; body = event.comment?.body; decisionId = String(event.comment?.id);
266
+ } else submitted = false;
267
+ const decision = parseProductDecision(body);
268
+ if (!decision || decision.action !== 'approve') return null;
269
+ const pr = event.pull_request?.number ?? event.issue?.number;
270
+ if (currentBinding.repository !== repository || currentBinding.pullRequest !== pr) throw Error('Product approval targets another PR');
271
+ return verifyProductDecision({ decision, actor, actorType, owner, source, submitted, currentBinding, observedBinding: currentBinding, decisionId });
272
+ }
273
+
274
+ export async function loadProductApproval(api, { eventName, event, owner, repository, root }) {
275
+ let body, actor, actorType, source, decisionId;
276
+ if (eventName === 'issue_comment' && event.action === 'created' && event.issue?.pull_request) {
277
+ body = event.comment?.body; actor = event.comment?.user?.login; actorType = event.comment?.user?.type; source = 'pull-request-comment'; decisionId = String(event.comment?.id);
278
+ } else if (eventName === 'pull_request_review' && event.action === 'submitted') {
279
+ body = event.review?.body; actor = event.review?.user?.login; actorType = event.review?.user?.type; source = 'pull-request-review'; decisionId = String(event.review?.id);
280
+ } else return null;
281
+ const decision = parseProductDecision(body);
282
+ if (!decision || !['approve', 'request-changes', 'answer'].includes(decision.action)) return null;
283
+ const number = event.pull_request?.number ?? event.issue?.number;
284
+ const pr = await api(`pulls/${number}`);
285
+ if (pr.user?.login !== 'github-actions[bot]' || pr.user?.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head?.repo?.full_name !== repository || pr.base?.repo?.full_name !== repository) throw Error('Product approval target is not a current Lighthouse PR');
286
+ const markers = [...(pr.body ?? '').matchAll(/<!-- lighthouse-product-change-review:(\{[^\n]*\}) -->/g)];
287
+ if (markers.length !== 1) throw Error('PR has no unique Product Change review binding');
288
+ const raw = JSON.parse(markers[0][1]);
289
+ const { key, ...fields } = raw;
290
+ const bound = productReviewBinding(fields);
291
+ if (bound.key !== key || bound.head !== pr.head.sha || bound.pullRequest !== pr.number || bound.repository !== repository) throw Error('Product Change review binding is stale or belongs to another PR');
292
+ const checkpoints = [...(pr.body ?? '').matchAll(/<!-- lighthouse-product-change-checkpoint:(\{[^\n]*\}) -->/g)];
293
+ if (checkpoints.length !== 1) throw Error('PR has no unique Product Change checkpoint');
294
+ const checkpoint = JSON.parse(checkpoints[0][1]);
295
+ const checkpointErrors = validateProductCheckpoint(checkpoint);
296
+ if (checkpointErrors.length || checkpoint.state !== 'waiting-product-decision' || checkpoint.changeName !== bound.changeName || checkpoint.changeId !== bound.changeId || checkpoint.proposalDigest !== bound.proposalDigest || checkpoint.baselineDigest !== bound.baselineDigest || checkpoint.identity.head !== pr.head.sha || checkpoint.identity.issue !== bound.issue || checkpoint.identity.repository !== repository || checkpoint.effectIds.length !== 1 || checkpoint.effectIds[0] !== `pr:${pr.number}:${pr.head.sha}`) throw Error('Product Change checkpoint is stale or invalid');
297
+ if (decision.changeName !== bound.changeName || decision.reviewKey !== bound.key) throw Error('Product approval action does not identify the current Product Change');
298
+ const candidate = await loadProductCandidateFromPullRequest(api, repository, pr, bound.changeName);
299
+ const repositoryInfo = await api('');
300
+ const defaultHead = (await api(`commits/${repositoryInfo.default_branch}`)).sha;
301
+ const accepted = await productModelDigestFromApi(api, defaultHead);
302
+ if (candidate.changeId !== bound.changeId || candidate.digest !== bound.proposalDigest || accepted.digest !== bound.baselineDigest) throw Error('Product Change proposal or accepted baseline changed after review');
303
+ const verified = verifyProductDecision({ decision, actor, actorType, owner, source, submitted: true, currentBinding: bound, observedBinding: bound, decisionId });
304
+ const issue = await api(`issues/${bound.issue}`);
305
+ if (issue.state !== 'open' || issue.labels?.some(label => ['future', 'human-working', 'agent-working'].includes(label.name))) throw Error('Product apply is blocked by the current issue state or reservation');
306
+ const feedback = decision.action === 'approve' ? null : { action: decision.action, changeName: decision.changeName, reviewKey: decision.reviewKey, text: decision.detail, sourceCommentId: decisionId, source: 'pull-request' };
307
+ return { ...(decision.action === 'approve' ? { approval: verified } : { feedback }), binding: bound, checkpoint, issue: bound.issue, pullRequest: pr.number, head: pr.head.sha, changeName: bound.changeName, candidate, modelRoot: accepted.modelRoot };
308
+ }