@juangcarmona/lighthouse-sdlc 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +15 -3
  2. package/capabilities.json +2 -1
  3. package/completion.md +2 -0
  4. package/epic-delivery.md +35 -0
  5. package/github-actions.md +19 -3
  6. package/host-contract.md +2 -0
  7. package/package.json +4 -1
  8. package/schemas/product-change.schema.json +23 -4
  9. package/schemas/stage-result.schema.json +23 -1
  10. package/skills/product-change/SKILL.md +3 -3
  11. package/skills/refine/SKILL.md +3 -2
  12. package/src/completion-actions.mjs +57 -13
  13. package/src/completion.mjs +1 -1
  14. package/src/contracts.mjs +10 -0
  15. package/src/decompose.mjs +5 -2
  16. package/src/epic-dispatch.mjs +59 -0
  17. package/src/epic-github.mjs +111 -0
  18. package/src/epic-journal.mjs +104 -0
  19. package/src/epic-merge.mjs +91 -0
  20. package/src/epic-native.mjs +465 -0
  21. package/src/epic.mjs +86 -0
  22. package/src/handoff.mjs +97 -0
  23. package/src/host-contract.mjs +22 -6
  24. package/src/implementation.mjs +33 -5
  25. package/src/install-workflows.mjs +1 -1
  26. package/src/issue-actions.mjs +360 -78
  27. package/src/opencode-execute.mjs +20 -0
  28. package/src/pr-state-actions.mjs +21 -7
  29. package/src/pr-state.mjs +18 -1
  30. package/src/process-publication.mjs +2 -1
  31. package/src/product-change-actions.mjs +158 -46
  32. package/src/product-change.mjs +92 -11
  33. package/src/proposal-review.mjs +16 -6
  34. package/src/proposal.mjs +13 -1
  35. package/src/stacked.mjs +14 -3
  36. package/src/stage-dispatch.mjs +66 -19
  37. package/src/stage-transition.mjs +151 -2
  38. package/src/triage.mjs +4 -2
  39. package/src/unattended-actions.mjs +39 -12
  40. package/src/unattended-planning-evidence.mjs +44 -0
  41. package/src/unattended.mjs +1 -1
  42. package/src/worker-maintenance.mjs +15 -3
  43. package/src/worker-recovery.mjs +204 -6
  44. package/workflows/lighthouse-sdlc-apply-product-change.lock.yml +97 -11
  45. package/workflows/lighthouse-sdlc-apply-product-change.md +75 -6
  46. package/workflows/lighthouse-sdlc-apply-review.lock.yml +33 -2
  47. package/workflows/lighthouse-sdlc-apply-review.md +29 -1
  48. package/workflows/lighthouse-sdlc-epic.yml +79 -0
  49. package/workflows/lighthouse-sdlc-implement.lock.yml +38 -2
  50. package/workflows/lighthouse-sdlc-implement.md +33 -2
  51. package/workflows/lighthouse-sdlc-integrate.lock.yml +4 -1
  52. package/workflows/lighthouse-sdlc-integrate.md +3 -0
  53. package/workflows/lighthouse-sdlc-product-change.lock.yml +39 -4
  54. package/workflows/lighthouse-sdlc-product-change.md +39 -6
  55. package/workflows/lighthouse-sdlc-propose.lock.yml +13 -1
  56. package/workflows/lighthouse-sdlc-propose.md +11 -1
  57. package/workflows/lighthouse-sdlc-refine.lock.yml +29 -2
  58. package/workflows/lighthouse-sdlc-refine.md +25 -2
  59. package/workflows/lighthouse-sdlc-review.lock.yml +4 -1
  60. package/workflows/lighthouse-sdlc-review.md +3 -0
  61. package/workflows/lighthouse-sdlc-router.yml +47 -3
  62. package/workflows/lighthouse-sdlc-triage.lock.yml +30 -3
  63. package/workflows/lighthouse-sdlc-triage.md +26 -3
  64. package/workflows/lighthouse-worker-maintenance.yml +70 -1
@@ -37,27 +37,33 @@ export function validateProductRequest(value) {
37
37
  }
38
38
 
39
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)))];
40
+ const fields = ['schemaVersion', 'outcome', 'entry', 'affectedAreas', 'decisions', ...(['changeName', 'changeId', 'files', 'question', 'blocker', 'recovery', 'proposalDigest', 'baselineDigest', 'providerValidation'].filter(key => Object.hasOwn(value ?? {}, key)))];
41
41
  if (!exact(value, fields)) return ['result.output.productChange has unsupported or missing fields'];
42
42
  const errors = [];
43
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
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
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');
46
+ if (value.question !== undefined && (typeof value.question !== 'string' || !value.question.trim() || value.question.length > 4000 || /<!--\s*lighthouse-/i.test(value.question))) errors.push('result.output.productChange question is invalid');
47
+ if (value.blocker !== undefined && (typeof value.blocker !== 'string' || !value.blocker.trim() || value.blocker.length > 4000 || /<!--\s*lighthouse-/i.test(value.blocker))) errors.push('result.output.productChange blocker is invalid');
48
+ if (value.recovery !== undefined && (typeof value.recovery !== 'string' || !value.recovery.trim() || value.recovery.length > 4000 || /<!--\s*lighthouse-/i.test(value.recovery))) errors.push('result.output.productChange recovery is invalid');
47
49
  if (value.changeName !== undefined && !namePattern.test(value.changeName)) errors.push('result.output.productChange changeName is invalid');
48
50
  if (value.changeId !== undefined && !/^CHG-[A-Z0-9-]+$/.test(value.changeId)) errors.push('result.output.productChange changeId is invalid');
49
51
  if (value.proposalDigest !== undefined && !hashPattern.test(value.proposalDigest)) errors.push('result.output.productChange proposalDigest is invalid');
50
52
  if (value.baselineDigest !== undefined && !hashPattern.test(value.baselineDigest)) errors.push('result.output.productChange baselineDigest is invalid');
51
53
  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');
54
+ if (!value.changeName || !value.changeId || !value.baselineDigest || !Array.isArray(value.files)) errors.push('candidate Product Change output needs its change identity, baseline digest, and files');
53
55
  else try {
54
56
  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');
57
+ if (checked.changeId !== value.changeId || value.proposalDigest !== undefined && checked.digest !== value.proposalDigest) errors.push('candidate Product Change identity or digest does not match its files');
56
58
  } 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');
59
+ if (!exact(value.providerValidation, ['status', 'diagnosticCount']) || !['pending', 'passed'].includes(value.providerValidation.status) || !Number.isSafeInteger(value.providerValidation.diagnosticCount) || value.providerValidation.diagnosticCount < 0 || value.providerValidation.status === 'pending' && value.providerValidation.diagnosticCount !== 0 || value.providerValidation.status === 'passed' && !value.proposalDigest) errors.push('candidate Product Change needs pending host validation or a bound successful provider validation');
58
60
  }
59
61
  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');
62
+ const hasQuestion = typeof value.question === 'string' && !!value.question.trim();
63
+ const hasBlocker = typeof value.blocker === 'string' && !!value.blocker.trim();
64
+ const hasRecovery = typeof value.recovery === 'string' && !!value.recovery.trim();
65
+ if (value.outcome === 'blocked' && ((!hasQuestion && !hasBlocker) || (value.question !== undefined && (value.blocker !== undefined || value.recovery !== undefined)) || (hasBlocker && !hasRecovery) || value.files !== undefined)) errors.push('blocked Product Change output needs either one decision question or one technical blocker with recovery and no candidate files');
66
+ if (value.outcome !== 'blocked' && (value.blocker !== undefined || value.recovery !== undefined)) errors.push('only a blocked Product Change can include a technical blocker or recovery');
61
67
  if (value.files !== undefined && value.outcome !== 'candidate') errors.push('only a candidate Product Change can include files');
62
68
  return errors;
63
69
  }
@@ -106,7 +112,7 @@ export function validateProductIdentity(value) {
106
112
 
107
113
  export function validateProductCheckpoint(value) {
108
114
  const fields = ['schemaVersion', 'kind', 'identity', 'requestDigest', 'changeName', 'changeId', 'proposalDigest', 'baselineDigest', 'state', 'effectIds'];
109
- const optional = ['pendingQuestion'];
115
+ const optional = ['pendingQuestion', 'parent'];
110
116
  if (!exact(value, [...fields, ...optional.filter(key => Object.hasOwn(value ?? {}, key))])) return ['Product Change checkpoint has unsupported or missing fields'];
111
117
  const errors = [...validateProductIdentity(value.identity)];
112
118
  if (value.schemaVersion !== 1 || value.kind !== 'checkpoint') errors.push('Product Change checkpoint identity is unsupported');
@@ -114,6 +120,22 @@ export function validateProductCheckpoint(value) {
114
120
  for (const field of ['requestDigest', 'proposalDigest', 'baselineDigest']) if (!hashPattern.test(value[field] ?? '')) errors.push(`${field} must be SHA-256`);
115
121
  if (!['preparing', 'waiting-product-decision', 'approved', 'applying', 'applied', 'blocked'].includes(value.state)) errors.push('Product Change checkpoint state is unsupported');
116
122
  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');
123
+ if (value.parent !== undefined) {
124
+ const parent = value.parent;
125
+ if (!exact(parent, ['issue', 'stage', 'predecessor', 'gap', 'sourceRevision', 'baseRevision', 'taskDigest', 'autoImplement', ...(['feedback', 'pullRequest', 'review'].filter(key => Object.hasOwn(parent ?? {}, key)))]) || parent.issue !== value.identity?.issue || !['triage', 'refine', 'propose', 'implement'].includes(parent.stage) || !exact(parent.predecessor, ['runId', 'attempt']) || !/^[1-9][0-9]*$/.test(parent.predecessor.runId ?? '') || !Number.isSafeInteger(parent.predecessor.attempt) || parent.predecessor.attempt < 1 || !exact(parent.gap, ['summary', 'scope']) || !['summary', 'scope'].every(key => typeof parent.gap[key] === 'string' && parent.gap[key].trim() && parent.gap[key].length <= 4000 && !/<!--\s*lighthouse-/i.test(parent.gap[key])) || !revisionPattern.test(parent.sourceRevision ?? '') || !revisionPattern.test(parent.baseRevision ?? '') || !hashPattern.test(parent.taskDigest ?? '') || typeof parent.autoImplement !== 'boolean') errors.push('Product Change parent checkpoint is invalid');
126
+ }
127
+ if (value.parent?.pullRequest !== undefined) {
128
+ const pr = value.parent.pullRequest;
129
+ if (!exact(pr, ['number', 'head', 'branch']) || !Number.isSafeInteger(pr.number) || pr.number < 1 || !revisionPattern.test(pr.head ?? '') || typeof pr.branch !== 'string' || !new RegExp(`^lighthouse/(?:implement|propose)-${value.identity?.issue}-[a-f0-9]{12}$`).test(pr.branch)) errors.push('Product Change parent PR evidence is invalid');
130
+ }
131
+ if (value.parent?.review !== undefined) {
132
+ const review = value.parent.review;
133
+ if (!exact(review, ['number', 'path', 'feedbackDigest', 'threadDigest']) || !Number.isSafeInteger(review.number) || review.number < 1 || review.number !== value.parent.pullRequest?.number || typeof review.path !== 'string' || !/^(?:issues\/comments|pulls\/comments|pulls\/[1-9][0-9]*\/reviews)\/[1-9][0-9]*$/.test(review.path) || !hashPattern.test(review.feedbackDigest ?? '') || !hashPattern.test(review.threadDigest ?? '')) errors.push('Product Change parent review evidence is invalid');
134
+ }
135
+ if (value.parent?.feedback !== undefined) {
136
+ const feedback = value.parent.feedback;
137
+ if (!exact(feedback, ['runId', 'attempt', 'taskDigest', 'replyTo', 'commentId']) || !/^[1-9][0-9]*$/.test(feedback.runId ?? '') || !Number.isSafeInteger(feedback.attempt) || feedback.attempt < 1 || !hashPattern.test(feedback.taskDigest ?? '') || !hashPattern.test(feedback.replyTo ?? '') || !Number.isSafeInteger(feedback.commentId) || feedback.commentId < 1) errors.push('Product Change parent answer evidence is invalid');
138
+ }
117
139
  if (value.pendingQuestion !== undefined && (typeof value.pendingQuestion !== 'string' || value.pendingQuestion.length > 4000)) errors.push('Product Change checkpoint pendingQuestion exceeds its bound');
118
140
  return errors;
119
141
  }
@@ -183,14 +205,17 @@ export async function prepareProductChange(root, changeName, files, bridge) {
183
205
  openspec(temp, ['new', 'change', changeName, '--schema', 'product-change', '--json']);
184
206
  const rootPath = `openspec/changes/${changeName}`;
185
207
  if (!files) {
208
+ const schemaPin = await readFile(join(temp, rootPath, '.openspec.yaml'), 'utf8');
209
+ if ((schemaPin.match(/^skip_specs:\s*true\s*$/gm) ?? []).length !== 1) throw Error('OpenSpec scaffold must declare skip_specs exactly once');
186
210
  const instructions = {};
187
211
  for (const artifact of ['intent', 'delta']) {
188
212
  const value = JSON.parse(openspec(temp, ['instructions', artifact, '--change', changeName, '--json']));
189
213
  instructions[artifact] = { outputPath: value.outputPath, instruction: value.instruction, template: value.template };
190
214
  }
191
- return { provider: '@fission-ai/openspec@1.15.0', schema: 'product-change', root: rootPath, changeName, instructions };
215
+ return { provider: '@fission-ai/openspec@1.15.0', schema: 'product-change', root: rootPath, changeName, schemaPin, instructions };
192
216
  }
193
217
  const summary = validateProductChangeFiles(files, { changeName });
218
+ const schemaPin = files.find(file => file.path === `${rootPath}/.openspec.yaml`).content;
194
219
  for (const file of files) {
195
220
  const target = join(temp, file.path);
196
221
  await mkdir(dirname(target), { recursive: true });
@@ -203,8 +228,8 @@ export async function prepareProductChange(root, changeName, files, bridge) {
203
228
  const value = JSON.parse(openspec(temp, ['instructions', artifact, '--change', changeName, '--json']));
204
229
  instructions[artifact] = { outputPath: value.outputPath, instruction: value.instruction, template: value.template };
205
230
  }
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 };
231
+ if (bridge && validation) return { ...summary, provider: '@prodshape/integration-openspec', root: rootPath, openSpec: 'passed', diagnostics: validation.diagnostics, schemaPin, instructions, files };
232
+ return { ...summary, root: rootPath, openSpec: 'passed', provider: 'unavailable', schemaPin, instructions, files };
208
233
  } finally { await rm(temp, { recursive: true, force: true }); }
209
234
  }
210
235
 
@@ -215,12 +240,45 @@ export async function loadProductBridge({ entry = process.env.PRODSHAPE_INTEGRAT
215
240
  return bridge;
216
241
  }
217
242
 
243
+ export async function loadProductCore() {
244
+ const entry = require.resolve('@prodshape/core');
245
+ const metadata = JSON.parse(await readFile(join(dirname(dirname(entry)), 'package.json'), 'utf8'));
246
+ if (metadata.name !== '@prodshape/core' || metadata.version !== '0.25.0') throw Error('Pinned ProductShape core 0.25.0 is unavailable');
247
+ const core = await import(pathToFileURL(entry).href);
248
+ for (const name of ['openRepository', 'validateBaseline', 'inspectArtifact', 'analyzeImpact', 'emitCitation']) if (typeof core[name] !== 'function') throw Error(`Pinned ProductShape core does not provide ${name}`);
249
+ return { ...core, version: metadata.version };
250
+ }
251
+
252
+ export async function prepareProductEvidence(root, destination, core, { baselineDigest } = {}) {
253
+ if (baselineDigest && await productModelDigest(root) !== baselineDigest) throw Error('ProductShape baseline changed before evidence preparation');
254
+ const repository = await core.openRepository(resolve(root));
255
+ const baseline = await core.validateBaseline(repository);
256
+ const errors = baseline.diagnostics.filter(item => item.severity === 'error');
257
+ if (errors.length) throw Error(`ProductShape baseline has ${errors.length} blocking diagnostic(s); validate the adopter model before retrying`);
258
+ const nodes = baseline.graph.nodes;
259
+ if (!Array.isArray(nodes) || nodes.length > 512) throw Error('ProductShape evidence exceeds the artifact bound');
260
+ await mkdir(destination, { recursive: true });
261
+ const index = []; let bytes = 0;
262
+ for (const node of nodes) {
263
+ if (!/^[A-Z0-9][A-Z0-9-]{0,159}$/.test(node.id ?? '')) throw Error('ProductShape evidence has an unsafe artifact identity');
264
+ const report = { inspect: core.inspectArtifact(baseline.graph, node.id), impact: core.analyzeImpact(baseline.graph, node.id), citation: core.emitCitation({ id: node.id, digest: node.digest, form: 'payload' }) };
265
+ const content = JSON.stringify(report, null, 2) + '\n'; bytes += Buffer.byteLength(content);
266
+ if (Buffer.byteLength(content) > 512 * 1024 || bytes > 16 * 1024 * 1024) throw Error('ProductShape evidence exceeds the byte bound');
267
+ await writeFile(join(destination, `${node.id}.json`), content, { flag: 'wx' });
268
+ index.push({ id: node.id, type: node.type, title: node.title, report: `${node.id}.json` });
269
+ }
270
+ if (baselineDigest && await productModelDigest(root) !== baselineDigest) throw Error('ProductShape baseline changed during evidence preparation');
271
+ await writeFile(join(destination, 'index.json'), JSON.stringify({ schemaVersion: 1, source: '@prodshape/core', version: core.version, ...(baselineDigest ? { baselineDigest } : {}), artifacts: index }, null, 2) + '\n', { flag: 'wx' });
272
+ return { artifacts: index.length, bytes };
273
+ }
274
+
218
275
  export function productReviewBinding(input) {
219
- const fields = ['repository', 'issue', 'pullRequest', 'changeName', 'changeId', 'proposalDigest', 'baselineDigest', 'policyDigest', 'authorityDigest', 'head'];
276
+ const fields = ['repository', 'issue', 'pullRequest', 'changeName', 'changeId', 'proposalDigest', 'baselineDigest', 'policyDigest', 'authorityDigest', 'head', ...(Object.hasOwn(input ?? {}, 'parentDigest') ? ['parentDigest'] : [])];
220
277
  if (!exact(input, fields)) throw Error('Product review binding has unsupported or missing fields');
221
278
  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
279
  if (!namePattern.test(input.changeName) || !/^CHG-[A-Z0-9-]+$/.test(input.changeId)) throw Error('Product review change identity is invalid');
223
280
  for (const field of ['proposalDigest', 'baselineDigest', 'policyDigest', 'authorityDigest']) if (!hashPattern.test(input[field] ?? '')) throw Error(`Product review ${field} is invalid`);
281
+ if (input.parentDigest !== undefined && !hashPattern.test(input.parentDigest)) throw Error('Product review parent digest is invalid');
224
282
  if (!revisionPattern.test(input.head ?? '')) throw Error('Product review head must be a full commit SHA');
225
283
  const key = digest(JSON.stringify(input));
226
284
  return { ...input, key };
@@ -275,3 +333,26 @@ export function verifyProductChangePath(root, relativePath) {
275
333
  return absolute;
276
334
  });
277
335
  }
336
+
337
+ export function checkProductResumePullRequest(pr, repository, resume) {
338
+ if (!resume || pr?.number !== resume.pullRequest || pr.state !== 'open' || pr.merged || !pr.draft || pr.user?.login !== 'github-actions[bot]' || pr.user?.type !== 'Bot' || pr.head?.repo?.full_name !== repository || pr.base?.repo?.full_name !== repository || pr.head.sha !== resume.head || pr.head.ref !== resume.branch) throw Error('Applied product PR changed before parent continuation');
339
+ const matches = [...(pr.body ?? '').matchAll(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):(\{[^\n]*\}) -->/g)];
340
+ if (matches.length !== 1) throw Error('Applied product checkpoint is unavailable');
341
+ const checkpoint = JSON.parse(matches[0][1]);
342
+ if (validateProductCheckpoint(checkpoint).length || checkpoint.state !== 'applied' || checkpoint.identity.issue !== resume.issue || checkpoint.identity.head !== resume.head || JSON.stringify(checkpoint.parent) !== JSON.stringify(resume.parent) || !checkpoint.effectIds.includes(`apply:${resume.receiptIntent}:${resume.head}`)) throw Error('Applied product checkpoint changed before parent continuation');
343
+ return checkpoint;
344
+ }
345
+
346
+ function productDeliveryProof(pr, kind) {
347
+ const expression = new RegExp(`<!-- lighthouse-product-${kind}:(\\{[^\\n]*\\}) -->`, 'g');
348
+ const proofs = [...(pr?.body ?? '').matchAll(expression)];
349
+ const checkpoints = [...(pr?.body ?? '').matchAll(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):(\{[^\n]*\}) -->/g)];
350
+ if (proofs.length !== 1 || checkpoints.length !== 1) return null;
351
+ let proof, checkpoint;
352
+ try { proof = JSON.parse(proofs[0][1]); checkpoint = JSON.parse(checkpoints[0][1]); } catch { return null; }
353
+ if (!exact(proof, ['schemaVersion', 'issue', 'runId', 'applyIntent', 'head']) || proof.schemaVersion !== 1 || !Number.isSafeInteger(proof.issue) || proof.issue < 1 || !hashPattern.test(proof.runId ?? '') || !hashPattern.test(proof.applyIntent ?? '') || proof.head !== pr.head?.sha || validateProductCheckpoint(checkpoint).length || checkpoint.state !== 'applied' || checkpoint.identity.issue !== proof.issue || !checkpoint.parent || !checkpoint.effectIds.includes(`apply:${proof.applyIntent}:${checkpoint.identity.head}`)) return null;
354
+ return proof;
355
+ }
356
+
357
+ export const productImplementationProof = pr => productDeliveryProof(pr, 'implementation');
358
+ export const productProposalProof = pr => productDeliveryProof(pr, 'proposal');
@@ -1,5 +1,6 @@
1
1
  import { digest } from './host-contract.mjs';
2
2
  import { proposalRoot, validateProposalFiles } from './proposal.mjs';
3
+ import { productImplementationProof, productProposalProof } from './product-change.mjs';
3
4
 
4
5
  export const feedbackKey = review => digest(`${review.number}:${review.kind}:${review.id}:${review.feedbackDigest}`);
5
6
  export const feedbackWorker = review => review && !review.approved ? 'apply-review' : undefined;
@@ -11,17 +12,21 @@ export function reviewTarget(event, eventName) {
11
12
  }
12
13
 
13
14
  export function checkProposal(pr, repo, expected) {
14
- const match = pr.head?.ref?.match(/^lighthouse\/propose-([1-9][0-9]*)-([a-f0-9]{12})$/);
15
+ const match = pr.head?.ref?.match(/^lighthouse\/(?:propose|implement)-([1-9][0-9]*)-([a-f0-9]{12})$/);
15
16
  const marker = pr.body?.match(/<!-- lighthouse-proposal:([a-f0-9]{64}) -->/)?.[1];
16
- 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 Lighthouse proposal');
17
+ const productProof = productProposalProof(pr);
18
+ const standard = pr.head?.ref?.startsWith('lighthouse/propose-') && !/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):/.test(pr.body ?? '') && marker?.startsWith(match?.[2] ?? 'invalid');
19
+ if (!match || !(standard || productProof?.issue === Number(match[1])) || 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 Lighthouse proposal');
17
20
  if (expected && (pr.head.sha !== expected.head || pr.head.ref !== expected.branch || Number(match[1]) !== expected.issue || pr.base.ref !== expected.base)) throw Error('Proposal changed during review; no update published');
18
- return { number: pr.number, issue: Number(match[1]), head: pr.head.sha, branch: pr.head.ref, base: pr.base.ref, url: pr.html_url };
21
+ return { number: pr.number, issue: Number(match[1]), head: pr.head.sha, branch: pr.head.ref, base: pr.base.ref, url: pr.html_url, ...(productProof ? { productProposal: true } : {}) };
19
22
  }
20
23
 
21
24
  export function checkImplementationPullRequest(pr, repo, expected) {
22
- const match = pr.head?.ref?.match(/^lighthouse\/implement-([1-9][0-9]*)-([a-f0-9]{12})$/);
25
+ const match = pr.head?.ref?.match(/^lighthouse\/(?:implement|propose)-([1-9][0-9]*)-([a-f0-9]{12})$/);
23
26
  const marker = pr.body?.match(/<!-- lighthouse-implementation:([a-f0-9]{64}) -->/)?.[1];
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');
27
+ const productProof = productImplementationProof(pr);
28
+ const standard = pr.head?.ref?.startsWith('lighthouse/implement-') && !pr.body?.includes('<!-- lighthouse-product-change:') && !/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):/.test(pr.body ?? '') && marker?.startsWith(match?.[2] ?? 'invalid');
29
+ if (!match || !(standard || productProof?.issue === Number(match[1])) || 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
30
  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
31
  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
32
  }
@@ -60,9 +65,10 @@ export async function reviewThread(api, number, owner) {
60
65
  export async function loadProposalFiles(api, review) {
61
66
  const changed = await api(`pulls/${review.number}/files?per_page=100`);
62
67
  const root = proposalRoot(review.issue);
63
- if (changed.length >= 100 || changed.some(f => !f.filename.startsWith(`${root}/`) || f.status === 'removed' || f.status === 'renamed')) throw Error('Proposal PR includes changes outside the proposal');
68
+ if (changed.length >= 100 || changed.some(f => (!review.productProposal || f.filename.startsWith(`${root}/`)) && (!f.filename.startsWith(`${root}/`) || f.status === 'removed' || f.status === 'renamed'))) throw Error('Proposal PR includes unsupported proposal changes');
64
69
  const files = [];
65
70
  for (const f of changed) {
71
+ if (!f.filename.startsWith(`${root}/`)) continue;
66
72
  const value = await api(`contents/${f.filename}?ref=${review.head}`);
67
73
  if (value.type !== 'file' || value.encoding !== 'base64' || value.size > 96000) throw Error('Unsupported proposal file');
68
74
  const content = Buffer.from(value.content, 'base64').toString('utf8');
@@ -86,7 +92,11 @@ export async function updateProposal(api, repo, snapshot, files, beforeWrite) {
86
92
  const created = await api('git/commits', 'POST', { message: `docs: address proposal review for #${snapshot.issue}`, tree: tree.sha, parents: [review.head] });
87
93
  await beforeWrite();
88
94
  await checkReviewCurrent(api, repo, review);
95
+ const current = review.productProposal ? await api(`pulls/${review.number}`) : null;
96
+ const proof = current ? productProposalProof(current) : null;
97
+ if (review.productProposal && (!proof || current.head.sha !== review.head)) throw Error('Product proposal changed before publication');
89
98
  // Non-forced update rejects a concurrent writer after the final head check.
90
99
  await api(`git/refs/heads/${review.branch}`, 'PATCH', { sha: created.sha, force: false });
100
+ if (proof) await api(`pulls/${review.number}`, 'PATCH', { body: current.body.replace(/<!-- lighthouse-product-proposal:\{[^\n]*\} -->/g, `<!-- lighthouse-product-proposal:${JSON.stringify({ ...proof, runId: snapshot.runId, head: created.sha })} -->`) });
91
101
  return { number: review.number, html_url: review.url };
92
102
  }
package/src/proposal.mjs CHANGED
@@ -3,6 +3,8 @@ import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { execFileSync } from 'node:child_process';
6
+ import { isDeepStrictEqual } from 'node:util';
7
+ import { checkProductResumePullRequest } from './product-change.mjs';
6
8
 
7
9
  export const changeName = issue => {
8
10
  if (!Number.isSafeInteger(issue) || issue < 1) throw Error('Invalid proposal issue');
@@ -89,7 +91,8 @@ export async function loadStageReport(api, repo, report, current) {
89
91
  if (request.dispatch) {
90
92
  if (!['triage', 'refine', 'propose', 'implement'].includes(request.stage) || !report.body.includes(`<!-- lighthouse-stage:${request.stage}:`)) throw Error('Unexpected dispatched stage');
91
93
  const dispatch = await loadDispatch(api, repo, current.owner, current.issue, request.dispatch.commentId, run);
92
- if (dispatch.request.stage !== request.stage || dispatch.request.id !== request.dispatch.requestId || dispatch.request.sourceRevision !== request.sourceRevision || dispatch.request.workflowRevision !== request.workflowRevision) throw Error('Dispatched report identity mismatch');
94
+ const dispatchedRevision = request.epic && request.stage === 'implement' ? request.baseRevision : request.sourceRevision;
95
+ if (dispatch.request.stage !== request.stage || dispatch.request.id !== request.dispatch.requestId || dispatch.request.sourceRevision !== dispatchedRevision || dispatch.request.workflowRevision !== request.workflowRevision || !isDeepStrictEqual(dispatch.request.epic, request.epic)) throw Error('Dispatched report identity mismatch');
93
96
  await checkDispatchClaim(api, repo, current.issue, request.dispatch, run.id);
94
97
  } else if (!['issues', 'issue_comment', 'workflow_dispatch'].includes(run.event) || run.path !== '.github/workflows/lighthouse-sdlc-router.yml' || run.actor?.login !== current.owner || run.head_sha !== request.sourceRevision) throw Error('Stage report identity mismatch');
95
98
  if (run.status !== 'completed' || run.conclusion !== 'success' || String(run.id) !== String(request.workflowRunId) || request.issue !== current.issue || marker !== request.runId) throw Error('Stage report identity mismatch');
@@ -111,6 +114,15 @@ export async function createProposalPullRequest(api, snapshot, files, body, runU
111
114
  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 })) });
112
115
  const created = await api('git/commits', 'POST', { message: `docs: propose issue #${snapshot.issue}`, tree: tree.sha, parents: [snapshot.sourceRevision] });
113
116
  await beforeWrite();
117
+ if (snapshot.productResume) {
118
+ const resume = snapshot.productResume;
119
+ const current = await api(`pulls/${resume.pullRequest}`);
120
+ checkProductResumePullRequest(current, snapshot.repository, resume);
121
+ await api(`git/refs/heads/${resume.branch}`, 'PATCH', { sha: created.sha, force: false });
122
+ const proof = { schemaVersion: 1, issue: snapshot.issue, runId: snapshot.runId, applyIntent: resume.receiptIntent, head: created.sha };
123
+ await api(`pulls/${resume.pullRequest}`, 'PATCH', { body: `${current.body}\n\n${body.trim()}\n\n[Proposal evidence](${runUrl}).\n\n<!-- lighthouse-product-proposal:${JSON.stringify(proof)} -->` });
124
+ return { number: resume.pullRequest, html_url: current.html_url };
125
+ }
114
126
  await api('git/refs', 'POST', { ref: `refs/heads/${branch}`, sha: created.sha });
115
127
  const pr = await api('pulls', 'POST', { title: `Proposal for #${snapshot.issue}: ${snapshot.title.slice(0, 160)}`, head: branch, base: snapshot.defaultBranch, body: `${body}\n\nRelated to #${snapshot.issue}. Proposal only; implementation requires a separate owner decision.\n\nOpenSpec 1.15.0 strict validation passed. [Execution evidence](${runUrl}).\n\n<!-- lighthouse-proposal:${snapshot.runId} -->` });
116
128
  return pr;
package/src/stacked.mjs CHANGED
@@ -14,6 +14,11 @@ export function buildStackedPlan(input) {
14
14
  const errors = [];
15
15
  if (!isRecord(input) || !isRecord(input.parent) || !Array.isArray(input.slices)) return { schemaVersion: 1, status: 'invalid', valid: false, canApply: false, errors: [issue('stacked.invalid', 'stacked input needs parent and slices')] };
16
16
  const parentRef = input.parent.number ?? input.parent.id;
17
+ const epic = input.mode === 'epic';
18
+ if (epic && input.slices.length === 0) errors.push(issue('stacked.empty', 'epic mode requires at least one child', 'slices'));
19
+ if (input.mode !== undefined && !['epic', 'nested'].includes(input.mode)) errors.push(issue('stacked.mode', 'mode must be epic or nested', 'mode'));
20
+ const epicParent = String(parentRef ?? '').match(/^(?:GH-)?([1-9]\d*)$/)?.[1];
21
+ if (epic && (!epicParent || input.parent.integrationBranch !== `lighthouse/epic-${epicParent}` || input.parent.integrationBranch === (input.parent.base ?? 'main'))) errors.push(issue('stacked.integration-branch', 'epic mode needs the integration branch for its parent issue', 'parent.integrationBranch'));
17
22
  if (parentRef === undefined || parentRef === null || String(parentRef).trim() === '') errors.push(issue('stacked.parent', 'parent needs an id or number', 'parent'));
18
23
  const seenBranches = new Set();
19
24
  const slices = input.slices.map((slice, index) => {
@@ -23,9 +28,14 @@ export function buildStackedPlan(input) {
23
28
  if (!branch) { errors.push(issue('stacked.branch', `slice ${slice.id} needs an explicit branch when the parent has no numeric issue id`, `slices[${index}].branch`)); return null; }
24
29
  if (seenBranches.has(branch)) errors.push(issue('stacked.branch-duplicate', `branch ${branch} appears more than once`, `slices[${index}].branch`));
25
30
  seenBranches.add(branch);
26
- return { id: slice.id, title: slice.title, branch, base: slice.base ?? null, dependencies: [...(slice.dependencies ?? [])].sort(), acceptance: [...(slice.acceptance ?? [])] };
31
+ if (epic && slice.base !== undefined && slice.base !== input.parent.integrationBranch) errors.push(issue('stacked.epic-base', 'epic children must target the shared integration branch', `slices[${index}].base`));
32
+ const strings = value => Array.isArray(value) && value.every(item => typeof item === 'string' && item.trim());
33
+ if ((epic || slice.dependencies != null) && !strings(slice.dependencies)) errors.push(issue('stacked.dependencies', 'dependencies must be a string array', `slices[${index}].dependencies`));
34
+ if (slice.acceptance != null && !strings(slice.acceptance)) errors.push(issue('stacked.acceptance', 'acceptance must be a string array', `slices[${index}].acceptance`));
35
+ return { id: slice.id, title: slice.title, branch, base: slice.base ?? null, dependencies: strings(slice.dependencies) ? [...slice.dependencies].sort() : [], acceptance: strings(slice.acceptance) ? [...slice.acceptance] : [] };
27
36
  }).filter(Boolean);
28
37
  const byId = new Map(slices.map((slice) => [slice.id, slice]));
38
+ if (byId.size !== slices.length) errors.push(issue('stacked.duplicate', 'slice identities must be unique', 'slices'));
29
39
  const order = [];
30
40
  const visiting = new Set();
31
41
  const visited = new Set();
@@ -44,7 +54,8 @@ export function buildStackedPlan(input) {
44
54
  for (const slice of slices) visit(slice.id);
45
55
  const operations = order.map((id, index) => {
46
56
  const slice = byId.get(id);
47
- return { type: 'open-pull-request', sliceId: id, branch: slice.branch, base: slice.base ?? (index === 0 ? input.parent.base ?? 'main' : slices.find((candidate) => candidate.id === order[index - 1])?.branch) };
57
+ return { type: 'open-pull-request', sliceId: id, branch: slice.branch, base: epic ? input.parent.integrationBranch : slice.base ?? (index === 0 ? input.parent.base ?? 'main' : slices.find((candidate) => candidate.id === order[index - 1])?.branch) };
48
58
  });
49
- return { schemaVersion: 1, status: errors.length ? 'invalid' : 'ready', valid: errors.length === 0, canApply: errors.length === 0, parent: input.parent, slices, order, operations, errors };
59
+ if (epic) operations.push({ type: 'open-aggregate-pull-request', branch: input.parent.integrationBranch, base: input.parent.base ?? 'main', requires: [...order] });
60
+ return { schemaVersion: 1, status: errors.length ? 'invalid' : 'ready', valid: errors.length === 0, canApply: errors.length === 0, parent: input.parent, slices, order, operations: errors.length ? [] : operations, errors };
50
61
  }
@@ -1,5 +1,7 @@
1
1
  import { digest } from './host-contract.mjs';
2
2
  import { readActionsJson } from './actions-artifact.mjs';
3
+ import { handoff } from './handoff.mjs';
4
+ import { isDeepStrictEqual } from 'node:util';
3
5
 
4
6
  const routerPath = '.github/workflows/lighthouse-sdlc-router.yml';
5
7
  export const stages = ['triage', 'refine', 'propose', 'implement', 'integrate', 'review'];
@@ -7,13 +9,15 @@ export const workers = [...stages, 'apply-review', 'product-change', 'apply-prod
7
9
  export const workerFor = request => request.worker ?? request.stage ?? 'implement';
8
10
  export const stagePath = stage => { if (!workers.includes(stage)) throw Error('Unknown dispatch worker'); return `.github/workflows/lighthouse-sdlc-${stage}.lock.yml`; };
9
11
  export const implementPath = stagePath('implement');
10
- export const statusText = (stage, status) => `${({ requested: '📥 Requested', running: '▶️ Execution admitted', completed: '✅ Completed', waiting: '⏸️ Waiting for your response', failed: '❌ Failed or cancelled' })[status]} · ${stage}`;
12
+ export const statusText = (stage, status) => `${({ requested: 'Requested', running: 'Queued', completed: 'Stage completed', waiting: 'Waiting for your answer', blocked: 'Blocked', failed: 'Execution failed', cancelled: 'Cancelled', unknown: 'Execution status unknown' })[status] ?? 'Execution status unknown'} · ${stage}`;
11
13
  const positive = value => /^[1-9][0-9]*$/.test(String(value));
12
- export const isBotContinuation = (event, eventName, actor, triggeringActor) => eventName === 'workflow_dispatch' && actor === 'github-actions[bot]' && triggeringActor === actor && event.inputs?.stage === 'implement' && positive(event.inputs?.issue) && positive(event.inputs?.after_run);
14
+ export const isBotContinuation = (event, eventName, actor, triggeringActor) => eventName === 'workflow_dispatch' && actor === 'github-actions[bot]' && triggeringActor === actor && (event.inputs?.stage === 'implement' && event.inputs?.workflow !== 'product-change' || ['triage', 'refine', 'propose', 'implement'].includes(event.inputs?.stage) && ['product-change', 'resume-product'].includes(event.inputs?.workflow)) && positive(event.inputs?.issue) && positive(event.inputs?.after_run);
15
+ export const isBotEpicTriage = (event, eventName, actor, triggeringActor) => eventName === 'workflow_dispatch' && actor === 'github-actions[bot]' && triggeringActor === actor && event.inputs?.stage === 'triage' && positive(event.inputs?.issue) && positive(event.inputs?.epic_parent) && positive(event.inputs?.epic_run);
13
16
  const sha = value => /^[a-f0-9]{40}$/.test(value ?? '');
14
17
  const hash = value => /^[a-f0-9]{64}$/.test(value ?? '');
15
18
  export const dispatchId = (repository, runId, stage = 'implement') => digest(`${repository}:${runId}:${stage}`);
16
- export const receiptId = (repository, receipt) => receipt.recovery ? digest(`${repository}:${receipt.recovery.rootId}:retry:${receipt.recovery.attempt}`) : dispatchId(repository, receipt.feedbackKey ?? receipt.eventRunId ?? receipt.routerRunId, receipt.stage ?? 'implement');
19
+ export const epicTriageKey = (repository, parent, child, planDigest) => digest(`${repository}:epic-triage:${parent}:${child}:${planDigest}`);
20
+ export const receiptId = (repository, receipt) => receipt.recovery ? digest(`${repository}:${receipt.recovery.rootId}:retry:${receipt.recovery.attempt}`) : dispatchId(repository, receipt.epicKey ?? receipt.feedbackKey ?? receipt.eventRunId ?? receipt.routerRunId, receipt.stage ?? 'implement');
17
21
  export const dispatchArtifact = request => `lighthouse-dispatch-${request.id}`;
18
22
 
19
23
  export function readDispatchReceipt(comment, repository, issue) {
@@ -22,16 +26,28 @@ export function readDispatchReceipt(comment, repository, issue) {
22
26
  if (matches.length !== 1) throw Error('Missing or ambiguous dispatch receipt');
23
27
  const receipt = JSON.parse(matches[0][1]);
24
28
  if (receipt.recovery && (!hash(receipt.recovery.rootId) || !positive(receipt.recovery.previousComment) || !Number.isSafeInteger(receipt.recovery.attempt) || receipt.recovery.attempt < 1 || receipt.recovery.attempt > 2)) throw Error('Invalid recovery receipt');
25
- if (receipt.schemaVersion !== 1 || !hash(receipt.id) || !hash(receipt.digest) || !positive(receipt.routerRunId) || !positive(receipt.routerAttempt) || !stages.includes(receipt.stage ?? 'implement') || !workers.includes(workerFor(receipt)) || (receipt.feedbackKey !== undefined && !hash(receipt.feedbackKey)) || (receipt.eventRunId !== undefined && !positive(receipt.eventRunId)) || receipt.id !== receiptId(repository, receipt) || (receipt.workerRunId !== null && !positive(receipt.workerRunId))) throw Error('Invalid dispatch receipt');
29
+ if (receipt.schemaVersion !== 1 || !hash(receipt.id) || !hash(receipt.digest) || !positive(receipt.routerRunId) || !positive(receipt.routerAttempt) || !stages.includes(receipt.stage ?? 'implement') || !workers.includes(workerFor(receipt)) || (receipt.epicKey !== undefined && !hash(receipt.epicKey)) || (receipt.feedbackKey !== undefined && !hash(receipt.feedbackKey)) || (receipt.eventRunId !== undefined && !positive(receipt.eventRunId)) || receipt.id !== receiptId(repository, receipt) || (receipt.workerRunId !== null && !positive(receipt.workerRunId))) throw Error('Invalid dispatch receipt');
26
30
  return receipt;
27
31
  }
28
32
 
29
33
  export function dispatchReceiptBody(repository, receipt) {
30
34
  const root = `https://github.com/${repository}/actions/runs/`;
31
- const heading = statusText(workerFor(receipt), receipt.status ?? (receipt.workerRunId ? 'running' : 'requested'));
32
- const status = receipt.workerRunId ? `[Execution](${root}${receipt.workerRunId}). Open the execution for its current status and result.` : 'Execution requested; execution is not yet confirmed. Do not resend while confirmation is pending.';
33
- const recovery = receipt.recovery ? `\n\nRetry ${receipt.recovery.attempt}/2 · [Previous request](https://github.com/${repository}/issues/${receipt.issue ?? ''}#issuecomment-${receipt.recovery.previousComment})` : '';
34
- return `${heading}\n\n${status}${recovery}\n\n[Dispatch origin](${root}${receipt.routerRunId})\n\n<!-- lighthouse-dispatch:${JSON.stringify(receipt)} -->`;
35
+ const status = receipt.status ?? (receipt.workerRunId ? 'running' : 'requested');
36
+ const states = {
37
+ requested: ['requested', 'The request is recorded; execution is not confirmed.', 'None. Wait for admission.', 'The workflow will report the outcome when the request is processed.'],
38
+ running: ['queued', 'The worker admitted the request; agent execution is not yet confirmed.', 'None. Wait for the result.', 'The workflow will publish a result or an actionable stop.'],
39
+ completed: ['completed', 'The stage published a validated result.', 'Follow the latest stage handoff.', 'The configured continuation applies only when authorized.'],
40
+ waiting: ['blocked', 'The stage published a blocked result.', 'Follow the latest stage handoff.', 'Continuation depends on the recorded blocker and authorization.'],
41
+ blocked: ['blocked', 'The stage published a blocker without a pending owner question.', 'The workflow maintainer should follow the latest stage handoff.', 'Work remains stopped until the blocker is resolved and continuation is authorized.'],
42
+ failed: ['failed', 'This attempt did not publish a validated result.', 'The workflow maintainer should inspect the execution and recovery policy.', 'Any retry requires the configured authorization.'],
43
+ cancelled: ['cancelled', 'This attempt was cancelled before a validated result was published.', 'The workflow maintainer should inspect the execution.', 'Any restart requires the configured authorization.'],
44
+ unknown: ['unknown', 'Publication of a validated result could not be confirmed.', 'The workflow maintainer should inspect the execution.', 'Reconcile the outcome before requesting another attempt.'],
45
+ };
46
+ const item = states[status];
47
+ if (!item) throw Error('Unknown dispatch status');
48
+ const recovery = receipt.recovery ? `\nRetry ${receipt.recovery.attempt}/2 · [Previous request](https://github.com/${repository}/issues/${receipt.issue ?? ''}#issuecomment-${receipt.recovery.previousComment})` : '';
49
+ const details = `[Dispatch origin](${root}${receipt.routerRunId})${receipt.workerRunId ? ` · [Execution](${root}${receipt.workerRunId})` : ''}${recovery}\n\n<!-- lighthouse-dispatch:${JSON.stringify(receipt)} -->`;
50
+ return handoff({ status: item[0], stage: workerFor(receipt), happened: item[1], action: item[2], next: item[3], details });
35
51
  }
36
52
 
37
53
  export async function issueComments(api, issue) {
@@ -57,7 +73,7 @@ export async function recordDispatch(api, repository, request) {
57
73
  const run = await api(`actions/runs/${receipt.workerRunId}`);
58
74
  if (run.path !== stagePath(workerFor(receipt)) || run.status !== 'completed' && request.queue !== true) throw Error('A stage execution is still pending or running');
59
75
  }
60
- const receipt = { schemaVersion: 1, stage: request.stage, ...(request.recovery ? { recovery: request.recovery, issue: request.issue } : {}), ...(request.worker ? { worker: request.worker } : {}), ...(request.feedbackKey ? { feedbackKey: request.feedbackKey } : {}), status: 'requested', ...(request.eventRunId ? { eventRunId: request.eventRunId } : {}), id: request.id, digest: digest(JSON.stringify(request)), routerRunId: request.routerRunId, routerAttempt: request.routerAttempt, workerRunId: null };
76
+ const receipt = { schemaVersion: 1, stage: request.stage, ...(request.recovery ? { recovery: request.recovery, issue: request.issue } : {}), ...(request.worker ? { worker: request.worker } : {}), ...(request.feedbackKey ? { feedbackKey: request.feedbackKey } : {}), ...(request.epicKey ? { epicKey: request.epicKey } : {}), status: 'requested', ...(request.eventRunId ? { eventRunId: request.eventRunId } : {}), id: request.id, digest: digest(JSON.stringify(request)), routerRunId: request.routerRunId, routerAttempt: request.routerAttempt, workerRunId: null };
61
77
  const comment = await api(`issues/${request.issue}/comments`, 'POST', { body: dispatchReceiptBody(repository, receipt) });
62
78
  return { send: true, comment, receipt };
63
79
  }
@@ -67,7 +83,20 @@ export async function sendDispatch(api, repository, request, commentId) {
67
83
  if (receipt.id !== request.id || receipt.digest !== digest(JSON.stringify(request))) throw Error('Dispatch request changed');
68
84
  if (receipt.workerRunId) return;
69
85
  // Never retry this POST automatically: its response can be lost after admission.
70
- return api(`actions/workflows/lighthouse-sdlc-${workerFor(request)}.lock.yml/dispatches`, 'POST', { ref: request.ref, inputs: { issue: String(request.issue), 'dispatch-request': String(commentId), ...(workerFor(request) === 'apply-product-change' ? { 'pull-request': String(request.productApproval.pullRequest), head: request.productApproval.head } : {}) } });
86
+ const parent = epicLeaseParent(request);
87
+ return api(`actions/workflows/lighthouse-sdlc-${workerFor(request)}.lock.yml/dispatches`, 'POST', { ref: request.ref, inputs: { issue: String(request.issue), 'dispatch-request': String(commentId), ...(parent ? { 'epic-parent': String(parent) } : {}), ...(workerFor(request) === 'apply-product-change' ? { 'pull-request': String(request.productApproval.pullRequest), head: request.productApproval.head } : {}) } });
88
+ }
89
+
90
+ export function epicLeaseParent(request) {
91
+ if (request.epic === undefined) return null;
92
+ const epic = request.epic;
93
+ if (!epic || !Number.isSafeInteger(epic.parent) || epic.parent < 1 || epic.parent === request.issue || !(['triage', 'refine', 'implement'].includes(workerFor(request)) || workerFor(request) === 'apply-review' && request.stage === 'implement') || epic.base !== `lighthouse/epic-${epic.parent}` || !hash(epic.planDigest)) throw Error('Invalid epic dispatch lease');
94
+ return epic.parent;
95
+ }
96
+
97
+ export function checkEpicWorkerLease(request, inputs) {
98
+ const parent = epicLeaseParent(request);
99
+ if ((inputs?.['epic-parent'] ?? '') !== (parent ? String(parent) : '')) throw Error('Worker epic lease does not match the authenticated dispatch');
71
100
  }
72
101
 
73
102
  // Called only for a dispatch whose router identity is authenticated by the host.
@@ -83,13 +112,14 @@ export function executionModelSelection(request) {
83
112
  }
84
113
 
85
114
  export function verifyDispatchOrigin(request, receipt, parent, worker, repository, owner, issue) {
86
- const delegatedWorker = workerFor(request) === 'apply-review' && ['propose', 'implement'].includes(request.stage) || workerFor(request) === 'product-change' && ['propose', 'implement'].includes(request.stage);
115
+ const delegatedWorker = workerFor(request) === 'apply-review' && ['propose', 'implement'].includes(request.stage) || workerFor(request) === 'product-change' && ['triage', 'refine', 'propose', 'implement'].includes(request.stage);
87
116
  const applyProductWorker = workerFor(request) === 'apply-product-change' && ['propose', 'implement'].includes(request.stage);
88
117
  if (workerFor(request) !== workerFor(receipt) || request.feedbackKey !== receipt.feedbackKey || workerFor(request) !== request.stage && !delegatedWorker && !applyProductWorker) throw Error('Dispatch worker responsibility mismatch');
89
118
  if (workerFor(request) === 'product-change') {
90
119
  const idea = request.eventName === 'workflow_dispatch' && request.event?.inputs?.workflow === 'product-change';
91
120
  const feedback = request.productFeedback && ['answer', 'request-changes'].includes(request.productFeedback.action) && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.productFeedback.changeName ?? '') && typeof request.productFeedback.text === 'string' && request.productFeedback.text.trim().length > 0 && request.productFeedback.text.length <= 12000 && ['issue_comment', 'pull_request_review'].includes(request.eventName);
92
- if (!idea && !feedback) throw Error('Product Change worker needs an authorized idea selector or authenticated owner feedback');
121
+ const parent = request.productParent && request.productParent.issue === issue && request.productParent.stage === request.stage && (request.eventName === 'workflow_run' && String(request.productParent.predecessor?.runId) === String(request.event?.workflow_run?.id) || request.eventName === 'workflow_dispatch' && request.event?.inputs?.workflow === 'product-change' && String(request.productParent.predecessor?.runId) === String(request.event.inputs.after_run));
122
+ if (!idea && !feedback && !parent) throw Error('Product Change worker needs an authorized idea, owner feedback or authenticated parent prerequisite');
93
123
  }
94
124
  if (workerFor(request) === 'apply-product-change' && (!request.productApproval || request.productApproval.kind !== 'approval' || !hash(request.productApproval.reviewKey))) throw Error('Trusted Product Change apply requires a bound approval');
95
125
  if (JSON.stringify(request.recovery) !== JSON.stringify(receipt.recovery)) throw Error('Recovery receipt identity mismatch');
@@ -98,10 +128,12 @@ export function verifyDispatchOrigin(request, receipt, parent, worker, repositor
98
128
  const recovery = !!request.recovery;
99
129
  const originPath = recovery ? '.github/workflows/lighthouse-worker-maintenance.yml' : routerPath;
100
130
  const originEvent = recovery ? request.routerEventName : request.eventName;
101
- const continuation = !recovery && isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login) && request.stage === 'implement' && request.handoff && !request.worker && !request.feedbackKey && Number(request.event.inputs.issue) === issue && request.predecessor?.runId === request.event.inputs.after_run;
102
- if (String(parent.id) !== String(receipt.routerRunId) || parent.run_attempt !== receipt.routerAttempt || request.routerRunId !== receipt.routerRunId || request.routerAttempt !== receipt.routerAttempt || parent.path !== originPath || (parent.event !== 'workflow_run' && !continuation && (parent.actor?.login !== owner || parent.triggering_actor?.login !== owner)) || !(recovery ? ['schedule', 'workflow_dispatch'] : events).includes(parent.event) || parent.event !== originEvent || parent.head_sha !== (request.routerRevision ?? request.workflowRevision) || parent.head_branch !== (request.routerRef ?? request.ref) || parent.repository?.full_name !== repository) throw Error('Dispatch origin is not an authorized router execution');
131
+ const continuation = !recovery && isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login) && Number(request.event.inputs.issue) === issue && (request.stage === 'implement' && request.handoff && !request.worker && !request.feedbackKey && request.predecessor?.runId === request.event.inputs.after_run || request.worker === 'product-change' && request.productParent?.predecessor?.runId === request.event.inputs.after_run || (!request.worker || request.worker === 'apply-review' && request.productResume?.parent.review) && request.event.inputs.workflow === 'resume-product' && request.productResume?.applyRun?.runId === request.event.inputs.after_run);
132
+ const epicTriage = !recovery && isBotEpicTriage(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login) && request.stage === 'triage' && !request.handoff && !request.worker && Number(request.event.inputs.issue) === issue && Number(request.event.inputs.epic_parent) === request.epic?.parent;
133
+ if (epicTriage ? request.epicKey !== epicTriageKey(repository, request.epic.parent, issue, request.epic.planDigest) || receipt.epicKey !== request.epicKey : request.epicKey !== undefined || receipt.epicKey !== undefined) throw Error('Epic triage identity is invalid');
134
+ if (String(parent.id) !== String(receipt.routerRunId) || parent.run_attempt !== receipt.routerAttempt || request.routerRunId !== receipt.routerRunId || request.routerAttempt !== receipt.routerAttempt || parent.path !== originPath || (parent.event !== 'workflow_run' && !continuation && !epicTriage && (parent.actor?.login !== owner || parent.triggering_actor?.login !== owner)) || !(recovery ? ['schedule', 'workflow_dispatch'] : events).includes(parent.event) || parent.event !== originEvent || parent.head_sha !== (request.routerRevision ?? request.workflowRevision) || parent.head_branch !== (request.routerRef ?? request.ref) || parent.repository?.full_name !== repository) throw Error('Dispatch origin is not an authorized router execution');
103
135
  executionModelSelection(request);
104
- const eventRunId = request.eventName === 'workflow_run' ? request.event?.workflow_run?.id : request.event?.inputs?.after_run;
136
+ const eventRunId = request.eventName === 'workflow_run' ? request.event?.workflow_run?.id : request.event?.inputs?.epic_run ?? request.event?.inputs?.after_run;
105
137
  if (String(eventRunId ?? '') !== String(receipt.eventRunId ?? '') || String(request.eventRunId ?? '') !== String(receipt.eventRunId ?? '')) throw Error('Completion event identity mismatch');
106
138
  if (worker.path !== stagePath(workerFor(request)) || worker.event !== 'workflow_dispatch' || worker.head_sha !== request.workflowRevision || worker.head_branch !== request.ref || worker.repository?.full_name !== repository || ![owner, 'github-actions[bot]'].includes(worker.actor?.login) || ![owner, 'github-actions[bot]'].includes(worker.triggering_actor?.login)) throw Error('Dispatch worker identity mismatch');
107
139
  }
@@ -114,11 +146,25 @@ export async function loadDispatch(api, repository, owner, issue, commentId, wor
114
146
  const parent = await api(`actions/runs/${receipt.routerRunId}/attempts/${receipt.routerAttempt}`);
115
147
  const request = await readArtifact(api, repository, receipt.routerRunId, dispatchArtifact(receipt), 'dispatch.json');
116
148
  verifyDispatchOrigin(request, receipt, parent, worker, repository, owner, issue);
117
- if (isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login)) {
149
+ if (isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login) && !request.productParent && request.event?.inputs?.workflow !== 'resume-product') {
118
150
  const predecessor = await api(`actions/runs/${request.predecessor.runId}`);
119
151
  const next = await (await import('./stage-transition.mjs')).refinementAfterRun(api, repository, owner, { action: 'completed', workflow_run: { id: predecessor.id, head_sha: predecessor.head_sha } }, readArtifact);
120
152
  if (next?.issue !== issue || JSON.stringify(next.predecessor) !== JSON.stringify(request.predecessor)) throw Error('Bot continuation lacks an authenticated refinement');
121
153
  }
154
+ if (request.productResume) {
155
+ const apply = await api(`actions/runs/${request.productResume.applyRun.runId}`);
156
+ const observed = await (await import('./stage-transition.mjs')).productApplyAfterRun(api, repository, owner, { action: 'completed', workflow_run: { id: apply.id, head_sha: apply.head_sha } }, readArtifact);
157
+ if (JSON.stringify(observed) !== JSON.stringify(request.productResume) || observed.baseRevision !== request.sourceRevision || (observed.parent.feedback?.taskDigest ?? observed.parent.taskDigest) !== request.taskDigest) throw Error('Applied product prerequisite or parent authorization changed');
158
+ }
159
+ if (request.productParent) {
160
+ const parentRun = await api(`actions/runs/${request.productParent.predecessor.runId}`);
161
+ const observed = await (await import('./stage-transition.mjs')).productGapAfterRun(api, repository, owner, { action: 'completed', workflow_run: { id: parentRun.id, head_sha: parentRun.head_sha } }, readArtifact);
162
+ if (JSON.stringify(observed) !== JSON.stringify(request.productParent) || observed.baseRevision !== request.sourceRevision || observed.taskDigest !== request.taskDigest) throw Error('Product prerequisite or parent authorization changed');
163
+ }
164
+ if (isBotEpicTriage(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login)) {
165
+ const verified = await (await import('./epic-dispatch.mjs')).verifyEpicTriageOrigin(api, { repository, owner, child: issue, parent: Number(request.event.inputs.epic_parent), runId: request.event.inputs.epic_run }, { readArtifact });
166
+ if (!isDeepStrictEqual(verified.current, request.epic)) throw Error('Epic triage authority changed');
167
+ }
122
168
  const dispatch = { request, receipt, commentId: Number(commentId) };
123
169
  if (request.recovery) await (await import('./worker-recovery.mjs')).verifyRecoveryOrigin(api, repository, owner, dispatch, parent, readArtifact, depth);
124
170
  return dispatch;
@@ -146,6 +192,7 @@ export async function admitDispatch(api, repository, owner, env, event) {
146
192
  if (!Number.isSafeInteger(issue) || issue < 1 || String(issue) !== env.LIGHTHOUSE_ISSUE) throw Error('Dispatch issue must be a canonical positive integer');
147
193
  const worker = await api(`actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}`);
148
194
  const dispatch = await loadDispatch(api, repository, owner, issue, event.inputs?.['dispatch-request'], worker);
195
+ checkEpicWorkerLease(dispatch.request, event.inputs);
149
196
  if (env.LIGHTHOUSE_WORKER ? workerFor(dispatch.request) !== env.LIGHTHOUSE_WORKER : dispatch.request.stage !== env.LIGHTHOUSE_STAGE) throw Error('Dispatch targets a different stage or worker');
150
197
  if (dispatch.request.recovery) {
151
198
  if (env.LIGHTHOUSE_RECOVERY_ENABLED !== 'true') throw Error('Automatic recovery has been disabled');
@@ -160,7 +207,7 @@ export async function finishDispatch(api, repository, issue, commentId, workerId
160
207
  if (!positive(commentId)) return;
161
208
  const receipt = readDispatchReceipt(await api(`issues/comments/${commentId}`), repository, issue);
162
209
  if (String(receipt.workerRunId) !== String(workerId)) return;
163
- receipt.status = outcome === 'passed' ? 'completed' : outcome === 'blocked' ? 'waiting' : 'failed';
210
+ receipt.status = outcome === 'passed' ? 'completed' : outcome === 'question' ? 'waiting' : outcome === 'blocked' ? 'blocked' : outcome === 'failed' ? 'failed' : outcome === 'cancelled' ? 'cancelled' : 'unknown';
164
211
  await api(`issues/comments/${commentId}`, 'PATCH', { body: dispatchReceiptBody(repository, receipt) });
165
212
  }
166
213
 
@@ -174,12 +221,12 @@ export async function completedEvent(api, event, env, wait = ms => new Promise(r
174
221
  if (!bot && (env.GITHUB_EVENT_NAME !== 'workflow_dispatch' || env.GITHUB_ACTOR !== env.GITHUB_REPOSITORY_OWNER || env.GITHUB_TRIGGERING_ACTOR !== env.GITHUB_REPOSITORY_OWNER || !positive(id))) throw Error('Completion replay requires an owner and a run ID, or a verified refinement continuation');
175
222
  let run = await api(`actions/runs/${id}`);
176
223
  if (bot) {
177
- if (run.path !== stagePath('refine')) throw Error('Bot continuation must reference a refinement');
224
+ if (event.inputs?.workflow === 'resume-product' ? run.path !== stagePath('apply-product-change') : event.inputs?.workflow === 'product-change' ? !['triage', 'refine', 'propose', 'implement', 'apply-review'].some(stage => run.path === stagePath(stage)) : run.path !== stagePath('refine')) throw Error(event.inputs?.workflow === 'resume-product' ? 'Bot continuation must reference a product apply' : event.inputs?.workflow === 'product-change' ? 'Bot continuation must reference a supported parent execution' : 'Bot continuation must reference a refinement');
178
225
  // The publisher signals before its own workflow has finished its final jobs.
179
226
  for (let attempt = 0; run.status !== 'completed' && attempt < 24; attempt++) {
180
227
  await wait(5000); run = await api(`actions/runs/${id}`);
181
228
  }
182
- if (run.status !== 'completed') throw Error('Refinement completion timed out; replay after the execution completes');
229
+ if (run.status !== 'completed') throw Error('Parent completion timed out; replay after the execution completes');
183
230
  }
184
231
  return { action: 'completed', workflow_run: { id: run.id, head_sha: run.head_sha } };
185
232
  }