@juangcarmona/lighthouse-sdlc 0.17.1 → 0.18.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 (61) hide show
  1. package/README.md +15 -3
  2. package/capabilities.json +2 -1
  3. package/epic-delivery.md +35 -0
  4. package/github-actions.md +21 -3
  5. package/host-contract.md +2 -0
  6. package/package.json +4 -1
  7. package/schemas/product-change.schema.json +23 -4
  8. package/schemas/stage-result.schema.json +23 -1
  9. package/skills/product-change/SKILL.md +3 -3
  10. package/skills/refine/SKILL.md +3 -2
  11. package/src/completion-actions.mjs +49 -12
  12. package/src/completion.mjs +1 -1
  13. package/src/contracts.mjs +10 -0
  14. package/src/decompose.mjs +5 -2
  15. package/src/epic-dispatch.mjs +59 -0
  16. package/src/epic-github.mjs +111 -0
  17. package/src/epic-journal.mjs +104 -0
  18. package/src/epic-merge.mjs +91 -0
  19. package/src/epic-native.mjs +465 -0
  20. package/src/epic.mjs +86 -0
  21. package/src/handoff.mjs +97 -0
  22. package/src/host-contract.mjs +22 -6
  23. package/src/implementation.mjs +33 -5
  24. package/src/install-workflows.mjs +1 -1
  25. package/src/issue-actions.mjs +368 -78
  26. package/src/opencode-execute.mjs +20 -0
  27. package/src/pr-state-actions.mjs +21 -7
  28. package/src/pr-state.mjs +18 -1
  29. package/src/product-change-actions.mjs +158 -46
  30. package/src/product-change.mjs +92 -11
  31. package/src/proposal-review.mjs +16 -6
  32. package/src/proposal.mjs +13 -1
  33. package/src/stacked.mjs +14 -3
  34. package/src/stage-dispatch.mjs +66 -19
  35. package/src/stage-transition.mjs +151 -2
  36. package/src/triage.mjs +4 -2
  37. package/src/unattended-actions.mjs +39 -12
  38. package/src/unattended.mjs +1 -1
  39. package/src/worker-maintenance.mjs +15 -3
  40. package/src/worker-recovery.mjs +209 -8
  41. package/workflows/lighthouse-sdlc-apply-product-change.lock.yml +97 -11
  42. package/workflows/lighthouse-sdlc-apply-product-change.md +75 -6
  43. package/workflows/lighthouse-sdlc-apply-review.lock.yml +33 -2
  44. package/workflows/lighthouse-sdlc-apply-review.md +29 -1
  45. package/workflows/lighthouse-sdlc-epic.yml +79 -0
  46. package/workflows/lighthouse-sdlc-implement.lock.yml +38 -2
  47. package/workflows/lighthouse-sdlc-implement.md +33 -2
  48. package/workflows/lighthouse-sdlc-integrate.lock.yml +4 -1
  49. package/workflows/lighthouse-sdlc-integrate.md +3 -0
  50. package/workflows/lighthouse-sdlc-product-change.lock.yml +39 -4
  51. package/workflows/lighthouse-sdlc-product-change.md +39 -6
  52. package/workflows/lighthouse-sdlc-propose.lock.yml +13 -1
  53. package/workflows/lighthouse-sdlc-propose.md +11 -1
  54. package/workflows/lighthouse-sdlc-refine.lock.yml +29 -2
  55. package/workflows/lighthouse-sdlc-refine.md +25 -2
  56. package/workflows/lighthouse-sdlc-review.lock.yml +4 -1
  57. package/workflows/lighthouse-sdlc-review.md +3 -0
  58. package/workflows/lighthouse-sdlc-router.yml +47 -3
  59. package/workflows/lighthouse-sdlc-triage.lock.yml +30 -3
  60. package/workflows/lighthouse-sdlc-triage.md +26 -3
  61. package/workflows/lighthouse-worker-maintenance.yml +70 -1
@@ -37,6 +37,22 @@ export async function snapshotSource(root, destination) {
37
37
  await mkdir(dirname(target), { recursive: true }); await copyFile(source, target); await chmod(target, stat.mode & 0o111 ? 0o555 : 0o444);
38
38
  }
39
39
  }
40
+ export async function installProductEvidence(input, source, expectedBaseline) {
41
+ const stat = await lstat(input);
42
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('ProductShape evidence directory is unavailable or unsafe');
43
+ const names = await readdir(input);
44
+ if (!names.includes('index.json') || names.length > 513 || names.some(name => name !== 'index.json' && !/^[A-Z0-9][A-Z0-9-]{0,159}\.json$/.test(name))) throw Error('ProductShape evidence inventory is invalid');
45
+ const index = JSON.parse(await readFile(join(input, 'index.json'), 'utf8'));
46
+ if (index.schemaVersion !== 1 || index.source !== '@prodshape/core' || index.version !== '0.25.0' || expectedBaseline && index.baselineDigest !== expectedBaseline || !Array.isArray(index.artifacts) || index.artifacts.length !== names.length - 1 || new Set(index.artifacts.map(item => item.report)).size !== index.artifacts.length || index.artifacts.some(item => !names.includes(item.report) || item.report !== `${item.id}.json`)) throw Error('ProductShape evidence index is invalid');
47
+ let bytes = 0;
48
+ const destination = join(source, '.lighthouse-product-evidence');
49
+ await mkdir(destination, { mode: 0o755 });
50
+ for (const name of names) {
51
+ const file = join(input, name), item = await lstat(file);
52
+ if (!item.isFile() || item.isSymbolicLink() || item.size > 512 * 1024 || (bytes += item.size) > 17 * 1024 * 1024) throw Error('ProductShape evidence is unsafe or exceeds bounds');
53
+ await copyFile(file, join(destination, name)); await chmod(join(destination, name), 0o444);
54
+ }
55
+ }
40
56
  export function acquireEngineSlot(path, seconds = 240) {
41
57
  if (!isAbsolute(path ?? '')) throw Error('Operator engine lock must be an absolute path');
42
58
  const fd = openSync(path, 'a', 0o600);
@@ -90,6 +106,10 @@ async function main() {
90
106
  if (cancelled) throw Error('Execution cancelled before agent startup');
91
107
  console.log('Engine slot acquired; starting isolated OpenCode');
92
108
  await snapshotSource(env.GITHUB_WORKSPACE, source);
109
+ if (request.worker === 'product-change') {
110
+ if (!/^[a-f0-9]{64}$/.test(request.baselineDigest ?? '')) throw Error('ProductShape evidence lacks its authenticated baseline');
111
+ await installProductEvidence(join(runDir, 'product-evidence'), source, request.baselineDigest);
112
+ }
93
113
  baseline = implementing ? await sourceFiles(source) : null;
94
114
  trackedSource = baseline ? new Map(baseline) : null;
95
115
  if (implementing) {
@@ -1,6 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { checkImplementationPullRequest } from './proposal-review.mjs';
3
- import { authorizedCiCallback, projectImplementationPrState, reconcileImplementationPr, scheduledImplementationPulls, setPullRequestDraft } from './pr-state.mjs';
3
+ import { authorizedCiCallback, productOnlyCheckpoint, isStaleProductDecision, projectImplementationPrState, reconcileImplementationPr, scheduledImplementationPulls, setPullRequestDraft } from './pr-state.mjs';
4
+ import { handoff } from './handoff.mjs';
5
+ import { productProposalProof } from './product-change.mjs';
4
6
 
5
7
  const apiFor = (repo, token) => async (path, method = 'GET', body, optional = false) => {
6
8
  const response = await fetch(`https://api.github.com/repos/${repo}${path ? `/${path}` : ''}`, {
@@ -69,14 +71,27 @@ async function main() {
69
71
  let target;
70
72
  try { target = checkImplementationPullRequest({ ...pr, state: 'open', merged: false }, repo); }
71
73
  catch {
72
- const match = pr.head.ref.match(/^lighthouse\/implement-([1-9][0-9]*)-[a-f0-9]{12}$/);
74
+ if (productProposalProof(pr)) continue;
75
+ const product = productOnlyCheckpoint(pr);
76
+ if (product) {
77
+ if (!isStaleProductDecision(pr) || product.identity.repository !== repo || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo || pr.state !== 'open') continue;
78
+ const issue = await api(`issues/${product.identity.issue}`);
79
+ if (issue.state !== 'open') continue;
80
+ if (!pr.draft) await setPullRequestDraft(env.GH_TOKEN, pr, true);
81
+ await projectImplementationPrState(api, issue.number, true);
82
+ const notice = `<!-- lighthouse-stale-product:${pr.number}:${pr.head.sha} -->`;
83
+ if (!(await commentsFor(api, issue.number)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(notice))) await api(`issues/${issue.number}/comments`, 'POST', { body: handoff({ status: 'blocked', stage: 'product-change', happened: `The product decision for PR #${pr.number} no longer matches its current head.`, action: 'Workflow maintainer: revalidate the Product Change and request a new decision for the current PR.', next: 'Product apply and parent continuation remain stopped until current evidence and approval are available.', link: `[Open PR #${pr.number}](${pr.html_url})`, details: notice }) });
84
+ continue;
85
+ }
86
+ const match = pr.head.ref.match(/^lighthouse\/(?:implement|propose)-([1-9][0-9]*)-[a-f0-9]{12}$/);
87
+ if (pr.head.ref.startsWith('lighthouse/propose-') && !pr.body?.includes('<!-- lighthouse-product-implementation:')) continue;
73
88
  if (!match || pr.user?.login !== 'github-actions[bot]' || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo || pr.state !== 'open') continue;
74
89
  const issue = await api(`issues/${match[1]}`);
75
90
  if (issue.state !== 'open') continue;
76
91
  if (!pr.draft) await setPullRequestDraft(env.GH_TOKEN, pr, true);
77
92
  await projectImplementationPrState(api, issue.number, true);
78
93
  const notice = `<!-- lighthouse-pr-link-changed:${pr.number}:${pr.head.sha} -->`;
79
- if (!(await commentsFor(api, issue.number)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(notice))) await api(`issues/${issue.number}/comments`, 'POST', { body: `PR #${pr.number} is back in draft because its Lighthouse link changed. Restore the PR description before review. [Open PR](${pr.html_url}).\n\n${notice}` });
94
+ if (!(await commentsFor(api, issue.number)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(notice))) await api(`issues/${issue.number}/comments`, 'POST', { body: handoff({ status: 'blocked', stage: 'implement', happened: `PR #${pr.number} is back in draft because its Lighthouse link changed.`, action: 'The PR owner must restore the link in the PR description.', next: 'The workflow can recheck the PR after the link is restored.', link: `[Open PR](${pr.html_url})`, details: notice }) });
80
95
  continue;
81
96
  }
82
97
  const issue = await api(`issues/${target.issue}`);
@@ -84,23 +99,22 @@ async function main() {
84
99
  if (pr.state !== 'open' || pr.merged) {
85
100
  for (const name of ['pr-draft', 'pr-ready']) if (issue.labels.some(label => label.name === name)) await api(`issues/${issue.number}/labels/${name}`, 'DELETE', undefined, true);
86
101
  if (!pr.merged) {
87
- await api(`issues/${issue.number}/labels`, 'POST', { labels: ['waiting-review'] });
88
102
  const notice = `<!-- lighthouse-pr-closed:${pr.number}:${pr.head.sha} -->`;
89
- if (!(await commentsFor(api, issue.number)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(notice))) await api(`issues/${issue.number}/comments`, 'POST', { body: `PR #${pr.number} was closed without merging. Reopen it or request a new implementation. [Open PR](${pr.html_url}).\n\n${notice}` });
103
+ if (!(await commentsFor(api, issue.number)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(notice))) await api(`issues/${issue.number}/comments`, 'POST', { body: handoff({ status: 'blocked', stage: 'implement', happened: `PR #${pr.number} was closed without merging.`, action: 'The owner can reopen the PR or request a new implementation.', next: 'No implementation resumes until an authorized request is made.', link: `[Open PR](${pr.html_url})`, details: notice }) });
90
104
  }
91
105
  continue;
92
106
  }
93
107
  const comments = await commentsFor(api, target.issue);
94
108
  if (event.action === 'converted_to_draft') {
95
109
  const hold = `<!-- lighthouse-pr-hold:${pr.number}:${pr.head.sha} -->`;
96
- if (!comments.some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(hold))) await api(`issues/${target.issue}/comments`, 'POST', { body: `PR #${pr.number} is a draft by owner choice. [Open PR](${pr.html_url}).\n\n${hold}` });
97
110
  await projectImplementationPrState(api, target.issue, true);
111
+ if (!comments.some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(hold))) await api(`issues/${target.issue}/comments`, 'POST', { body: handoff({ status: 'blocked', stage: 'implement', happened: `PR #${pr.number} is a draft by owner choice.`, action: 'The PR owner can mark it ready when appropriate.', next: 'Automatic readiness remains on hold until the owner changes the PR state.', link: `[Open PR](${pr.html_url})`, details: hold }) });
98
112
  continue;
99
113
  }
100
114
  const hold = comments.some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(`<!-- lighthouse-pr-hold:${pr.number}:${pr.head.sha} -->`));
101
115
  const auto = (['workflow_run', 'schedule'].includes(env.GITHUB_EVENT_NAME) || ciCallback) && !hold;
102
116
  const result = await reconcileImplementationPr(api, repo, pr, issue, comments, requiredChecks(profile), (current, draft) => setPullRequestDraft(env.GH_TOKEN, current, draft), { auto });
103
- if (event.action === 'ready_for_review' && result.status === 'draft') await api(`issues/${target.issue}/comments`, 'POST', { body: `PR #${pr.number} remains a draft: ${result.reason}. [Open PR](${pr.html_url}).` });
117
+ if (event.action === 'ready_for_review' && result.status === 'draft') await api(`issues/${target.issue}/comments`, 'POST', { body: handoff({ status: 'blocked', stage: 'implement', happened: `PR #${pr.number} remains a draft: ${result.reason}.`, action: 'The PR owner should resolve the stated blocker.', next: 'The workflow rechecks readiness when the relevant checks or PR state change.', link: `[Open PR](${pr.html_url})` }) });
104
118
  }
105
119
  }
106
120
  main().catch(error => { console.error(`::error::${error.message}`); process.exitCode = 1; });
package/src/pr-state.mjs CHANGED
@@ -1,13 +1,30 @@
1
1
  import { checkImplementationPullRequest } from './proposal-review.mjs';
2
2
  import { checkCommit } from './completion.mjs';
3
+ import { validateProductCheckpoint, productProposalProof } from './product-change.mjs';
3
4
 
4
5
  const marker = /<!-- lighthouse-pr-state:(\{[^\n]*\}) -->/;
5
6
 
7
+ export function productOnlyCheckpoint(pr) {
8
+ if (pr?.body?.includes('<!-- lighthouse-product-implementation:') || productProposalProof(pr)) return null;
9
+ const matches = [...(pr?.body ?? '').matchAll(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):(\{[^\n]*\}) -->/g)];
10
+ if (matches.length !== 1) return null;
11
+ try {
12
+ const checkpoint = JSON.parse(matches[0][1]);
13
+ const branch = pr.head?.ref?.match(/^lighthouse\/(?:implement|propose|product-change)-([1-9][0-9]*)-[a-f0-9]{12}$/);
14
+ if (validateProductCheckpoint(checkpoint).length || !branch || Number(branch[1]) !== checkpoint.identity.issue || checkpoint.identity.repository !== pr.head?.repo?.full_name || checkpoint.identity.repository !== pr.base?.repo?.full_name || !Number.isSafeInteger(pr.number)) return null;
15
+ if (checkpoint.state === 'waiting-product-decision' && checkpoint.effectIds.length === 1 && checkpoint.effectIds[0] === `pr:${pr.number}:${checkpoint.identity.head}`) return checkpoint;
16
+ if (checkpoint.state === 'applied' && checkpoint.effectIds.some(id => /^apply:[a-f0-9]{64}:/.test(id) && id.endsWith(`:${checkpoint.identity.head}`))) return checkpoint;
17
+ return null;
18
+ } catch { return null; }
19
+ }
20
+ export const isProductOnlyPullRequest = pr => !!productOnlyCheckpoint(pr);
21
+ export const isStaleProductDecision = pr => { const checkpoint = productOnlyCheckpoint(pr); return !!checkpoint && checkpoint.identity.head !== pr.head?.sha; };
22
+
6
23
  export async function scheduledImplementationPulls(api) {
7
24
  const numbers = [];
8
25
  for (let page = 1; page <= 10; page++) {
9
26
  const pulls = await api(`pulls?state=open&per_page=100&page=${page}`);
10
- numbers.push(...pulls.filter(pr => /^lighthouse\/implement-[1-9][0-9]*-[a-f0-9]{12}$/.test(pr.head?.ref ?? '') && pr.user?.login === 'github-actions[bot]').map(pr => pr.number));
27
+ numbers.push(...pulls.filter(pr => /^lighthouse\/(?:implement|propose|product-change)-[1-9][0-9]*-[a-f0-9]{12}$/.test(pr.head?.ref ?? '') && pr.user?.login === 'github-actions[bot]' && !productProposalProof(pr) && (isProductOnlyPullRequest(pr) ? isStaleProductDecision(pr) : pr.head.ref.startsWith('lighthouse/implement-') || pr.body?.includes('<!-- lighthouse-product-implementation:'))).map(pr => pr.number));
11
28
  if (pulls.length < 100) return numbers;
12
29
  }
13
30
  throw Error('Open PR history exceeds reconciliation bound');
@@ -3,10 +3,25 @@ import { dirname, join } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { createHash } from 'node:crypto';
6
+ import { isDeepStrictEqual } from 'node:util';
6
7
  import { validateProductChangeFiles, validateProductCheckpoint, validateProductWorkspace, loadProductBridge, productReviewBinding, parseProductDecision, verifyProductDecision, authorizeProductApply, createProductApplyReceipt, productModelDigest, productModelDirectory } from './product-change.mjs';
7
- import { checkImplementationPullRequest } from './proposal-review.mjs';
8
+ import { checkImplementationPullRequest, checkProposal } from './proposal-review.mjs';
9
+ import { handoff } from './handoff.mjs';
10
+
11
+ const marker = '<!-- lighthouse-product-change-';
12
+ const digest = value => createHash('sha256').update(value).digest('hex');
13
+ export function preparePendingProductCandidate(product) {
14
+ if (product.providerValidation?.status !== 'pending') return product;
15
+ validateProductChangeFiles(product.files, { changeName: product.changeName });
16
+ const path = `openspec/changes/${product.changeName}/.openspec.yaml`;
17
+ const metadata = product.files.find(file => file.path === path);
18
+ const flags = [...metadata.content.matchAll(/^\s*skip_specs\s*:\s*(.*?)\s*$/gm)];
19
+ if (flags.length > 1 || flags.length === 1 && !/^true(?:\s+#.*)?$/.test(flags[0][1])) throw Error('Product Change schema pin has conflicting skip_specs metadata');
20
+ if (flags.length) return product;
21
+ if (product.proposalDigest !== undefined) throw Error('Cannot normalize a Product Change with a supplied proposal digest');
22
+ return { ...product, files: product.files.map(file => file.path === path ? { ...file, content: `${file.content.trimEnd()}\nskip_specs: true\n` } : file) };
23
+ }
8
24
 
9
- const marker = '<!-- lighthouse-product-change:';
10
25
  export async function validateProductCandidate(root, changeName, files, bridge) {
11
26
  const summary = validateProductChangeFiles(files, { changeName });
12
27
  const temp = await mkdtemp(join(tmpdir(), 'lighthouse-product-change-'));
@@ -38,6 +53,13 @@ export async function validateProductCandidate(root, changeName, files, bridge)
38
53
  } finally { await rm(temp, { recursive: true, force: true }); }
39
54
  }
40
55
 
56
+ export function bindValidatedProductCandidate(product, validation, baselineDigest) {
57
+ if (validation.provider !== 'passed' || validation.openSpec !== 'passed' || !/^[a-f0-9]{64}$/.test(validation.digest ?? '') || !Number.isSafeInteger(validation.diagnosticCount) || validation.diagnosticCount < 0 || validation.changeId !== product.changeId || validation.baselineDigest !== baselineDigest || product.baselineDigest !== baselineDigest || product.proposalDigest !== undefined && product.proposalDigest !== validation.digest) throw Error('Product Change candidate or baseline changed during validation');
58
+ if (product.providerValidation?.status === 'passed' && product.providerValidation.diagnosticCount !== validation.diagnosticCount) throw Error('Product Change provider validation evidence does not match the host result');
59
+ if (!['pending', 'passed'].includes(product.providerValidation?.status)) throw Error('Product Change provider validation state is invalid');
60
+ return { ...product, proposalDigest: validation.digest, providerValidation: { status: 'passed', diagnosticCount: validation.diagnosticCount } };
61
+ }
62
+
41
63
  export async function loadProductCandidateFromPullRequest(api, repository, pr, changeName) {
42
64
  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
65
  const changed = await api(`pulls/${pr.number}/files?per_page=100`);
@@ -53,7 +75,7 @@ export async function loadProductCandidateFromPullRequest(api, repository, pr, c
53
75
  return { files, ...checked };
54
76
  }
55
77
 
56
- export async function productModelDigestFromApi(api, revision) {
78
+ export async function productModelDigestFromApi(api, revision, overrides = []) {
57
79
  const tree = await api(`git/trees/${revision}?recursive=1`);
58
80
  if (tree.truncated || !Array.isArray(tree.tree) || tree.tree.length > 10000) throw Error('Accepted product model tree exceeds the API bound');
59
81
  let modelRoot = 'docs/product/model';
@@ -66,27 +88,37 @@ export async function productModelDigestFromApi(api, revision) {
66
88
  if (!safeRelative(modelRoot) || modelRoot.split('/').some(part => part.startsWith('.'))) throw Error('ProductShape product-root is unsafe');
67
89
  const entries = tree.tree.filter(item => item.path.startsWith(`${modelRoot}/`) && item.type === 'blob').sort((a, b) => a.path.localeCompare(b.path));
68
90
  if (!entries.length || entries.length > 512) throw Error('Accepted product model file list is empty or exceeds its bound');
91
+ const changed = new Map(overrides.filter(file => file.path.startsWith(`${modelRoot}/`)).map(file => [file.path, file.deleted ? null : Buffer.from(file.content, 'utf8')]));
69
92
  const rows = [];
70
93
  let bytes = 0;
71
94
  for (const entry of entries) {
72
95
  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');
96
+ if (changed.has(entry.path) && changed.get(entry.path) === null) { changed.delete(entry.path); continue; }
97
+ const blob = changed.has(entry.path) ? null : await api(`git/blobs/${entry.sha}`);
98
+ if (blob && (blob.encoding !== 'base64' || blob.size > 65536)) throw Error(`Accepted product artifact exceeds its bound: ${entry.path}`);
99
+ const content = changed.get(entry.path) ?? Buffer.from(blob.content, 'base64');
100
+ changed.delete(entry.path);
76
101
  bytes += content.length;
77
102
  if (bytes > 8 * 1024 * 1024) throw Error('Accepted product model exceeds its aggregate bound');
78
103
  rows.push(`${entry.path.slice(`${modelRoot}/`.length)}\0${createHash('sha256').update(content).digest('hex')}\n`);
79
104
  }
80
- return { digest: createHash('sha256').update(rows.join('')).digest('hex'), modelRoot };
105
+ for (const [path, content] of changed) {
106
+ if (content === null) throw Error(`Product apply cannot delete a missing model file: ${path}`);
107
+ bytes += content.length;
108
+ if (content.length > 65536 || bytes > 8 * 1024 * 1024) throw Error('Applied product model exceeds its aggregate bound');
109
+ rows.push(`${path.slice(`${modelRoot}/`.length)}\0${createHash('sha256').update(content).digest('hex')}\n`);
110
+ }
111
+ if (rows.length > 512) throw Error('Applied product model file list exceeds its bound');
112
+ return { digest: createHash('sha256').update(rows.sort((a, b) => a.split('\0')[0].localeCompare(b.split('\0')[0])).join('')).digest('hex'), modelRoot };
81
113
  }
82
114
 
83
- function reviewText({ repository, snapshot, head, changeId, changeName, files, candidate, validation, runUrl, approvalKey }) {
115
+ export function reviewText({ repository, snapshot, head, changeId, changeName, files, candidate, validation, runUrl, approvalKey, prUrl }) {
84
116
  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');
117
+ const sections = [...manifest.matchAll(/^## (.+?)\n([\s\S]*?)(?=^## |(?![\s\S]))/gm)].map(match => `### ${match[1]}\n${match[2].trim()}`).join('\n\n');
86
118
  const artifacts = files.filter(file => file.path.includes('/proposed/')).map(file => `- [${file.path}](https://github.com/${repository}/blob/${head}/${file.path})`).join('\n');
87
119
  const areas = candidate.affectedAreas?.map(area => `- ${area}`).join('\n') || '- See the change manifest.';
88
120
  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}).`;
121
+ return handoff({ status: 'review', stage: snapshot.stage, happened: `Product decision: ${changeId} was prepared for [issue #${snapshot.issue}](https://github.com/${repository}/issues/${snapshot.issue}).\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}).`, action: `Owner: review the proposal on this PR. Approve this exact product delta in a new comment with \`/product-change approve ${changeName} ${approvalKey}\`. To request a revision, put \`/product-change request-changes ${changeName} ${approvalKey}\` on the first line of a new comment and describe the change on the next line.`, next: 'Approval starts trusted ProductShape apply. Implementation, PR merge, release and deployment require their own authorization.', link: `[Review the branch diff](https://github.com/${repository}/compare/${snapshot.defaultBranch}...${head}) · [Product Change PR](${prUrl})`, details: `[Preparation run](${runUrl})` }).replace(/<!--\s*lighthouse-/gi, '&lt;!-- lighthouse-');
90
122
  }
91
123
 
92
124
  export async function publishProductChangePullRequest(api, repository, snapshot, candidate, { validation, runUrl, beforeWrite }) {
@@ -94,14 +126,15 @@ export async function publishProductChangePullRequest(api, repository, snapshot,
94
126
  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
127
  const pulls = await api('pulls?state=open&per_page=100');
96
128
  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}-`));
129
+ const issuePulls = pulls.filter(pr => pr.head?.ref?.startsWith(`lighthouse/implement-${snapshot.issue}-`) || pr.head?.ref?.startsWith(`lighthouse/product-change-${snapshot.issue}-`) || pr.head?.ref?.startsWith(`lighthouse/propose-${snapshot.issue}-`));
98
130
  if (issuePulls.length > 1) throw Error('Issue has multiple eligible delivery PRs; owner review is required');
99
131
  let pr = issuePulls[0] ? await api(`pulls/${issuePulls[0].number}`) : null;
100
132
  let base, branch, parent;
101
133
  if (pr) {
102
- if (pr.head.ref.startsWith(`lighthouse/implement-${snapshot.issue}-`) && pr.body?.includes('<!-- lighthouse-product-change:')) {
134
+ if ((pr.head.ref.startsWith(`lighthouse/implement-${snapshot.issue}-`) || pr.head.ref.startsWith(`lighthouse/propose-${snapshot.issue}-`)) && (pr.body?.includes('<!-- lighthouse-product-change:') || pr.body?.includes('<!-- lighthouse-product-change-review:'))) {
103
135
  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
136
  } else if (pr.head.ref.startsWith(`lighthouse/implement-${snapshot.issue}-`)) checkImplementationPullRequest(pr, repository);
137
+ else if (pr.head.ref.startsWith(`lighthouse/propose-${snapshot.issue}-`)) checkProposal(pr, repository);
105
138
  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
139
  if (pr.base?.ref !== snapshot.defaultBranch || pr.head?.repo?.full_name !== repository) throw Error('Existing issue PR targets an unexpected repository or base');
107
140
  if (!pr.draft) throw Error('Product Change cannot modify a PR already submitted for review');
@@ -112,6 +145,7 @@ export async function publishProductChangePullRequest(api, repository, snapshot,
112
145
  parent = snapshot.sourceRevision;
113
146
  base = await api(`git/commits/${parent}`);
114
147
  }
148
+ if (snapshot.productParent?.pullRequest && (!pr || pr.number !== snapshot.productParent.pullRequest.number || snapshot.productEntry !== 'feedback' && pr.head.sha !== snapshot.productParent.pullRequest.head || pr.head.ref !== snapshot.productParent.pullRequest.branch)) throw Error('Parent issue PR changed before Product Change publication');
115
149
  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
150
  const commit = await api('git/commits', 'POST', { message: `docs: prepare product change for #${snapshot.issue}`, tree: tree.sha, parents: [parent] });
117
151
  await beforeWrite();
@@ -128,39 +162,37 @@ export async function publishProductChangePullRequest(api, repository, snapshot,
128
162
  }
129
163
  const repositoryInfo = await api('');
130
164
  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 });
165
+ 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, ...(snapshot.productParent ? { parentDigest: digest(JSON.stringify(snapshot.productParent)) } : {}) });
132
166
  const checkpoint = {
133
167
  schemaVersion: 1, kind: 'checkpoint',
134
168
  identity: { repository, issue: snapshot.issue, parentRunId: String(snapshot.dispatch?.routerRunId ?? snapshot.workflowRunId), authorizationDigest: snapshot.authorizationDigest, policyDigest, workflowRevision: snapshot.workflowRevision, head: pr.head.sha },
135
169
  requestDigest: snapshot.dispatch?.digest ?? snapshot.taskDigest,
136
170
  changeName: candidate.changeName, changeId: candidate.changeId, proposalDigest: candidate.proposalDigest, baselineDigest: candidate.baselineDigest,
137
- state: 'waiting-product-decision', effectIds: [`pr:${pr.number}:${pr.head.sha}`]
171
+ state: 'waiting-product-decision', effectIds: [`pr:${pr.number}:${pr.head.sha}`], ...(snapshot.productParent ? { parent: snapshot.productParent } : {})
138
172
  };
139
173
  const checkpointErrors = validateProductCheckpoint(checkpoint);
140
174
  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 });
175
+ const body = reviewText({ repository, snapshot, head: pr.head.sha, changeId: candidate.changeId, changeName: candidate.changeName, files: candidate.files, candidate, validation, runUrl, approvalKey: binding.key, prUrl: pr.html_url });
142
176
  const reviewMarker = `${marker}review:${JSON.stringify(binding)} -->`;
143
177
  const checkpointMarker = `${marker}checkpoint:${JSON.stringify(checkpoint)} -->`;
144
178
  let prBody = pr.body ?? '';
145
- prBody = prBody.includes('<!-- lighthouse-product-change-review:')
146
- ? prBody.replace(/<!-- lighthouse-product-change-review:\{[^\n]*\} -->/g, reviewMarker)
179
+ prBody = /<!-- lighthouse-product-change(?:-review|:review):/.test(prBody)
180
+ ? prBody.replace(/<!-- lighthouse-product-change(?:-review|:review):\{[^\n]*\} -->/g, reviewMarker)
147
181
  : `${prBody}\n\n${reviewMarker}`;
148
- prBody = prBody.includes('<!-- lighthouse-product-change-checkpoint:')
149
- ? prBody.replace(/<!-- lighthouse-product-change-checkpoint:\{[^\n]*\} -->/g, checkpointMarker)
182
+ prBody = /<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):/.test(prBody)
183
+ ? prBody.replace(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):\{[^\n]*\} -->/g, checkpointMarker)
150
184
  : `${prBody}\n${checkpointMarker}`;
151
185
  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
186
  return { pullRequest: pr, binding, checkpoint, body, repositoryDefaultBranch: repositoryInfo.default_branch };
162
187
  }
163
188
 
189
+ export function checkProductDryRun(preview, changeId) {
190
+ if (preview.outcome !== 'dry-run' || preview.plan?.changeId && preview.plan.changeId !== changeId) {
191
+ const codes = (preview.plan?.diagnostics ?? []).filter(item => item.severity === 'error' && /^[A-Z][A-Z0-9]{0,31}$/.test(item.code ?? '')).map(item => item.code).slice(0, 10);
192
+ throw Error(`ProductShape dry-run does not match the reviewed change${codes.length ? `: ${[...new Set(codes)].join(', ')}` : ''}`);
193
+ }
194
+ }
195
+
164
196
  export async function applyApprovedProductChange({ root, changeName, checkpoint, approval, currentBinding, currentIdentity, bridge, headAfter, resultingModelDigest, reportBeforeWrite, publish }) {
165
197
  const intent = authorizeProductApply({ checkpoint, approval, currentBinding, currentIdentity });
166
198
  const provider = bridge ?? await loadProductBridge();
@@ -170,7 +202,7 @@ export async function applyApprovedProductChange({ root, changeName, checkpoint,
170
202
  await writeFile(manifest, original.replace(/^status:\s*draft\s*$/m, 'status: approved'));
171
203
  await validateProductWorkspace(root, changeName, provider);
172
204
  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');
205
+ checkProductDryRun(preview, checkpoint.changeId);
174
206
  const applied = await provider.applyOpenSpecProductChange(root, changeName, { reportBeforeWrite });
175
207
  if (applied.outcome !== 'applied' || applied.change?.id !== checkpoint.changeId) throw Error('ProductShape apply was refused or returned a different change');
176
208
  if ((applied.resultingModel?.diagnostics ?? []).some(item => item.severity === 'error')) throw Error('ProductShape resulting model has validation errors');
@@ -185,13 +217,22 @@ export async function applyApprovedProductChange({ root, changeName, checkpoint,
185
217
  export async function collectProductApplyFiles(root, changeName, baseRevision) {
186
218
  const prefix = `openspec/changes/${changeName}/product-change/change.md`;
187
219
  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();
220
+ const options = { cwd: root, encoding: 'utf8', maxBuffer: 128000 };
221
+ const changed = execFileSync('git', ['diff', '--name-only', '-z', '--no-renames', baseRevision, '--'], options).split('\0').filter(Boolean);
222
+ const added = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z', '--', '.', ':(exclude).lighthouse-harness', ':(exclude).lighthouse-harness/**'], options).split('\0').filter(Boolean);
223
+ const paths = [...new Set([...changed, ...added])].sort();
189
224
  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
225
  const files = [];
191
226
  let bytes = 0;
192
227
  for (const path of paths) {
193
228
  const absolute = join(root, path);
194
- const stat = await import('node:fs/promises').then(fs => fs.lstat(absolute));
229
+ let stat;
230
+ try { stat = await import('node:fs/promises').then(fs => fs.lstat(absolute)); }
231
+ catch (error) {
232
+ if (error.code !== 'ENOENT' || path === prefix) throw error;
233
+ files.push({ path, deleted: true });
234
+ continue;
235
+ }
195
236
  if (!stat.isFile() || stat.isSymbolicLink()) throw Error(`Trusted apply produced a non-regular path: ${path}`);
196
237
  if (stat.size > 65536) throw Error(`Trusted apply artifact exceeds its bound: ${path}`);
197
238
  const content = await readFile(absolute, 'utf8');
@@ -210,9 +251,11 @@ export function validateProductApplyFiles(files, changeName, modelRoot = 'docs/p
210
251
  if (!Array.isArray(files) || !files.length || files.length > 520) throw Error('Product apply output file list is empty or exceeds its bound');
211
252
  const seen = new Set(); let bytes = 0;
212
253
  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');
254
+ const deletion = file?.deleted === true && Object.keys(file).sort().join(',') === 'deleted,path';
255
+ if (!file || !deletion && (Object.keys(file).sort().join(',') !== 'content,path' || typeof file.content !== 'string') || typeof file.path !== 'string' || seen.has(file.path)) throw Error('Invalid or duplicated Product apply output file');
214
256
  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}`);
257
+ if (!safeRelative(file.path) || deletion && file.path === manifestPath || !deletion && file.content.includes('\0')) throw Error(`Unsafe Product apply output: ${file.path}`);
258
+ if (deletion) { seen.add(file.path); continue; }
216
259
  const size = Buffer.byteLength(file.content);
217
260
  if (size > 65536) throw Error(`Product apply output exceeds its file bound: ${file.path}`);
218
261
  bytes += size;
@@ -226,32 +269,64 @@ export function validateProductApplyFiles(files, changeName, modelRoot = 'docs/p
226
269
 
227
270
  const safeRelative = path => !path.startsWith('/') && !path.includes('\\') && !path.split('/').some(part => !part || part === '.' || part === '..');
228
271
 
229
- export async function publishProductApply(api, repository, owner, dispatch, result, beforeWrite) {
272
+ export function productApplyHandoff({ stage, receipt, pullRequest, url, affectedCitations, parent }) {
273
+ return handoff({ status: 'completed', stage, happened: `Product Change ${receipt.changeId} was applied to the provider-validated model on PR #${pullRequest}.\n\nAffected citations:\n${(affectedCitations ?? []).map(item => `- \`${item.document ?? item.file ?? item.id ?? 'consumer'}\`: ${item.status ?? 'refresh required'}`).join('\n') || '- None reported.'}`, action: 'Owner: review the applied model and affected citations on the linked PR.', next: parent ? 'The workflow will recheck the existing parent authorization before requesting its continuation. Merge remains separate.' : 'Merge and any further delivery require their own authorization.', link: `[Review PR #${pullRequest}](${url})`, details: `<!-- lighthouse-product-apply:${JSON.stringify(receipt)} -->` });
274
+ }
275
+
276
+ export async function observeUpdatedProductPull(api, number, expectedHead, wait = ms => new Promise(resolve => setTimeout(resolve, ms))) {
277
+ for (let attempt = 0; attempt < 20; attempt++) {
278
+ const pr = await api(`pulls/${number}`);
279
+ if (pr.state !== 'open' || pr.merged) throw Error('Product PR closed while confirming the applied head');
280
+ if (pr.head?.sha === expectedHead) return pr;
281
+ if (attempt < 19) await wait(1000);
282
+ }
283
+ throw Error('Product PR head update could not be confirmed; reconcile publication before retrying');
284
+ }
285
+
286
+ export async function advanceProductRef(api, branch, head) {
287
+ try {
288
+ await api(`git/refs/heads/${branch}`, 'PATCH', { sha: head, force: false });
289
+ } catch (error) {
290
+ const current = await api(`git/ref/heads/${branch}`, 'GET', undefined, true).catch(() => null);
291
+ if (current?.object?.sha !== head) throw error;
292
+ }
293
+ }
294
+
295
+ export async function publishProductApply(api, repository, owner, dispatch, result, beforeWrite, applyRequest) {
230
296
  const approvalInfo = dispatch.request.productApproval;
231
297
  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
298
  const files = validateProductApplyFiles(result.files, approvalInfo.changeName, result.modelRoot);
233
299
  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');
300
+ 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');
301
+ const approvalFields = value => { const { observedAt, ...rest } = value ?? {}; return rest; };
302
+ if (!applyRequest || applyRequest.repository !== repository || applyRequest.owner !== owner || applyRequest.issue !== fresh.issue || applyRequest.pullRequest !== fresh.pullRequest || applyRequest.head !== fresh.head || applyRequest.changeName !== fresh.changeName || !isDeepStrictEqual(applyRequest.checkpoint, fresh.checkpoint) || !isDeepStrictEqual(applyRequest.binding, fresh.binding) || !isDeepStrictEqual(approvalFields(applyRequest.approval), approvalFields(fresh.approval))) throw Error('Product apply request no longer matches authenticated approval');
303
+ const intent = authorizeProductApply({ checkpoint: applyRequest.checkpoint, approval: applyRequest.approval, currentBinding: applyRequest.binding, currentIdentity: applyRequest.checkpoint.identity });
304
+ const expectedReceipt = createProductApplyReceipt({ intent, approval: applyRequest.approval, checkpoint: applyRequest.checkpoint, headAfter: fresh.head, resultingModelDigest: result.resultingModelDigest, validated: true, appliedAt: result.receipt?.appliedAt });
305
+ if (result.providerOutcome !== 'applied' || !isDeepStrictEqual(result.receipt, expectedReceipt)) throw Error('Product apply receipt does not match authenticated provider output');
235
306
  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');
307
+ const projected = await productModelDigestFromApi(api, fresh.head, files);
308
+ if (projected.modelRoot !== result.modelRoot || projected.digest !== result.resultingModelDigest) throw Error('Published files do not reproduce the provider-validated product model');
236
309
  const pr = await api(`pulls/${fresh.pullRequest}`);
237
310
  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
311
  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 })) });
312
+ const tree = await api('git/trees', 'POST', { base_tree: commit.tree.sha, tree: files.map(file => file.deleted ? { path: file.path, mode: '100644', type: 'blob', sha: null } : { path: file.path, mode: '100644', type: 'blob', content: file.content }) });
240
313
  const created = await api('git/commits', 'POST', { message: `docs: apply product change ${fresh.checkpoint.changeId}`, tree: tree.sha, parents: [fresh.head] });
314
+ if (!/^[a-f0-9]{40}$/.test(created?.sha ?? '')) throw Error('Created product commit has no valid identity');
241
315
  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}`);
316
+ await advanceProductRef(api, pr.head.ref, created.sha);
317
+ const updated = await observeUpdatedProductPull(api, fresh.pullRequest, created.sha);
244
318
  const receipt = { ...result.receipt, headAfter: updated.head.sha };
245
319
  let prBody = updated.body ?? '';
246
320
  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)} -->`);
321
+ const checkpointErrors = validateProductCheckpoint(checkpoint);
322
+ if (checkpointErrors.length) throw Error(checkpointErrors.join('; '));
323
+ prBody = prBody.replace(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):\{[^\n]*\} -->/g, `${marker}checkpoint:${JSON.stringify(checkpoint)} -->`);
249
324
  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)} -->`;
325
+ const issueBody = productApplyHandoff({ stage: dispatch.request.stage, receipt, pullRequest: fresh.pullRequest, url: updated.html_url, affectedCitations: result.affectedCitations, parent: checkpoint.parent });
326
+ if (checkpoint.parent && (await api(`issues/${fresh.issue}`)).labels?.some(label => label.name === 'waiting-review')) await api(`issues/${fresh.issue}/labels/waiting-review`, 'DELETE');
251
327
  const comments = await api(`issues/${fresh.issue}/comments?per_page=100`);
252
328
  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 });
329
+ if (!prior) await api(`issues/${fresh.issue}/comments`, 'POST', { body: issueBody });
255
330
  return { pullRequest: updated, receipt, checkpoint };
256
331
  }
257
332
 
@@ -271,6 +346,35 @@ export function productApprovalFromGitHub({ eventName, event, owner, repository,
271
346
  return verifyProductDecision({ decision, actor, actorType, owner, source, submitted, currentBinding, observedBinding: currentBinding, decisionId });
272
347
  }
273
348
 
349
+ export function checkCurrentProductDecision({ eventName, event, current, repository, number }) {
350
+ const original = eventName === 'issue_comment' ? event.comment : event.review;
351
+ if (!original || !current || !/^[1-9][0-9]*$/.test(String(original.id ?? '')) || String(current.id) !== String(original.id) || current.body !== original.body || current.user?.login !== original.user?.login || current.user?.type !== original.user?.type) throw Error('Product decision was edited, deleted or replaced');
352
+ if (eventName === 'issue_comment') {
353
+ if (current.issue_url !== `https://api.github.com/repos/${repository}/issues/${number}` || !original.updated_at || current.updated_at !== original.updated_at) throw Error('Product decision comment changed or belongs to another PR');
354
+ } else if (current.pull_request_url !== `https://api.github.com/repos/${repository}/pulls/${number}` || !original.submitted_at || current.submitted_at !== original.submitted_at || typeof current.state !== 'string' || typeof original.state !== 'string' || current.state.toUpperCase() !== original.state.toUpperCase() || current.state.toUpperCase() === 'DISMISSED') throw Error('Product decision review changed or was dismissed');
355
+ }
356
+
357
+ export async function checkNoLaterProductObjection(api, number, owner, decision, originalTime) {
358
+ const started = Date.parse(originalTime ?? '');
359
+ if (!Number.isFinite(started)) throw Error('Product decision time is unavailable');
360
+ const comments = await (await import('./stage-dispatch.mjs')).issueComments(api, number);
361
+ const reviews = [];
362
+ for (let page = 1; page <= 10; page++) {
363
+ const rows = await api(`pulls/${number}/reviews?per_page=100&page=${page}`);
364
+ if (!Array.isArray(rows)) throw Error('Product review history is unavailable');
365
+ reviews.push(...rows);
366
+ if (rows.length < 100) break;
367
+ if (page === 10) throw Error('Product review history exceeds bounds');
368
+ }
369
+ for (const entry of [...comments, ...reviews]) {
370
+ if (entry.user?.login !== owner || entry.user?.type !== 'User') continue;
371
+ const next = parseProductDecision(entry.body);
372
+ if (!next || next.changeName !== decision.changeName || next.reviewKey !== decision.reviewKey || !['request-changes', 'answer'].includes(next.action)) continue;
373
+ const at = Date.parse(entry.submitted_at ?? entry.created_at ?? '');
374
+ if (!Number.isFinite(at) || at >= started) throw Error('A later owner decision requires product review before apply');
375
+ }
376
+ }
377
+
274
378
  export async function loadProductApproval(api, { eventName, event, owner, repository, root }) {
275
379
  let body, actor, actorType, source, decisionId;
276
380
  if (eventName === 'issue_comment' && event.action === 'created' && event.issue?.pull_request) {
@@ -281,19 +385,23 @@ export async function loadProductApproval(api, { eventName, event, owner, reposi
281
385
  const decision = parseProductDecision(body);
282
386
  if (!decision || !['approve', 'request-changes', 'answer'].includes(decision.action)) return null;
283
387
  const number = event.pull_request?.number ?? event.issue?.number;
388
+ if (!Number.isSafeInteger(number) || number < 1 || !/^[1-9][0-9]*$/.test(decisionId)) throw Error('Product decision identity is invalid');
389
+ const decisionPath = eventName === 'issue_comment' ? `issues/comments/${decisionId}` : `pulls/${number}/reviews/${decisionId}`;
390
+ checkCurrentProductDecision({ eventName, event, current: await api(decisionPath), repository, number });
391
+ if (decision.action === 'approve') await checkNoLaterProductObjection(api, number, owner, decision, eventName === 'issue_comment' ? event.comment.created_at : event.review.submitted_at);
284
392
  const pr = await api(`pulls/${number}`);
285
393
  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)];
394
+ const markers = [...(pr.body ?? '').matchAll(/<!-- lighthouse-product-change(?:-review|:review):(\{[^\n]*\}) -->/g)];
287
395
  if (markers.length !== 1) throw Error('PR has no unique Product Change review binding');
288
396
  const raw = JSON.parse(markers[0][1]);
289
397
  const { key, ...fields } = raw;
290
398
  const bound = productReviewBinding(fields);
291
399
  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)];
400
+ const checkpoints = [...(pr.body ?? '').matchAll(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):(\{[^\n]*\}) -->/g)];
293
401
  if (checkpoints.length !== 1) throw Error('PR has no unique Product Change checkpoint');
294
402
  const checkpoint = JSON.parse(checkpoints[0][1]);
295
403
  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');
404
+ if (checkpointErrors.length || (checkpoint.parent ? digest(JSON.stringify(checkpoint.parent)) !== bound.parentDigest : bound.parentDigest !== undefined) || 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
405
  if (decision.changeName !== bound.changeName || decision.reviewKey !== bound.key) throw Error('Product approval action does not identify the current Product Change');
298
406
  const candidate = await loadProductCandidateFromPullRequest(api, repository, pr, bound.changeName);
299
407
  const repositoryInfo = await api('');
@@ -303,6 +411,10 @@ export async function loadProductApproval(api, { eventName, event, owner, reposi
303
411
  const verified = verifyProductDecision({ decision, actor, actorType, owner, source, submitted: true, currentBinding: bound, observedBinding: bound, decisionId });
304
412
  const issue = await api(`issues/${bound.issue}`);
305
413
  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');
414
+ if (checkpoint.parent) {
415
+ const comments = await (await import('./stage-dispatch.mjs')).issueComments(api, bound.issue);
416
+ await (await import('./stage-transition.mjs')).verifyProductParent(api, repository, owner, checkpoint.parent, issue, comments, defaultHead);
417
+ }
306
418
  const feedback = decision.action === 'approve' ? null : { action: decision.action, changeName: decision.changeName, reviewKey: decision.reviewKey, text: decision.detail, sourceCommentId: decisionId, source: 'pull-request' };
307
419
  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
420
  }