@juangcarmona/lighthouse-sdlc 0.18.7 → 0.18.8

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.
package/github-actions.md CHANGED
@@ -50,6 +50,8 @@ Successful refinement explicitly wakes the router after publication and reservat
50
50
 
51
51
  For acceptance or deliberate event replay, the owner can dispatch the router with `issue`, the expected next `stage` and `after_run` set to the completed execution ID. The bot can request authenticated refinement-to-implementation, product-prerequisite and applied-product parent continuations. Each request is a wake-up: the router verifies the originating run and its retained authority. Both use the same fresh observations and deduplication as `workflow_run`; neither can force a transition or resend an uncertain request. If the bounded wait expires, inspect the source run and replay after it completes. An expired request artifact, changed source, changed intent or edited receipt blocks admission. Request artifacts last 14 days. Inspect origin and worker runs before reconciling uncertainty; there is no database, persistent dispatcher or SoFa dependency.
52
52
 
53
+ When an authenticated Product Change worker finds that accepted product intent already covers its parent request, the router can resume the original stage if the parent has no existing PR and the result did not follow a product answer. The original scope and automatic implementation setting still apply. A repeated product gap stops for maintainer review instead of starting another alignment cycle. Parents with an existing PR or a later product answer need a separate continuation path.
54
+
53
55
  The worker creates the OpenSpec plan, implements it and runs checks in one workspace. The PR contains both the plan and code. A separate proposal PR remains available for design-only work; merging one is not an implementation prerequisite.
54
56
 
55
57
  By default, repositories with a `verify` npm script use `npm ci --ignore-scripts` and `npm run verify`. Other repositories configure checks once in `.lighthouse/implement.json`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juangcarmona/lighthouse-sdlc",
3
- "version": "0.18.7",
3
+ "version": "0.18.8",
4
4
  "type": "module",
5
5
  "description": "Provider-neutral SDLC skills, commands and route contracts for Lighthouse adopters",
6
6
  "repository": {
@@ -18,7 +18,7 @@ for (const name of files) {
18
18
  if (name === 'lighthouse-sdlc-router.yml') {
19
19
  const enabled = ['triage', 'refine', ...(propose ? ['propose'] : []), ...(implement ? ['implement'] : []), ...(completion ? ['integrate', 'review'] : [])];
20
20
  content = content.replace('LIGHTHOUSE_ENABLED_STAGES: triage,refine,propose,implement,integrate,review', `LIGHTHOUSE_ENABLED_STAGES: ${enabled.join(',')}`).replace('options: [triage, refine, propose, implement, integrate, review]', `options: [${enabled.join(', ')}]`);
21
- content = content.replace('options: [stage, product-change, resume-product]', `options: [stage${productChange ? ', product-change, resume-product' : ''}]`).replace('LIGHTHOUSE_PRODUCT_CHANGE_ENABLED: false', `LIGHTHOUSE_PRODUCT_CHANGE_ENABLED: ${productChange}`);
21
+ content = content.replace('options: [stage, product-change, resume-product, resume-aligned]', `options: [stage${productChange ? ', product-change, resume-product, resume-aligned' : ''}]`).replace('LIGHTHOUSE_PRODUCT_CHANGE_ENABLED: false', `LIGHTHOUSE_PRODUCT_CHANGE_ENABLED: ${productChange}`);
22
22
  if (!implement) content = content.replace('LIGHTHOUSE_AUTO_IMPLEMENT: true', 'LIGHTHOUSE_AUTO_IMPLEMENT: false');
23
23
  if (!completion) content = content.replaceAll('LIGHTHOUSE_COMPLETION_ENABLED: true', 'LIGHTHOUSE_COMPLETION_ENABLED: false');
24
24
  }
@@ -1,4 +1,4 @@
1
- import { refinementAfterRun, signalRefinementContinuation, productGapAfterRun, signalProductGap, signalProductApplyContinuation, productApplyAfterRun } from './stage-transition.mjs';
1
+ import { refinementAfterRun, signalRefinementContinuation, productAlignmentAfterRun, signalProductAlignment, productGapAfterRun, signalProductGap, signalProductApplyContinuation, productApplyAfterRun } from './stage-transition.mjs';
2
2
  import { dispatchId, dispatchArtifact, recordDispatch, sendDispatch, admitDispatch, loadDispatch, claimDispatch, checkDispatchClaim, finishDispatch, completedEvent, readDispatchReceipt, issueComments, stages, executionModelSelection, isBotEpicTriage, epicTriageKey } from './stage-dispatch.mjs';
3
3
  import { readActionsJson } from './actions-artifact.mjs';
4
4
  import { deliveryAfterChecks, dispatchDeliveryChecks } from './delivery-events.mjs';
@@ -152,6 +152,7 @@ export function refinementNextAction({ stage, status, autoImplement, paused }) {
152
152
  }
153
153
  export function checkPublication(snapshot, issue, comments, revision) {
154
154
  if (issue.state !== 'open' || issue.pull_request || revision !== (snapshot.baseRevision ?? snapshot.sourceRevision)) throw Error('Issue closed or source revision changed');
155
+ if (snapshot.productAlignment && issue.labels.some(x => x.name === 'future')) throw Error('Product alignment continuation was paused');
155
156
  if (issue.labels.some(x => x.name === 'human-working') || !issue.labels.some(x => x.name === 'agent-working')) throw Error('Execution reservation revoked');
156
157
  if (taskDigest(issue, comments) !== snapshot.taskDigest) throw Error('Issue or owner answers changed; request refinement again');
157
158
  }
@@ -277,8 +278,9 @@ async function main(command) {
277
278
  const after = ['route', 'dispatch-prepare', 'prepare'].includes(command) ? await completedEvent(api, event, env) : null;
278
279
  if (after) {
279
280
  const productResume = env.LIGHTHOUSE_PRODUCT_CHANGE_ENABLED === 'true' ? await productApplyAfterRun(api, repo, owner, after) : null;
280
- const productParent = !productResume && env.LIGHTHOUSE_PRODUCT_CHANGE_ENABLED === 'true' ? await productGapAfterRun(api, repo, owner, after) : null;
281
- const next = productResume ? { issue: productResume.issue, stage: productResume.stage, ...(productResume.parent.review ? { worker: 'apply-review' } : {}), productResume, reason: 'The approved product prerequisite was applied; resuming its authenticated parent.' } : productParent ? { issue: productParent.issue, stage: productParent.stage, worker: 'product-change', productParent, reason: 'An authenticated issue stage reported a product prerequisite.' } : await refinementAfterRun(api, repo, owner, after) ?? (env.LIGHTHOUSE_COMPLETION_ENABLED === 'true' ? await deliveryAfterChecks(api, repo, owner, after) : null);
281
+ const productAlignment = !productResume && env.LIGHTHOUSE_PRODUCT_CHANGE_ENABLED === 'true' ? await productAlignmentAfterRun(api, repo, owner, after) : null;
282
+ const productParent = !productResume && !productAlignment && env.LIGHTHOUSE_PRODUCT_CHANGE_ENABLED === 'true' ? await productGapAfterRun(api, repo, owner, after) : null;
283
+ const next = productResume ? { issue: productResume.issue, stage: productResume.stage, ...(productResume.parent.review ? { worker: 'apply-review' } : {}), productResume, reason: 'The approved product prerequisite was applied; resuming its authenticated parent.' } : productAlignment ? { issue: productAlignment.issue, stage: productAlignment.stage, productAlignment, reason: 'Accepted product intent covers the authenticated parent request.' } : productParent ? { issue: productParent.issue, stage: productParent.stage, worker: 'product-change', productParent, reason: 'An authenticated issue stage reported a product prerequisite.' } : await refinementAfterRun(api, repo, owner, after) ?? (env.LIGHTHOUSE_COMPLETION_ENABLED === 'true' ? await deliveryAfterChecks(api, repo, owner, after) : null);
282
284
  route = next ? { ...next, run: true, reason: next.reason ?? 'Checks passed; continuing delivery.' } : { run: false, reason: 'No delivery action needed.' };
283
285
  if (event.inputs?.after_run && route.run && (Number(event.inputs.issue) !== route.issue || event.inputs.stage !== route.stage)) throw Error('Completion replay target differs from the verified continuation');
284
286
  }
@@ -349,6 +351,7 @@ async function main(command) {
349
351
  const metadata = await api(''), revision = (await api(`commits/${metadata.default_branch}`)).sha;
350
352
  if ((!productEntry && !eligibleIssueAuthor(issue, owner, stage)) || issue.state !== 'open' && stage !== 'review' || issue.pull_request || issue.labels.some(x => x.name === 'human-working' || x.name === 'agent-working' && !queue)) throw Error('Issue not eligible for dispatch');
351
353
  if (route.productResume && (route.productResume.baseRevision !== revision || (route.productResume.parent.feedback?.taskDigest ?? route.productResume.parent.taskDigest) !== taskDigest(issue, answers) || issue.labels.some(x => ['future', 'needs-info', 'waiting-review'].includes(x.name)))) throw Error('Applied product parent, intent or authorization changed');
354
+ if (route.productAlignment && (route.productAlignment.baseRevision !== revision || route.productAlignment.parent.taskDigest !== taskDigest(issue, answers) || issue.labels.some(x => ['future', 'needs-info', 'waiting-review'].includes(x.name)))) throw Error('Aligned product parent, intent or authorization changed');
352
355
  if (route.productParent && (route.productParent.baseRevision !== revision || route.productParent.taskDigest !== taskDigest(issue, answers) || issue.labels.some(x => ['future', 'needs-info', 'waiting-review'].includes(x.name)))) throw Error('Product prerequisite parent, intent or authorization changed');
353
356
  if (handoff) {
354
357
  const prior = await readActionsJson(api, repo, route.predecessor.runId, `lighthouse-input-${issueNumber}-${route.predecessor.attempt}`, 'request.json');
@@ -366,7 +369,7 @@ async function main(command) {
366
369
  // Review events can run on a PR merge ref, which is not a dispatchable branch.
367
370
  // Only an explicit owner dispatch selects a workflow branch; native events use main.
368
371
  const manual = env.GITHUB_EVENT_NAME === 'workflow_dispatch';
369
- const request = { schemaVersion: 1, id: dispatchId(repo, epicIdentity ?? feedbackIdentity ?? eventRunId ?? env.GITHUB_RUN_ID, stage), ...(epicIdentity ? { epicKey: epicIdentity } : {}), ...(epicContext ? { epic: epicContext } : {}), ...(worker ? { worker, ...(feedbackIdentity ? { feedbackKey: feedbackIdentity } : {}) } : {}), ...(route.productApproval?.approval ? { productApproval: { ...route.productApproval.approval, pullRequest: route.productApproval.pullRequest, head: route.productApproval.head, issue: route.productApproval.issue, changeName: route.productApproval.changeName } } : {}), ...(route.productFeedback ? { productFeedback: route.productFeedback } : {}), ...(route.productParent ? { productParent: route.productParent } : {}), ...(route.productResume ? { productResume: route.productResume } : {}), ...(route.productReview && !route.productApproval?.approval ? { productReview: { pullRequest: route.productReview.pullRequest, head: route.productReview.head, issue: route.productReview.issue, changeName: route.productReview.changeName, ...(route.productReview.checkpoint?.parent ? { parent: route.productReview.checkpoint.parent } : {}) } } : {}), repository: repo, issue: issueNumber, stage, routerRunId: env.GITHUB_RUN_ID, routerAttempt: Number(env.GITHUB_RUN_ATTEMPT), routerRevision: router.head_sha, routerRef: router.head_branch, workflowRevision: manual ? router.head_sha : revision, sourceRevision: revision, ref: manual ? router.head_branch : metadata.default_branch, eventName: env.GITHUB_EVENT_NAME, event, handoff, queue, ...(handoff ? { predecessor: route.predecessor } : {}), ...(eventRunId ? { eventRunId } : {}), autoImplement: !epicContext && stage === 'refine' && env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true' && (!route.productResume || route.productResume.parent.autoImplement), productChangeEnabled: env.LIGHTHOUSE_PRODUCT_CHANGE_ENABLED === 'true', taskDigest: taskDigest(issue, answers) };
372
+ const request = { schemaVersion: 1, id: dispatchId(repo, epicIdentity ?? feedbackIdentity ?? eventRunId ?? env.GITHUB_RUN_ID, stage), ...(epicIdentity ? { epicKey: epicIdentity } : {}), ...(epicContext ? { epic: epicContext } : {}), ...(worker ? { worker, ...(feedbackIdentity ? { feedbackKey: feedbackIdentity } : {}) } : {}), ...(route.productApproval?.approval ? { productApproval: { ...route.productApproval.approval, pullRequest: route.productApproval.pullRequest, head: route.productApproval.head, issue: route.productApproval.issue, changeName: route.productApproval.changeName } } : {}), ...(route.productFeedback ? { productFeedback: route.productFeedback } : {}), ...(route.productParent ? { productParent: route.productParent } : {}), ...(route.productResume ? { productResume: route.productResume } : {}), ...(route.productAlignment ? { productAlignment: route.productAlignment } : {}), ...(route.productReview && !route.productApproval?.approval ? { productReview: { pullRequest: route.productReview.pullRequest, head: route.productReview.head, issue: route.productReview.issue, changeName: route.productReview.changeName, ...(route.productReview.checkpoint?.parent ? { parent: route.productReview.checkpoint.parent } : {}) } } : {}), repository: repo, issue: issueNumber, stage, routerRunId: env.GITHUB_RUN_ID, routerAttempt: Number(env.GITHUB_RUN_ATTEMPT), routerRevision: router.head_sha, routerRef: router.head_branch, workflowRevision: manual ? router.head_sha : revision, sourceRevision: revision, ref: manual ? router.head_branch : metadata.default_branch, eventName: env.GITHUB_EVENT_NAME, event, handoff, queue, ...(handoff ? { predecessor: route.predecessor } : {}), ...(eventRunId ? { eventRunId } : {}), autoImplement: !epicContext && stage === 'refine' && env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true' && (!route.productResume || route.productResume.parent.autoImplement) && (!route.productAlignment || route.productAlignment.parent.autoImplement), productChangeEnabled: env.LIGHTHOUSE_PRODUCT_CHANGE_ENABLED === 'true', taskDigest: taskDigest(issue, answers) };
370
373
  executionModelSelection(request);
371
374
  const recorded = await recordDispatch(api, repo, request);
372
375
  await output('send', String(recorded.send));
@@ -482,7 +485,7 @@ async function main(command) {
482
485
  const productChangeName = productWorker ? dispatch.request.productFeedback?.changeName ?? dispatch.request.event.inputs?.change_name?.trim() ?? `issue-${issueNumber}-product-change` : undefined;
483
486
  if (productWorker && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(productChangeName)) throw Error('Invalid Product Change name');
484
487
  const baselineDigest = productWorker ? await productModelDigest(process.cwd()) : undefined;
485
- const snapshot = { ...(dispatch?.request.epic ? { epic: dispatch.request.epic } : {}), ...(dispatch && executionModelSelection(dispatch.request) ? { modelSelection: executionModelSelection(dispatch.request) } : {}), autoImplement: env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true', productChangeEnabled: dispatch?.request.productChangeEnabled === true, ...(dispatch ? { worker: dispatch.request.worker, workflowRevision: env.GITHUB_SHA, dispatch: { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest, routerRunId: dispatch.request.routerRunId, routerAttempt: dispatch.request.routerAttempt } } : {}), ...(productWorker ? { authorizationDigest, policyDigest, productChangeName, baselineDigest, productEntry: dispatch.request.productFeedback ? 'feedback' : dispatch.request.productParent ? 'parent-handoff' : 'idea', ...((dispatch.request.productParent ?? dispatch.request.productReview?.parent ?? continuation?.request.productParent) ? { productParent: dispatch.request.productParent ?? dispatch.request.productReview?.parent ?? continuation.request.productParent } : {}) } : {}), ...(planningReference ? { planningReference } : {}), ...(productResume ? { productResume, baseRevision } : {}), ...(implementationReview ? { implementationReview, baseRevision } : epicImplementation ? { baseRevision } : {}), ...(proposalReview ? { proposalReview, ...(proposalReview.productProposal ? { baseRevision } : {}) } : {}), repository: repo, runId: id, issue: issueNumber, stage, title: issue.title, defaultBranch: repository.default_branch, sourceRevision: revision.sha, taskDigest: taskDigest(issue, answers), workflowRunId: env.GITHUB_RUN_ID, ...(predecessor ? { refinement: predecessor } : {}), ...(continuation ? { replyTo: continuation.request.runId, commentId: continuationAnswer.id, answerHistory: continuationHistory(continuation, continuationAnswer) } : {}), ...(implementation ? { implementation } : {}), ...(seed ? { seedDigest: changesDigest(seed) } : {}) };
488
+ const snapshot = { ...(dispatch?.request.epic ? { epic: dispatch.request.epic } : {}), ...(dispatch && executionModelSelection(dispatch.request) ? { modelSelection: executionModelSelection(dispatch.request) } : {}), autoImplement: env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true', productChangeEnabled: dispatch?.request.productChangeEnabled === true, ...(dispatch ? { worker: dispatch.request.worker, workflowRevision: env.GITHUB_SHA, dispatch: { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest, routerRunId: dispatch.request.routerRunId, routerAttempt: dispatch.request.routerAttempt } } : {}), ...(productWorker ? { authorizationDigest, policyDigest, productChangeName, baselineDigest, productEntry: dispatch.request.productFeedback ? 'feedback' : dispatch.request.productParent ? 'parent-handoff' : 'idea', ...((dispatch.request.productParent ?? dispatch.request.productReview?.parent ?? continuation?.request.productParent) ? { productParent: dispatch.request.productParent ?? dispatch.request.productReview?.parent ?? continuation.request.productParent } : {}) } : {}), ...(planningReference ? { planningReference } : {}), ...(productResume ? { productResume, baseRevision } : {}), ...(dispatch?.request.productAlignment ? { productAlignment: dispatch.request.productAlignment } : {}), ...(implementationReview ? { implementationReview, baseRevision } : epicImplementation ? { baseRevision } : {}), ...(proposalReview ? { proposalReview, ...(proposalReview.productProposal ? { baseRevision } : {}) } : {}), repository: repo, runId: id, issue: issueNumber, stage, title: issue.title, defaultBranch: repository.default_branch, sourceRevision: revision.sha, taskDigest: taskDigest(issue, answers), workflowRunId: env.GITHUB_RUN_ID, ...(predecessor ? { refinement: predecessor } : {}), ...(continuation ? { replyTo: continuation.request.runId, commentId: continuationAnswer.id, answerHistory: continuationHistory(continuation, continuationAnswer) } : {}), ...(implementation ? { implementation } : {}), ...(seed ? { seedDigest: changesDigest(seed) } : {}) };
486
489
  if (stage === 'triage') snapshot.originalRequest = issue.body ?? '';
487
490
  if (triage) snapshot.triageEstimate = triage.envelope.result.output.estimate;
488
491
  await output('source_revision', revision.sha);
@@ -492,6 +495,7 @@ async function main(command) {
492
495
  const contract = fileURLToPath(new URL('./host-contract.mjs', import.meta.url));
493
496
  const context = JSON.parse(execFileSync(process.execPath, [contract, 'context', join(dir, 'request.json'), join(dir, 'issue.json')], { encoding: 'utf8' }));
494
497
  if (productResume) { context.productResume = productResume; context.contract.rules += ' The approved Product Change was applied to the supplied issue branch. Resume only the original parent stage and its existing authorization. Preserve the applied product files and branch; product approval does not authorize merge or broaden implementation scope.'; }
498
+ if (snapshot.productAlignment) { context.productAlignment = snapshot.productAlignment; context.contract.rules += ' The authenticated Product Change worker found that accepted product intent already covers this request. Resume only the original parent stage and its existing authorization. This observation grants no new product decision, implementation scope or merge authority.'; }
495
499
  if (!productWorker && ['triage', 'refine', 'propose', 'implement'].includes(stage)) context.contract.rules += ' If the authorized issue requires an adjustment to accepted product intent, stop with status blocked and return output.productGap with only summary and scope. Treat that observation as context, not approval or scope authority. Do not use a handoff question for a technical product prerequisite. Set nextAction to: Workflow maintainer: ensure the optional Product Change workflow is installed and follow its authenticated handoff. If a product or scope decision itself is missing, ask one explicit owner question instead.';
496
500
  if (continuation) {
497
501
  context.continuation = continuationContext(continuation, continuationAnswer);
@@ -617,6 +621,13 @@ async function main(command) {
617
621
  await summary(sent ? 'Product prerequisite requested; the router will verify the completed parent.' : 'No product prerequisite continuation requested.');
618
622
  return;
619
623
  }
624
+ if (command === 'continue-product-alignment') {
625
+ await readFile(join(dir, 'published'));
626
+ const envelope = JSON.parse(await readFile(join(dir, 'envelope.json'), 'utf8'));
627
+ const sent = await signalProductAlignment(api, snapshot, envelope);
628
+ await summary(sent ? 'Product alignment continuation requested; the router will verify the completed result.' : 'No product alignment continuation requested.');
629
+ return;
630
+ }
620
631
  if (command === 'continue-refinement') {
621
632
  await readFile(join(dir, 'published'));
622
633
  const envelope = JSON.parse(await readFile(join(dir, 'envelope.json'), 'utf8'));
@@ -650,7 +661,7 @@ async function main(command) {
650
661
  }
651
662
  };
652
663
  await checkEpic();
653
- if (snapshot.productParent && publicationIssue.labels.some(label => label.name === 'future')) throw Error('Product prerequisite was paused before publication');
664
+ if ((snapshot.productParent || snapshot.productAlignment) && publicationIssue.labels.some(label => label.name === 'future')) throw Error('Product prerequisite was paused before publication');
654
665
  const checkFeedback = async () => {
655
666
  if (!snapshot.proposalReview) return;
656
667
  if (snapshot.productResume) { checkProductResumePullRequest(await api(`pulls/${snapshot.productResume.pullRequest}`), repo, snapshot.productResume); if (digest(JSON.stringify(await api(snapshot.proposalReview.path))) !== snapshot.proposalReview.feedbackDigest) throw Error('Proposal feedback changed'); }
@@ -696,7 +707,7 @@ async function main(command) {
696
707
  reviewTarget = true;
697
708
  await summary(`### Product Change review\n[PR #${published.pullRequest.number}](${published.pullRequest.html_url})\n\nProvider overlay and OpenSpec validation passed. Waiting for the explicit owner product decision.`);
698
709
  } else if (product.outcome === 'aligned') {
699
- proposalLink = '\n\nAccepted product intent already covers this idea. No Product Change was created; delivery still needs its own authorization.';
710
+ proposalLink = !snapshot.productParent ? '\n\nAccepted product intent already covers this idea. No Product Change was created; delivery still needs its own authorization.' : !snapshot.productParent.pullRequest && !snapshot.productParent.review && !snapshot.replyTo ? '\n\nAccepted product intent already covers this request. The workflow will verify the original authorization before resuming its stage.' : '\n\nAccepted product intent already covers this request. The workflow maintainer must reconcile the parent continuation; no new delivery is authorized by this result.';
700
711
  } else {
701
712
  proposalLink = product.question ? `\n\nProduct preparation is waiting for one owner decision: ${product.question}` : `\n\nProduct preparation is blocked: ${product.blocker}`;
702
713
  }
@@ -769,7 +780,7 @@ async function main(command) {
769
780
  const projection = resultHandoffState({ status: value.result.status, stage: snapshot.stage, question, reviewTarget, draft: !!prStateProof });
770
781
  const isReview = projection.review;
771
782
  const productRecovery = snapshot.worker === 'product-change' ? value.result.output?.productChange?.recovery : null;
772
- const productGap = value.result.status === 'blocked' && snapshot.worker !== 'product-change' ? productGapNextAction({ gap: value.result.output?.productGap, enabled: snapshot.productChangeEnabled === true, dispatched: !!snapshot.dispatch, paused: publicationIssue.labels.some(label => label.name === 'future') }) : null;
783
+ const productGap = value.result.status === 'blocked' && snapshot.worker !== 'product-change' ? snapshot.productAlignment && value.result.output?.productGap ? { action: 'Workflow maintainer: compare the new product gap with the recorded alignment and determine the next authorized action.', next: 'Automatic continuation has stopped; no repeated product handoff was requested.' } : productGapNextAction({ gap: value.result.output?.productGap, enabled: snapshot.productChangeEnabled === true, dispatched: !!snapshot.dispatch, paused: publicationIssue.labels.some(label => label.name === 'future') }) : null;
773
784
  const action = question ? `Owner: ${question} Reply here.` : isReview ? snapshot.worker === 'product-change' ? 'Owner: review the linked Product Change PR and approve the proposal or request changes there.' : 'Owner: open the linked result and record your decision there.' : value.result.status === 'blocked' ? productRecovery ? `Workflow maintainer: ${productRecovery}` : productGap?.action ?? visibleNextAction(value.result.nextAction) : refinementAction?.action ?? 'None.';
774
785
  const whatNext = question ? `The answer will be checked against this question before ${snapshot.stage} continues.` : isReview ? 'The workflow waits for the required decision before continuing.' : value.result.status === 'blocked' ? productGap?.next ?? 'Work remains stopped until the blocker is resolved and continuation is authorized.' : refinementAction?.next ?? 'Any continuation follows the configured authorization.';
775
786
  const happened = productPublicationMessage({ worker: snapshot.worker, outcome: value.result.output?.productChange?.outcome, reviewTarget, agentMessage: value.effect.body });
@@ -11,7 +11,7 @@ export const stagePath = stage => { if (!workers.includes(stage)) throw Error('U
11
11
  export const implementPath = stagePath('implement');
12
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}`;
13
13
  const positive = value => /^[1-9][0-9]*$/.test(String(value));
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);
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', 'resume-aligned'].includes(event.inputs?.workflow)) && positive(event.inputs?.issue) && positive(event.inputs?.after_run);
15
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);
16
16
  const sha = value => /^[a-f0-9]{40}$/.test(value ?? '');
17
17
  const hash = value => /^[a-f0-9]{64}$/.test(value ?? '');
@@ -128,7 +128,7 @@ export function verifyDispatchOrigin(request, receipt, parent, worker, repositor
128
128
  const recovery = !!request.recovery;
129
129
  const originPath = recovery ? '.github/workflows/lighthouse-worker-maintenance.yml' : routerPath;
130
130
  const originEvent = recovery ? request.routerEventName : request.eventName;
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);
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 || !request.worker && request.event.inputs.workflow === 'resume-aligned' && request.productAlignment?.alignmentRun?.runId === request.event.inputs.after_run);
132
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
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
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');
@@ -146,11 +146,16 @@ export async function loadDispatch(api, repository, owner, issue, commentId, wor
146
146
  const parent = await api(`actions/runs/${receipt.routerRunId}/attempts/${receipt.routerAttempt}`);
147
147
  const request = await readArtifact(api, repository, receipt.routerRunId, dispatchArtifact(receipt), 'dispatch.json');
148
148
  verifyDispatchOrigin(request, receipt, parent, worker, repository, owner, issue);
149
- if (isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login) && !request.productParent && request.event?.inputs?.workflow !== 'resume-product') {
149
+ if (isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login) && !request.productParent && !['resume-product', 'resume-aligned'].includes(request.event?.inputs?.workflow)) {
150
150
  const predecessor = await api(`actions/runs/${request.predecessor.runId}`);
151
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);
152
152
  if (next?.issue !== issue || JSON.stringify(next.predecessor) !== JSON.stringify(request.predecessor)) throw Error('Bot continuation lacks an authenticated refinement');
153
153
  }
154
+ if (request.productAlignment) {
155
+ const run = await api(`actions/runs/${request.productAlignment.alignmentRun.runId}`);
156
+ const observed = await (await import('./stage-transition.mjs')).productAlignmentAfterRun(api, repository, owner, { action: 'completed', workflow_run: { id: run.id, head_sha: run.head_sha } }, readArtifact);
157
+ if (JSON.stringify(observed) !== JSON.stringify(request.productAlignment) || observed.baseRevision !== request.sourceRevision || observed.parent.taskDigest !== request.taskDigest || observed.stage !== request.stage) throw Error('Product alignment or parent authorization changed');
158
+ }
154
159
  if (request.productResume) {
155
160
  const apply = await api(`actions/runs/${request.productResume.applyRun.runId}`);
156
161
  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);
@@ -221,7 +226,7 @@ export async function completedEvent(api, event, env, wait = ms => new Promise(r
221
226
  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');
222
227
  let run = await api(`actions/runs/${id}`);
223
228
  if (bot) {
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');
229
+ if (event.inputs?.workflow === 'resume-product' ? run.path !== stagePath('apply-product-change') : event.inputs?.workflow === 'resume-aligned' ? run.path !== stagePath('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 === 'resume-aligned' ? 'Bot continuation must reference a product alignment' : event.inputs?.workflow === 'product-change' ? 'Bot continuation must reference a supported parent execution' : 'Bot continuation must reference a refinement');
225
230
  // The publisher signals before its own workflow has finished its final jobs.
226
231
  for (let attempt = 0; run.status !== 'completed' && attempt < 24; attempt++) {
227
232
  await wait(5000); run = await api(`actions/runs/${id}`);
@@ -16,7 +16,7 @@ export async function signalRefinementContinuation(api, request, envelope) {
16
16
 
17
17
  // Signal only after publication and cleanup; the router authenticates the completed run.
18
18
  export async function signalProductGap(api, request, envelope) {
19
- if (!request.dispatch || !request.productChangeEnabled || !['triage', 'refine', 'propose', 'implement'].includes(request.stage) || envelope.result?.status !== 'blocked' || !envelope.result.output?.productGap) return false;
19
+ if (!request.dispatch || request.productAlignment || !request.productChangeEnabled || !['triage', 'refine', 'propose', 'implement'].includes(request.stage) || envelope.result?.status !== 'blocked' || !envelope.result.output?.productGap) return false;
20
20
  if (validateStageEnvelope(envelope, request).length) throw Error('Invalid product prerequisite result');
21
21
  const repository = await api('');
22
22
  await api('actions/workflows/lighthouse-sdlc-router.yml/dispatches', 'POST', { ref: repository.default_branch, inputs: { issue: String(request.issue), stage: request.stage, workflow: 'product-change', after_run: String(request.workflowRunId) } });
@@ -30,6 +30,14 @@ export async function signalProductApplyContinuation(api, issue, runId, checkpoi
30
30
  return true;
31
31
  }
32
32
 
33
+ export async function signalProductAlignment(api, request, envelope) {
34
+ if (request.worker !== 'product-change' || !request.dispatch || !request.productParent || request.productParent.pullRequest || request.productParent.review || request.replyTo || envelope.result?.status !== 'passed' || envelope.result.output?.productChange?.outcome !== 'aligned') return false;
35
+ if (validateStageEnvelope(envelope, request).length) throw Error('Invalid product alignment result');
36
+ const repository = await api('');
37
+ await api('actions/workflows/lighthouse-sdlc-router.yml/dispatches', 'POST', { ref: repository.default_branch, inputs: { issue: String(request.issue), stage: request.productParent.stage, workflow: 'resume-aligned', after_run: String(request.workflowRunId) } });
38
+ return true;
39
+ }
40
+
33
41
  // The completed execution is an observation. Its retained owner request grants the handoff.
34
42
  export async function refinementAfterRun(api, repository, owner, event, readArtifact = readActionsJson) {
35
43
  if (event.action !== 'completed' || !Number.isSafeInteger(event.workflow_run?.id)) return null;
@@ -146,6 +154,32 @@ export async function verifyProductParent(api, repository, owner, parent, issue,
146
154
  return true;
147
155
  }
148
156
 
157
+ export async function productAlignmentAfterRun(api, repository, owner, event, readArtifact = readActionsJson) {
158
+ if (event.action !== 'completed' || !Number.isSafeInteger(event.workflow_run?.id)) return null;
159
+ const run = await api(`actions/runs/${event.workflow_run.id}`);
160
+ if (run.path !== stagePath('product-change') || run.status !== 'completed' || run.conclusion !== 'success' || run.head_sha !== event.workflow_run.head_sha) return null;
161
+ const artifacts = await api(`actions/runs/${run.id}/artifacts?per_page=100`);
162
+ if (artifacts.total_count > 100) throw Error('Product alignment artifacts exceed bounds');
163
+ const inputs = artifacts.artifacts.filter(a => /^lighthouse-input-[1-9][0-9]*-[1-9][0-9]*$/.test(a.name) && a.name.endsWith(`-${run.run_attempt}`) && !a.expired);
164
+ if (inputs.length !== 1) return null;
165
+ const snapshot = await readArtifact(api, repository, run.id, inputs[0].name, 'request.json');
166
+ if (snapshot.worker !== 'product-change' || !snapshot.dispatch || !snapshot.productParent || snapshot.replyTo || inputs[0].name !== `lighthouse-input-${snapshot.issue}-${run.run_attempt}` || String(snapshot.workflowRunId) !== String(run.id)) return null;
167
+ const envelope = await readArtifact(api, repository, run.id, `lighthouse-result-${snapshot.issue}-${run.run_attempt}`, 'envelope.json');
168
+ if (validateStageEnvelope(envelope, snapshot).length) throw Error('Invalid product alignment result');
169
+ if (envelope.result.status !== 'passed' || envelope.result.output?.productChange?.outcome !== 'aligned' || envelope.result.output.productChange.entry !== 'parent-handoff') return null;
170
+ const origin = await loadDispatch(api, repository, owner, snapshot.issue, snapshot.dispatch.commentId, run, readArtifact);
171
+ if (origin.request.worker !== 'product-change' || origin.request.id !== snapshot.dispatch.requestId || origin.request.stage !== snapshot.stage || origin.request.sourceRevision !== snapshot.sourceRevision || origin.request.workflowRevision !== snapshot.workflowRevision || JSON.stringify(origin.request.productParent) !== JSON.stringify(snapshot.productParent) || origin.request.taskDigest !== snapshot.taskDigest) throw Error('Product alignment authority changed');
172
+ await checkDispatchClaim(api, repository, snapshot.issue, snapshot.dispatch, run.id);
173
+ const parent = snapshot.productParent;
174
+ if (parent.pullRequest || parent.review) return null;
175
+ const reports = await issueComments(api, snapshot.issue);
176
+ if (reports.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body.includes(`<!-- lighthouse-actions:${snapshot.runId} -->`) && c.body.includes(`<!-- lighthouse-stage:${snapshot.stage}:passed -->`)).length !== 1) throw Error('Product alignment publication is unavailable or ambiguous');
177
+ const repo = await api(''), baseRevision = (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha, issue = await api(`issues/${snapshot.issue}`);
178
+ await verifyProductParent(api, repository, owner, parent, issue, reports, baseRevision, readArtifact);
179
+ if (issue.state !== 'open' || issue.labels?.some(l => ['future', 'needs-info', 'waiting-review', 'human-working', 'agent-working'].includes(l.name))) throw Error('Product alignment continuation is paused');
180
+ return { issue: snapshot.issue, stage: parent.stage, parent, alignmentRun: { runId: String(run.id), attempt: run.run_attempt }, baseRevision };
181
+ }
182
+
149
183
  // A product apply is usable by a parent only when its original approval, provider
150
184
  // result, published receipt, current PR head and retained parent still agree.
151
185
  export async function productApplyAfterRun(api, repository, owner, event, readArtifact = readActionsJson) {
@@ -1,4 +1,4 @@
1
- # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a7ed9b2d7b90453caab6233d54bd2cd8b6a38a0a42ac3b956a33ab9cb379b708","body_hash":"5269ffd1955c7c2f756025111209f18fd1d97a635086c7d29d7a24d1b8a9b09d","compiler_version":"v0.93.2","agent_id":"opencode","engine_versions":{"opencode":"2.1.0"},"agent_image_runner":"[\"self-hosted\",\"linux\",\"lighthouse-workflow\"]"}
1
+ # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"f6a5fb2aaa8a8089b83cd24aa185fe64db6c76b4069615e4b7810da5c83ae8d7","body_hash":"5269ffd1955c7c2f756025111209f18fd1d97a635086c7d29d7a24d1b8a9b09d","compiler_version":"v0.93.2","agent_id":"opencode","engine_versions":{"opencode":"2.1.0"},"agent_image_runner":"[\"self-hosted\",\"linux\",\"lighthouse-workflow\"]"}
2
2
  # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/checkout","sha":"fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09","version":"v5"},{"repo":"actions/download-artifact","sha":"9000827ccba6bdab643e8b6fd33ac0654aef8333","version":"v8.0.2"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"949feb2413d6458794dcd2491c4babbbce0c15c1","version":"v7.1.0"},{"repo":"actions/setup-node","sha":"a0853c24544627f65ddf259abe73b1d18a591444","version":"v5"},{"repo":"actions/upload-artifact","sha":"cf430e030ddbb5b0abf93d22962f4752f3646cd9","version":"v7.0.2"},{"repo":"github/gh-aw-actions/setup","sha":"d9128eb3ea40c18b77c76184eda03c0ff2608d79","version":"v0.93.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382","pinned_image":"ghcr.io/github/gh-aw-node@sha256:41835613992df88f51e923bad486f96dfb2e6cda29f13e217df41833284ae382"}],"mcp_servers":[{"name":"safeoutputs","tools":["noop"]}],"threat_detection":{"mode":"disabled"},"admission":false}
3
3
  # This file was automatically generated by gh-aw (v0.93.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
4
4
  #
@@ -1362,3 +1362,8 @@ jobs:
1362
1362
  LIGHTHOUSE_AGENT_RESULT: ${{ needs.agent.result }}
1363
1363
  LIGHTHOUSE_PRE_ACTIVATION_RESULT: ${{ needs.pre_activation.result }}
1364
1364
  LIGHTHOUSE_PUBLISH_STATUS: ${{ job.status }}
1365
+ - name: Continue an aligned parent request
1366
+ if: success() && needs.agent.result == 'success'
1367
+ run: node .lighthouse-harness/src/packages/sdlc/src/issue-actions.mjs continue-product-alignment
1368
+ env:
1369
+ GH_TOKEN: ${{ github.token }}
@@ -217,6 +217,11 @@ jobs:
217
217
  LIGHTHOUSE_AGENT_RESULT: ${{ needs.agent.result }}
218
218
  LIGHTHOUSE_PUBLISH_STATUS: ${{ job.status }}
219
219
  run: node .lighthouse-harness/src/packages/sdlc/src/issue-actions.mjs cleanup
220
+ - name: Continue an aligned parent request
221
+ if: success() && needs.agent.result == 'success'
222
+ env:
223
+ GH_TOKEN: ${{ github.token }}
224
+ run: node .lighthouse-harness/src/packages/sdlc/src/issue-actions.mjs continue-product-alignment
220
225
  ---
221
226
 
222
227
  # Prepare a product change from an idea
@@ -34,7 +34,7 @@ on:
34
34
  required: true
35
35
  default: stage
36
36
  type: choice
37
- options: [stage, product-change, resume-product]
37
+ options: [stage, product-change, resume-product, resume-aligned]
38
38
  model_selection:
39
39
  description: Optional JSON model selection for this execution
40
40
  required: false