@juangcarmona/lighthouse-sdlc 0.15.2 → 0.16.1

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 (49) hide show
  1. package/README.md +6 -1
  2. package/execution-environments.md +2 -0
  3. package/meta-commands/ff.md +2 -2
  4. package/meta-commands/hotfix.md +3 -1
  5. package/package.json +8 -3
  6. package/schemas/product-change.schema.json +118 -0
  7. package/skills/independent-review/SKILL.md +13 -0
  8. package/skills/product-change/SKILL.md +18 -0
  9. package/src/completion.mjs +5 -3
  10. package/src/contracts.mjs +2 -0
  11. package/src/host-contract.mjs +11 -3
  12. package/src/implementation-runtime.mjs +14 -5
  13. package/src/implementation.mjs +2 -2
  14. package/src/install-workflows.mjs +17 -5
  15. package/src/integration-evidence.mjs +4 -1
  16. package/src/issue-actions.mjs +124 -17
  17. package/src/opencode-execute.mjs +7 -7
  18. package/src/product-change-actions.mjs +308 -0
  19. package/src/product-change.mjs +277 -0
  20. package/src/proposal-review.mjs +1 -1
  21. package/src/proposal.mjs +4 -4
  22. package/src/sdlc.mjs +1 -1
  23. package/src/stage-dispatch.mjs +11 -3
  24. package/src/unattended-actions.mjs +287 -0
  25. package/src/unattended-runtime.mjs +115 -0
  26. package/src/unattended.mjs +161 -0
  27. package/templates/unattended.json +37 -0
  28. package/unattended.md +40 -0
  29. package/workflows/lighthouse-sdlc-apply-product-change.lock.yml +1838 -0
  30. package/workflows/lighthouse-sdlc-apply-product-change.md +183 -0
  31. package/workflows/lighthouse-sdlc-apply-review.lock.yml +4 -4
  32. package/workflows/lighthouse-sdlc-apply-review.md +2 -2
  33. package/workflows/lighthouse-sdlc-implement.lock.yml +4 -4
  34. package/workflows/lighthouse-sdlc-implement.md +2 -2
  35. package/workflows/lighthouse-sdlc-integrate.lock.yml +4 -4
  36. package/workflows/lighthouse-sdlc-integrate.md +2 -2
  37. package/workflows/lighthouse-sdlc-product-change.lock.yml +1329 -0
  38. package/workflows/lighthouse-sdlc-product-change.md +195 -0
  39. package/workflows/lighthouse-sdlc-propose.lock.yml +4 -4
  40. package/workflows/lighthouse-sdlc-propose.md +2 -2
  41. package/workflows/lighthouse-sdlc-refine.lock.yml +4 -4
  42. package/workflows/lighthouse-sdlc-refine.md +2 -2
  43. package/workflows/lighthouse-sdlc-review.lock.yml +4 -4
  44. package/workflows/lighthouse-sdlc-review.md +2 -2
  45. package/workflows/lighthouse-sdlc-router.yml +12 -2
  46. package/workflows/lighthouse-sdlc-triage.lock.yml +4 -4
  47. package/workflows/lighthouse-sdlc-triage.md +2 -2
  48. package/workflows/lighthouse-sdlc-unattended.yml +67 -0
  49. package/workflows/shared/lighthouse-opencode.md +1 -1
@@ -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
+ }
@@ -0,0 +1,277 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createRequire } from 'node:module';
3
+ import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { dirname, join, resolve, sep } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { pathToFileURL } from 'node:url';
8
+
9
+ export const PRODUCT_CHANGE_SCHEMA_VERSION = 1;
10
+ export const PRODUCT_CHANGE_WORKER = 'product-change';
11
+ export const PRODUCT_FILE_LIMITS = Object.freeze({ files: 64, fileBytes: 65536, totalBytes: 524288 });
12
+ const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
+ const hashPattern = /^[a-f0-9]{64}$/;
14
+ const revisionPattern = /^[a-f0-9]{40}$/;
15
+ const require = createRequire(import.meta.url);
16
+ 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' } });
17
+
18
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
19
+ const exact = (value, fields) => record(value) && Object.keys(value).every(key => fields.includes(key)) && fields.every(key => Object.hasOwn(value, key));
20
+ const sha = value => createHash('sha256').update(value).digest('hex');
21
+ const digest = sha;
22
+ const safePath = value => typeof value === 'string' && !value.includes('\\') && !value.startsWith('/') && !/^[A-Za-z]:/.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..');
23
+
24
+ export function validateProductRequest(value) {
25
+ const errors = [];
26
+ if (!exact(value, ['schemaVersion', 'kind', 'identity', 'entry', 'idea', 'scope', 'createdAt', ...(['changeName'].filter(k => Object.hasOwn(value ?? {}, k))), ...(['feedback'].filter(k => Object.hasOwn(value ?? {}, k))) ])) return ['Product Change request has unsupported or missing fields'];
27
+ if (value.schemaVersion !== 1 || value.kind !== 'request') errors.push('Product Change request identity is unsupported');
28
+ if (!['idea', 'parent-handoff', 'feedback'].includes(value.entry)) errors.push('Product Change request entry is unsupported');
29
+ if (typeof value.idea !== 'string' || !value.idea.trim() || value.idea.length > 12000) errors.push('Product Change request idea must contain 1-12000 characters');
30
+ if (typeof value.scope !== 'string' || !value.scope.trim() || value.scope.length > 12000) errors.push('Product Change request scope must contain 1-12000 characters');
31
+ if (!Number.isFinite(Date.parse(value.createdAt))) errors.push('Product Change request createdAt must be a timestamp');
32
+ if (value.changeName !== undefined && !namePattern.test(value.changeName)) errors.push('Product Change request changeName must be a kebab-case OpenSpec name');
33
+ if (value.feedback !== undefined && (typeof value.feedback !== 'string' || value.feedback.length > 12000)) errors.push('Product Change request feedback exceeds its bound');
34
+ errors.push(...validateProductIdentity(value.identity));
35
+ if (value.entry === 'feedback' && (!value.changeName || !value.feedback?.trim())) errors.push('Product feedback must identify the existing change and contain feedback');
36
+ return errors;
37
+ }
38
+
39
+ export function validateProductChangeOutput(value) {
40
+ const fields = ['schemaVersion', 'outcome', 'entry', 'affectedAreas', 'decisions', ...(['changeName', 'changeId', 'files', 'question', 'proposalDigest', 'baselineDigest', 'providerValidation'].filter(key => Object.hasOwn(value ?? {}, key)))];
41
+ if (!exact(value, fields)) return ['result.output.productChange has unsupported or missing fields'];
42
+ const errors = [];
43
+ if (value.schemaVersion !== 1 || !['aligned', 'candidate', 'blocked'].includes(value.outcome) || !['idea', 'parent-handoff', 'feedback'].includes(value.entry)) errors.push('result.output.productChange identity is unsupported');
44
+ if (!Array.isArray(value.affectedAreas) || value.affectedAreas.length > 32 || value.affectedAreas.some(item => typeof item !== 'string' || !item.trim() || item.length > 256)) errors.push('result.output.productChange affectedAreas is invalid');
45
+ if (!Array.isArray(value.decisions) || value.decisions.length > 32 || value.decisions.some(item => typeof item !== 'string' || !item.trim() || item.length > 1000)) errors.push('result.output.productChange decisions is invalid');
46
+ if (value.question !== undefined && (typeof value.question !== 'string' || value.question.length > 4000)) errors.push('result.output.productChange question exceeds its bound');
47
+ if (value.changeName !== undefined && !namePattern.test(value.changeName)) errors.push('result.output.productChange changeName is invalid');
48
+ if (value.changeId !== undefined && !/^CHG-[A-Z0-9-]+$/.test(value.changeId)) errors.push('result.output.productChange changeId is invalid');
49
+ if (value.proposalDigest !== undefined && !hashPattern.test(value.proposalDigest)) errors.push('result.output.productChange proposalDigest is invalid');
50
+ if (value.baselineDigest !== undefined && !hashPattern.test(value.baselineDigest)) errors.push('result.output.productChange baselineDigest is invalid');
51
+ if (value.outcome === 'candidate') {
52
+ if (!value.changeName || !value.changeId || !value.proposalDigest || !value.baselineDigest || !Array.isArray(value.files)) errors.push('candidate Product Change output needs its change identity, proposal and baseline digests, and files');
53
+ else try {
54
+ const checked = validateProductChangeFiles(value.files, { changeName: value.changeName });
55
+ if (checked.changeId !== value.changeId || checked.digest !== value.proposalDigest) errors.push('candidate Product Change identity or digest does not match its files');
56
+ } catch (error) { errors.push(error.message); }
57
+ if (!exact(value.providerValidation, ['status', 'diagnosticCount']) || value.providerValidation.status !== 'passed' || !Number.isSafeInteger(value.providerValidation.diagnosticCount) || value.providerValidation.diagnosticCount < 0) errors.push('candidate Product Change needs successful provider overlay validation');
58
+ }
59
+ if (value.outcome === 'aligned' && (value.files !== undefined || value.changeName !== undefined || value.changeId !== undefined)) errors.push('aligned Product Change output cannot include a delta');
60
+ if (value.outcome === 'blocked' && (!value.question?.trim() || value.files !== undefined)) errors.push('blocked Product Change output needs one decision question and no candidate files');
61
+ if (value.files !== undefined && value.outcome !== 'candidate') errors.push('only a candidate Product Change can include files');
62
+ return errors;
63
+ }
64
+
65
+ export async function productModelDigest(root) {
66
+ const modelRoot = await productModelDirectory(root);
67
+ const base = resolve(root, modelRoot);
68
+ const files = [];
69
+ async function collect(directory, relative = '') {
70
+ for (const entry of await (await import('node:fs/promises')).readdir(directory, { withFileTypes: true })) {
71
+ const path = relative ? `${relative}/${entry.name}` : entry.name;
72
+ const target = join(directory, entry.name), stat = await lstat(target);
73
+ if (stat.isSymbolicLink() || !stat.isDirectory() && !stat.isFile()) throw Error(`Unsafe accepted product model path: ${path}`);
74
+ if (stat.isDirectory()) await collect(target, path);
75
+ else {
76
+ if (files.length >= 512 || stat.size > 65536) throw Error('Accepted product model exceeds fingerprint bounds');
77
+ files.push({ path, content: await readFile(target) });
78
+ }
79
+ }
80
+ }
81
+ await collect(base);
82
+ return digest(files.sort((a, b) => a.path.localeCompare(b.path)).map(file => `${file.path}\0${sha(file.content)}\n`).join(''));
83
+ }
84
+
85
+ export async function productModelDirectory(root) {
86
+ let productRoot = 'docs/product';
87
+ try {
88
+ const config = await readFile(resolve(root, '.product/config.yaml'), 'utf8');
89
+ const declared = config.match(/^product-root:\s*(.*?)\s*$/m)?.[1]?.replace(/^(?:"(.*)"|'(.*)')$/, (_match, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted);
90
+ if (declared) productRoot = declared;
91
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
92
+ if (!safePath(productRoot) || productRoot.includes('\\') || productRoot.split('/').some(part => part.startsWith('.'))) throw Error('ProductShape product-root is unsafe');
93
+ return `${productRoot}/model`;
94
+ }
95
+
96
+ export function validateProductIdentity(value) {
97
+ if (!exact(value, ['repository', 'issue', 'parentRunId', 'authorizationDigest', 'policyDigest', 'workflowRevision', 'head'])) return ['Product Change identity has unsupported or missing fields'];
98
+ const errors = [];
99
+ if (typeof value.repository !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository)) errors.push('Product Change repository identity is invalid');
100
+ if (!Number.isSafeInteger(value.issue) || value.issue < 1) errors.push('Product Change issue identity is invalid');
101
+ if (typeof value.parentRunId !== 'string' || !value.parentRunId.trim() || value.parentRunId.length > 128) errors.push('Product Change parent run identity is invalid');
102
+ if (!hashPattern.test(value.authorizationDigest ?? '') || !hashPattern.test(value.policyDigest ?? '')) errors.push('Product Change authority and policy digests must be SHA-256');
103
+ if (!revisionPattern.test(value.workflowRevision ?? '') || !revisionPattern.test(value.head ?? '')) errors.push('Product Change revisions must be full commit SHAs');
104
+ return errors;
105
+ }
106
+
107
+ export function validateProductCheckpoint(value) {
108
+ const fields = ['schemaVersion', 'kind', 'identity', 'requestDigest', 'changeName', 'changeId', 'proposalDigest', 'baselineDigest', 'state', 'effectIds'];
109
+ const optional = ['pendingQuestion'];
110
+ if (!exact(value, [...fields, ...optional.filter(key => Object.hasOwn(value ?? {}, key))])) return ['Product Change checkpoint has unsupported or missing fields'];
111
+ const errors = [...validateProductIdentity(value.identity)];
112
+ if (value.schemaVersion !== 1 || value.kind !== 'checkpoint') errors.push('Product Change checkpoint identity is unsupported');
113
+ if (!namePattern.test(value.changeName ?? '') || !/^CHG-[A-Z0-9-]{1,156}$/.test(value.changeId ?? '')) errors.push('Product Change checkpoint identity is invalid');
114
+ for (const field of ['requestDigest', 'proposalDigest', 'baselineDigest']) if (!hashPattern.test(value[field] ?? '')) errors.push(`${field} must be SHA-256`);
115
+ if (!['preparing', 'waiting-product-decision', 'approved', 'applying', 'applied', 'blocked'].includes(value.state)) errors.push('Product Change checkpoint state is unsupported');
116
+ if (!Array.isArray(value.effectIds) || value.effectIds.length > 32 || value.effectIds.some(item => typeof item !== 'string' || !item.trim() || item.length > 256) || new Set(value.effectIds).size !== value.effectIds.length) errors.push('Product Change checkpoint effectIds are invalid');
117
+ if (value.pendingQuestion !== undefined && (typeof value.pendingQuestion !== 'string' || value.pendingQuestion.length > 4000)) errors.push('Product Change checkpoint pendingQuestion exceeds its bound');
118
+ return errors;
119
+ }
120
+
121
+ function parseStatus(path, content) {
122
+ if (!path.endsWith('/product-change/change.md')) return null;
123
+ const frontmatter = content.match(/^---\s*\n([\s\S]*?)\n---(?:\n|$)/);
124
+ if (!frontmatter) throw Error('Product Change manifest needs YAML frontmatter');
125
+ const status = frontmatter[1].match(/^status:\s*([A-Za-z-]+)\s*$/m)?.[1];
126
+ if (!status) throw Error('Product Change manifest needs a status');
127
+ if (!['draft', 'in-review', 'rejected', 'superseded'].includes(status)) throw Error(`Worker output cannot set Product Change status '${status}'`);
128
+ const changeId = frontmatter[1].match(/^id:\s*([A-Z0-9-]+)\s*$/m)?.[1];
129
+ if (!/^CHG-[A-Z0-9-]+$/.test(changeId ?? '')) throw Error('Product Change manifest needs a valid CHG ID');
130
+ return { status, changeId };
131
+ }
132
+
133
+ export function validateProductChangeFiles(files, { changeName, root = 'openspec/changes' } = {}) {
134
+ if (!namePattern.test(changeName ?? '')) throw Error('Product Change name must be lowercase kebab-case');
135
+ if (!Array.isArray(files) || !files.length || files.length > PRODUCT_FILE_LIMITS.files) throw Error('Product Change candidate must contain a bounded non-empty file list');
136
+ const seen = new Set();
137
+ let total = 0, manifest;
138
+ const base = `${root}/${changeName}/`;
139
+ for (const file of files) {
140
+ if (!exact(file, ['path', 'content', ...(['mode'].filter(key => Object.hasOwn(file ?? {}, key))) ])) throw Error('Product Change candidate file has unsupported or missing fields');
141
+ if (!safePath(file.path) || !file.path.startsWith(base)) throw Error(`Product Change file is outside its hosted change: ${file.path}`);
142
+ if (seen.has(file.path)) throw Error(`Duplicate Product Change path: ${file.path}`);
143
+ seen.add(file.path);
144
+ if (file.mode !== undefined && file.mode !== '100644' && file.mode !== '100755') throw Error(`Product Change file mode is not regular: ${file.path}`);
145
+ const relative = file.path.slice(base.length);
146
+ const allowed = relative === '.openspec.yaml' || relative === 'proposal.md' || relative === 'product-change/change.md' || relative === 'product-change/impact.json' || /^product-change\/proposed\/[A-Za-z0-9._/-]+\.(?:md|json|ya?ml)$/.test(relative);
147
+ if (!allowed || relative.split('/').some(part => part.startsWith('.'))) throw Error(`Unsupported Product Change file: ${file.path}`);
148
+ if (file.path.startsWith('docs/product/model/')) throw Error('Product Change candidates cannot write accepted model artifacts');
149
+ if (typeof file.content !== 'string') throw Error(`Product Change file content must be text: ${file.path}`);
150
+ const bytes = Buffer.byteLength(file.content, 'utf8');
151
+ if (bytes > PRODUCT_FILE_LIMITS.fileBytes) throw Error(`Product Change file exceeds its size bound: ${file.path}`);
152
+ total += bytes;
153
+ if (total > PRODUCT_FILE_LIMITS.totalBytes) throw Error('Product Change candidate exceeds its total size bound');
154
+ const parsed = parseStatus(file.path, file.content);
155
+ if (parsed) manifest = parsed;
156
+ }
157
+ if (!seen.has(`${base}.openspec.yaml`) || !manifest) throw Error('Product Change candidate needs its OpenSpec schema pin and provider manifest');
158
+ const spec = files.find(file => file.path === `${base}.openspec.yaml`)?.content;
159
+ if (!/^schema:\s*product-change\s*$/m.test(spec ?? '')) throw Error('Product Change must use the installed product-change schema');
160
+ if (!files.some(file => file.path.startsWith(`${base}product-change/proposed/`))) throw Error('Product Change candidate must include proposed provider artifacts');
161
+ const digestValue = sha([...files].sort((a, b) => a.path.localeCompare(b.path)).map(file => `${file.path}\0${sha(file.content)}\n`).join(''));
162
+ return { files: files.length, bytes: total, ...manifest, digest: digestValue };
163
+ }
164
+
165
+ export async function validateProductWorkspace(root, changeName, bridge) {
166
+ if (!bridge || typeof bridge.validateOpenSpecProductChange !== 'function') throw Error('Pinned ProductShape OpenSpec bridge is unavailable');
167
+ const result = await bridge.validateOpenSpecProductChange(resolve(root), changeName);
168
+ const blocking = result.blocking ?? [];
169
+ if (blocking.length) throw Error(`Product Change overlay validation failed: ${blocking.map(item => item.message ?? item.code).join('; ')}`);
170
+ return result;
171
+ }
172
+
173
+ export async function prepareProductChange(root, changeName, files, bridge) {
174
+ if (!namePattern.test(changeName ?? '')) throw Error('Product Change name must be lowercase kebab-case');
175
+ const temp = await mkdtemp(join(tmpdir(), 'lighthouse-product-workflow-'));
176
+ try {
177
+ const productRoot = dirname(await productModelDirectory(root));
178
+ for (const relative of [productRoot, '.product/config.yaml', 'openspec/config.yaml', 'openspec/schemas/product-change']) {
179
+ const source = join(root, relative), target = join(temp, relative);
180
+ await mkdir(dirname(target), { recursive: true });
181
+ await cp(source, target, { recursive: true, errorOnExist: true, force: false });
182
+ }
183
+ openspec(temp, ['new', 'change', changeName, '--schema', 'product-change', '--json']);
184
+ const rootPath = `openspec/changes/${changeName}`;
185
+ if (!files) {
186
+ const instructions = {};
187
+ for (const artifact of ['intent', 'delta']) {
188
+ const value = JSON.parse(openspec(temp, ['instructions', artifact, '--change', changeName, '--json']));
189
+ instructions[artifact] = { outputPath: value.outputPath, instruction: value.instruction, template: value.template };
190
+ }
191
+ return { provider: '@fission-ai/openspec@1.15.0', schema: 'product-change', root: rootPath, changeName, instructions };
192
+ }
193
+ const summary = validateProductChangeFiles(files, { changeName });
194
+ for (const file of files) {
195
+ const target = join(temp, file.path);
196
+ await mkdir(dirname(target), { recursive: true });
197
+ await writeFile(target, file.content);
198
+ }
199
+ openspec(temp, ['validate', changeName, '--strict', '--no-interactive']);
200
+ const validation = bridge ? await validateProductWorkspace(temp, changeName, bridge) : null;
201
+ const instructions = {};
202
+ for (const artifact of ['intent', 'delta']) {
203
+ const value = JSON.parse(openspec(temp, ['instructions', artifact, '--change', changeName, '--json']));
204
+ instructions[artifact] = { outputPath: value.outputPath, instruction: value.instruction, template: value.template };
205
+ }
206
+ if (bridge && validation) return { ...summary, provider: '@prodshape/integration-openspec', root: rootPath, openSpec: 'passed', diagnostics: validation.diagnostics, instructions, files };
207
+ return { ...summary, root: rootPath, openSpec: 'passed', provider: 'unavailable', instructions, files };
208
+ } finally { await rm(temp, { recursive: true, force: true }); }
209
+ }
210
+
211
+ export async function loadProductBridge({ entry = process.env.PRODSHAPE_INTEGRATION_OPENSPEC_ENTRY } = {}) {
212
+ const spec = entry ?? require.resolve('@prodshape/integration-openspec');
213
+ const bridge = await import(spec.startsWith('/') ? pathToFileURL(spec).href : spec);
214
+ for (const name of ['validateOpenSpecProductChange', 'applyOpenSpecProductChange']) if (typeof bridge[name] !== 'function') throw Error(`Pinned ProductShape bridge does not provide ${name}`);
215
+ return bridge;
216
+ }
217
+
218
+ export function productReviewBinding(input) {
219
+ const fields = ['repository', 'issue', 'pullRequest', 'changeName', 'changeId', 'proposalDigest', 'baselineDigest', 'policyDigest', 'authorityDigest', 'head'];
220
+ if (!exact(input, fields)) throw Error('Product review binding has unsupported or missing fields');
221
+ if (typeof input.repository !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(input.repository) || !Number.isSafeInteger(input.issue) || input.issue < 1 || !Number.isSafeInteger(input.pullRequest) || input.pullRequest < 1) throw Error('Product review target is invalid');
222
+ if (!namePattern.test(input.changeName) || !/^CHG-[A-Z0-9-]+$/.test(input.changeId)) throw Error('Product review change identity is invalid');
223
+ for (const field of ['proposalDigest', 'baselineDigest', 'policyDigest', 'authorityDigest']) if (!hashPattern.test(input[field] ?? '')) throw Error(`Product review ${field} is invalid`);
224
+ if (!revisionPattern.test(input.head ?? '')) throw Error('Product review head must be a full commit SHA');
225
+ const key = digest(JSON.stringify(input));
226
+ return { ...input, key };
227
+ }
228
+
229
+ export function parseProductDecision(text) {
230
+ const match = typeof text === 'string' && text.trim().match(/^\/product-change (approve|request-changes|answer) ([a-z0-9]+(?:-[a-z0-9]+)*) ([a-f0-9]{64})(?:\n([\s\S]{1,4000}))?$/);
231
+ if (!match) return null;
232
+ return { action: match[1], changeName: match[2], reviewKey: match[3], detail: match[4]?.trim() ?? '' };
233
+ }
234
+
235
+ export function verifyProductDecision({ decision, actor, owner, actorType = 'User', source, submitted = true, currentBinding, observedBinding, decisionId }) {
236
+ const errors = [];
237
+ if (!decision || !['approve', 'request-changes', 'answer'].includes(decision.action)) errors.push('Explicit Product Change action is required');
238
+ if (actor !== owner || actorType !== 'User') errors.push('Product Change decision must come from the authenticated repository owner');
239
+ if (!['pull-request-comment', 'pull-request-review', 'issue-comment'].includes(source) || submitted !== true) errors.push('Product Change decision must come from a newly submitted supported GitHub event');
240
+ if (!currentBinding || !observedBinding || currentBinding.key !== observedBinding.key || currentBinding.key !== decision?.reviewKey || currentBinding.changeName !== decision?.changeName) errors.push('Product Change decision does not match the current reviewed binding');
241
+ if (!decisionId || typeof decisionId !== 'string') errors.push('Product Change decision event identity is missing');
242
+ if (errors.length) throw Error(errors.join('; '));
243
+ return { schemaVersion: 1, kind: 'approval', actor, source, reviewKey: currentBinding.key, changeId: currentBinding.changeId, proposalDigest: currentBinding.proposalDigest, baselineDigest: currentBinding.baselineDigest, policyDigest: currentBinding.policyDigest, head: currentBinding.head, decisionId, observedAt: new Date().toISOString() };
244
+ }
245
+
246
+ export function authorizeProductApply({ checkpoint, approval, currentBinding, currentIdentity }) {
247
+ const errors = [...validateProductCheckpoint(checkpoint)];
248
+ if (!approval || approval.kind !== 'approval' || approval.schemaVersion !== 1) errors.push('Authenticated Product Change approval is required');
249
+ if (!currentBinding || !currentIdentity) errors.push('Fresh product authority and review observations are required');
250
+ else {
251
+ const { key: currentKey, ...bindingFields } = currentBinding;
252
+ const fresh = productReviewBinding(bindingFields);
253
+ if (currentKey !== fresh.key) errors.push('Current Product Change review binding is malformed');
254
+ if (fresh.key !== approval?.reviewKey) errors.push('Product Change approval is stale');
255
+ if (currentIdentity.repository !== checkpoint.identity.repository || currentIdentity.issue !== checkpoint.identity.issue || currentIdentity.parentRunId !== checkpoint.identity.parentRunId || currentIdentity.authorizationDigest !== checkpoint.identity.authorizationDigest || currentIdentity.policyDigest !== checkpoint.identity.policyDigest || currentIdentity.workflowRevision !== checkpoint.identity.workflowRevision || currentIdentity.head !== checkpoint.identity.head) errors.push('Product Change parent authority or branch head changed');
256
+ }
257
+ if (checkpoint.changeId !== approval?.changeId || checkpoint.proposalDigest !== approval?.proposalDigest || checkpoint.baselineDigest !== approval?.baselineDigest || checkpoint.state !== 'waiting-product-decision') errors.push('Product Change checkpoint does not match the approved candidate');
258
+ if (errors.length) throw Error(errors.join('; '));
259
+ return { intentId: digest(`${checkpoint.identity.repository}:${checkpoint.identity.issue}:${checkpoint.changeId}:${approval.reviewKey}:apply`), approvalDigest: digest(JSON.stringify(approval)) };
260
+ }
261
+
262
+ export function createProductApplyReceipt({ intent, approval, checkpoint, headAfter, resultingModelDigest, validated, appliedAt = new Date().toISOString() }) {
263
+ if (!intent || intent.intentId !== digest(`${checkpoint.identity.repository}:${checkpoint.identity.issue}:${checkpoint.changeId}:${approval.reviewKey}:apply`) || intent.approvalDigest !== digest(JSON.stringify(approval))) throw Error('Product apply intent does not match its approval');
264
+ if (!revisionPattern.test(headAfter ?? '') || !hashPattern.test(resultingModelDigest ?? '') || validated !== true || !Number.isFinite(Date.parse(appliedAt))) throw Error('Product apply result is not validated');
265
+ return { schemaVersion: 1, kind: 'apply-receipt', intentId: intent.intentId, approvalDigest: intent.approvalDigest, changeId: checkpoint.changeId, headBefore: checkpoint.identity.head, headAfter, proposalDigest: checkpoint.proposalDigest, baselineDigest: checkpoint.baselineDigest, resultingModelDigest, validated: true, appliedAt };
266
+ }
267
+
268
+ export function verifyProductChangePath(root, relativePath) {
269
+ if (!safePath(relativePath)) throw Error('Invalid Product Change path');
270
+ const absolute = resolve(root, relativePath);
271
+ const prefix = `${resolve(root)}${sep}`;
272
+ if (!absolute.startsWith(prefix)) throw Error('Product Change path escapes its workspace');
273
+ return lstat(absolute).then(stat => {
274
+ if (!stat.isFile() || stat.isSymbolicLink()) throw Error(`Product Change path is not a regular file: ${relativePath}`);
275
+ return absolute;
276
+ });
277
+ }
@@ -21,7 +21,7 @@ export function checkProposal(pr, repo, expected) {
21
21
  export function checkImplementationPullRequest(pr, repo, expected) {
22
22
  const match = pr.head?.ref?.match(/^lighthouse\/implement-([1-9][0-9]*)-([a-f0-9]{12})$/);
23
23
  const marker = pr.body?.match(/<!-- lighthouse-implementation:([a-f0-9]{64}) -->/)?.[1];
24
- if (!match || !marker?.startsWith(match[2]) || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo) throw Error('Not an open repository-owned implementation PR');
24
+ if (!match || pr.body?.includes('<!-- lighthouse-product-change:') || !marker?.startsWith(match[2]) || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo) throw Error('Not an open repository-owned implementation PR');
25
25
  if (expected && (pr.head.sha !== expected.head || pr.head.ref !== expected.branch || Number(match[1]) !== expected.issue || pr.base.ref !== expected.base)) throw Error('Implementation changed during review');
26
26
  return { number: pr.number, issue: Number(match[1]), head: pr.head.sha, branch: pr.head.ref, base: pr.base.ref, url: pr.html_url };
27
27
  }