@juangcarmona/lighthouse-sdlc 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,12 +12,16 @@ import { digest, validateStageEnvelope } from './host-contract.mjs';
12
12
  import { loadRefinement, loadStageReport, prepareProposal, proposalRoot, createProposalPullRequest } from './proposal.mjs';
13
13
  import { eligibleIssueAuthor } from './request-triage.mjs';
14
14
  import { projectImplementationPrState, reconcileImplementationPr, setPullRequestDraft } from './pr-state.mjs';
15
+ import { prepareProductChange, productModelDigest, loadProductBridge } from './product-change.mjs';
16
+ import { validateProductCandidate, publishProductChangePullRequest, loadProductApproval, applyApprovedProductChange, collectProductApplyFiles, publishProductApply } from './product-change-actions.mjs';
15
17
 
16
18
  export const repositoryApiUrl = (repository, path = '') => `https://api.github.com/repos/${repository}${path ? `/${path}` : ''}`;
17
19
 
18
20
  export function pendingReply(event, comments) {
19
21
  const report = [...comments].reverse().find(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && /<!-- lighthouse-actions:[a-f0-9]{64} -->/.test(c.body));
20
22
  if (!report || /<!-- lighthouse-proposal-review:|\[Review the implementation/.test(report.body) || !/^Outcome: blocked$/m.test(report.body) || !event.comment?.body?.trim() || event.comment.created_at <= report.created_at) return null;
23
+ const productChangeName = report.body.match(/<!-- lighthouse-product-workflow:([a-z0-9]+(?:-[a-z0-9]+)*) -->/)?.[1];
24
+ if (productChangeName) return { stage: 'propose', worker: 'product-change', productFeedback: { action: 'answer', changeName: productChangeName, text: event.comment.body, sourceCommentId: event.comment.id } , report };
21
25
  const declared = report.body.match(/<!-- lighthouse-stage:([a-z]+):blocked -->/);
22
26
  const stage = declared?.[1] ?? (/^## Proposal: blocked candidate$/m.test(report.body) ? 'propose' : /^## Refinement:/m.test(report.body) ? 'refine' : null);
23
27
  return ['triage', 'refine', 'propose', 'implement'].includes(stage) ? { stage, report } : null;
@@ -32,9 +36,10 @@ export function routeIssue(event, actor, owner, eventName, comments = []) {
32
36
  if (eventName === 'issues' && event.action === 'labeled' && ['triage', 'refine', 'propose', 'implement'].includes(event.label?.name)) return { run: true, stage: event.label.name, issue: event.issue.number, reason: `Owner requested ${event.label.name}` };
33
37
  if (eventName === 'issue_comment' && event.action === 'created' && event.comment?.user?.login === owner && event.comment.user.type !== 'Bot' && labels.some(x => ['needs-info', 'waiting-review'].includes(x))) {
34
38
  const pending = pendingReply(event, comments);
35
- if (pending) return { run: true, stage: pending.stage, issue: event.issue.number, reason: `Owner answered pending ${pending.stage}` };
39
+ if (pending) return { run: true, ...pending, issue: event.issue.number, reason: `Owner answered pending ${pending.stage}` };
36
40
  }
37
41
  if (eventName === 'workflow_dispatch' && ['integrate', 'review'].includes(event.inputs?.stage) && (!/^[1-9][0-9]*$/.test(event.inputs?.pull_request ?? '') || !/^[a-f0-9]{40}$/.test(event.inputs?.head ?? ''))) return { run: false, reason: 'Completion needs a PR and accepted full head SHA' };
42
+ if (eventName === 'workflow_dispatch' && event.inputs?.workflow === 'product-change' && /^[1-9][0-9]*$/.test(event.inputs?.issue ?? '')) return { run: true, stage: ['propose', 'implement'].includes(event.inputs?.stage) ? event.inputs.stage : 'propose', worker: 'product-change', issue: Number(event.inputs.issue), reason: 'Owner requested Product Change preparation from an idea' };
38
43
  if (eventName === 'workflow_dispatch' && ['triage', 'refine', 'propose', 'implement', 'integrate', 'review'].includes(event.inputs?.stage ?? 'refine') && /^[1-9][0-9]*$/.test(event.inputs?.issue ?? '')) return { run: true, stage: event.inputs.stage ?? 'refine', issue: Number(event.inputs.issue), reason: 'Owner requested manual stage' };
39
44
  return { run: false, reason: 'No eligible stage event' };
40
45
  }
@@ -72,7 +77,7 @@ async function main(command) {
72
77
  return;
73
78
  }
74
79
  let dispatch;
75
- if (command === 'prepare' && env.GITHUB_EVENT_NAME === 'workflow_dispatch' && (event.inputs?.['dispatch-request'] || env.LIGHTHOUSE_DISPATCH_REQUIRED === 'true')) {
80
+ if (['prepare', 'product-apply-prepare'].includes(command) && env.GITHUB_EVENT_NAME === 'workflow_dispatch' && (event.inputs?.['dispatch-request'] || env.LIGHTHOUSE_DISPATCH_REQUIRED === 'true')) {
76
81
  dispatch = await admitDispatch(api, repo, owner, env, event);
77
82
  if (!dispatch.admitted) {
78
83
  await output('eligible', 'false'); await summary('This request already has an execution. No duplicate agent started.'); return;
@@ -93,7 +98,57 @@ async function main(command) {
93
98
  }
94
99
  }
95
100
  }
101
+ if (command === 'product-apply-prepare') {
102
+ if (dispatch?.request.worker !== 'apply-product-change' || !dispatch.request.productApproval) throw Error('Trusted Product Change apply dispatch is missing');
103
+ const current = await loadProductApproval(api, { eventName: dispatch.request.eventName, event: dispatch.request.event, owner, repository: repo, root: process.cwd() });
104
+ const requested = dispatch.request.productApproval;
105
+ if (!current || current.issue !== issueNumber || current.pullRequest !== requested.pullRequest || current.head !== requested.head || current.approval.reviewKey !== requested.reviewKey || current.changeName !== requested.changeName) throw Error('Product approval changed before trusted apply admission');
106
+ const issue = await api(`issues/${issueNumber}`);
107
+ if (issue.state !== 'open' || issue.labels?.some(label => ['future', 'human-working', 'agent-working'].includes(label.name))) throw Error('Product apply is blocked by the current issue state or reservation');
108
+ const applyRequest = { approval: current.approval, binding: current.binding, checkpoint: current.checkpoint, issue: current.issue, pullRequest: current.pullRequest, head: current.head, changeName: current.changeName, repository: repo, owner, sourceRevision: current.head, dispatch: { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest } };
109
+ await mkdir(dir, { recursive: true });
110
+ await writeFile(join(dir, 'product-apply.json'), JSON.stringify(applyRequest));
111
+ await output('eligible', 'true'); await output('head', current.head); await output('issue', String(current.issue)); await output('pull_request', String(current.pullRequest));
112
+ await summary(`Product Change ${current.approval.changeId} approval verified for PR #${current.pullRequest}. The apply job has no GitHub credentials.`);
113
+ return;
114
+ }
115
+ if (command === 'product-apply') {
116
+ const applyRequest = JSON.parse(await readFile(join(dir, 'product-apply.json'), 'utf8'));
117
+ const currentHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: process.cwd(), encoding: 'utf8' }).trim();
118
+ if (currentHead !== applyRequest.head) throw Error('Apply checkout does not match the approved Product Change head');
119
+ const bridge = await loadProductBridge();
120
+ let applyOutput;
121
+ await applyApprovedProductChange({ root: process.cwd(), changeName: applyRequest.changeName, checkpoint: applyRequest.checkpoint, approval: applyRequest.approval, currentBinding: applyRequest.binding, currentIdentity: applyRequest.checkpoint.identity, bridge, headAfter: currentHead, reportBeforeWrite: async citations => writeFile(join(dir, 'affected-citations.json'), JSON.stringify(citations)), publish: async (receipt, providerResult, details) => {
122
+ const output = await collectProductApplyFiles(process.cwd(), applyRequest.changeName, currentHead);
123
+ applyOutput = { receipt, files: output.files, modelRoot: output.modelRoot, affectedCitations: details.affectedCitations, resultingModelDigest: details.resultingModelDigest, head: currentHead, providerOutcome: providerResult.outcome };
124
+ await writeFile(join(dir, 'apply-result.json'), JSON.stringify(applyOutput));
125
+ } });
126
+ if (!applyOutput) throw Error('Trusted Product Change apply produced no receipt');
127
+ console.log(`Applied ${applyRequest.approval.changeId}; provider output is ready for independent publication.`); return;
128
+ }
129
+ if (command === 'product-apply-publish') {
130
+ const applyRequest = JSON.parse(await readFile(join(dir, 'product-apply.json'), 'utf8'));
131
+ const applyResult = JSON.parse(await readFile(join(dir, 'apply-result.json'), 'utf8'));
132
+ const dispatch = await admitDispatch(api, repo, owner, env, event);
133
+ if (!dispatch.admitted || dispatch.request.worker !== 'apply-product-change' || dispatch.request.productApproval?.reviewKey !== applyRequest.approval.reviewKey) throw Error('Product apply publication dispatch is invalid');
134
+ const published = await publishProductApply(api, repo, owner, dispatch, applyResult, async fresh => {
135
+ await checkDispatchClaim(api, repo, issueNumber, dispatch, env.GITHUB_RUN_ID);
136
+ const current = await loadProductApproval(api, { eventName: dispatch.request.eventName, event: dispatch.request.event, owner, repository: repo, root: process.cwd() });
137
+ if (!current || current.head !== fresh.head || current.approval.reviewKey !== fresh.approval.reviewKey) throw Error('Product approval changed before apply publication');
138
+ });
139
+ await writeFile(join(dir, 'published'), JSON.stringify({ receipt: published.receipt, checkpoint: published.checkpoint }));
140
+ await finishDispatch(api, repo, issueNumber, dispatch.commentId, env.GITHUB_RUN_ID, 'passed');
141
+ await summary(`Product Change ${published.receipt.changeId} applied to PR #${published.pullRequest.number}. Review the refreshed model diff; merge is separate.`);
142
+ return;
143
+ }
96
144
  let route = routeIssue(event, env.GITHUB_ACTOR, owner, env.GITHUB_EVENT_NAME);
145
+ if (dispatch?.request.worker === 'product-change') route = { run: true, stage: dispatch.request.stage, worker: 'product-change', issue: dispatch.request.issue, reason: 'Authenticated Product Change dispatch' };
146
+ let productApproval;
147
+ if (['route', 'dispatch-prepare'].includes(command) && env.LIGHTHOUSE_PRODUCT_CHANGE_ENABLED === 'true' && ['issue_comment', 'pull_request_review'].includes(env.GITHUB_EVENT_NAME)) {
148
+ productApproval = await loadProductApproval(api, { eventName: env.GITHUB_EVENT_NAME, event, owner, repository: repo, root: process.cwd() });
149
+ if (productApproval?.approval) route = { run: true, stage: 'propose', worker: 'apply-product-change', issue: productApproval.issue, productApproval, reason: 'Explicit current Product Change approval' };
150
+ else if (productApproval?.feedback) route = { run: true, stage: 'propose', worker: 'product-change', issue: productApproval.issue, productFeedback: productApproval.feedback, productReview: productApproval, reason: 'Owner requested a Product Change revision' };
151
+ }
97
152
  const after = ['route', 'dispatch-prepare', 'prepare'].includes(command) ? await completedEvent(api, event, env) : null;
98
153
  if (after) {
99
154
  const next = await refinementAfterRun(api, repo, owner, after) ?? (env.LIGHTHOUSE_COMPLETION_ENABLED === 'true' ? await deliveryAfterChecks(api, repo, owner, after) : null);
@@ -101,7 +156,7 @@ async function main(command) {
101
156
  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');
102
157
  }
103
158
  let proposalReview, implementationReview;
104
- if (['route', 'prepare', 'dispatch-prepare'].includes(command) && reviewTarget(event, env.GITHUB_EVENT_NAME)) {
159
+ if (['route', 'prepare', 'dispatch-prepare'].includes(command) && !productApproval && reviewTarget(event, env.GITHUB_EVENT_NAME)) {
105
160
  if (env.GITHUB_ACTOR !== owner || env.GITHUB_TRIGGERING_ACTOR !== owner) throw Error('Review actor is not authorized');
106
161
  const pr = await api(`pulls/${reviewTarget(event, env.GITHUB_EVENT_NAME)}`);
107
162
  const implementation = pr.head?.ref?.startsWith('lighthouse/implement-');
@@ -113,7 +168,7 @@ async function main(command) {
113
168
  route = { run: !feedback.handled && (implementation || !feedback.approved), stage: implementation ? feedback.approved ? 'integrate' : 'implement' : 'propose', issue: feedback.issue, reason: feedback.approved ? implementation ? 'Review approved; checking the PR for merge.' : 'Proposal approval recorded.' : 'Applying review feedback.' };
114
169
  if (implementation && feedback.approved && env.LIGHTHOUSE_COMPLETION_ENABLED !== 'true') route = { ...route, run: false, reason: 'PR approved. Merge through GitHub; automatic completion is not enabled.' };
115
170
  }
116
- const issueNumber = Number(env.LIGHTHOUSE_ISSUE ?? event.issue?.number ?? route.issue);
171
+ const issueNumber = Number(env.LIGHTHOUSE_ISSUE ?? route.issue ?? event.issue?.number);
117
172
  const issuePath = `issues/${issueNumber}`;
118
173
  const comments = async () => {
119
174
  const all = [];
@@ -124,7 +179,7 @@ async function main(command) {
124
179
  throw Error('Comment history exceeds bounded intake');
125
180
  };
126
181
  const ownerAnswers = all => all.filter(c => c.user.login === owner && !c.body.includes('<!-- lighthouse-run:') && !c.body.includes('<!-- lighthouse-actions:'));
127
- if (['route', 'prepare', 'dispatch-prepare'].includes(command) && env.GITHUB_EVENT_NAME === 'issue_comment' && !proposalReview && !implementationReview) {
182
+ if (['route', 'prepare', 'dispatch-prepare'].includes(command) && env.GITHUB_EVENT_NAME === 'issue_comment' && !proposalReview && !implementationReview && !productApproval) {
128
183
  const fresh = await api(issuePath);
129
184
  route = routeIssue({ ...event, issue: fresh }, env.GITHUB_ACTOR, owner, env.GITHUB_EVENT_NAME, await comments());
130
185
  if (command === 'route' && env.GITHUB_ACTOR === owner && event.comment?.user?.login === owner && event.comment.user.type !== 'Bot') {
@@ -134,10 +189,12 @@ async function main(command) {
134
189
  if (command === 'dispatch-prepare') {
135
190
  if (!after && (env.GITHUB_ACTOR !== owner || env.GITHUB_TRIGGERING_ACTOR !== owner)) throw Error('Dispatch actor is not authorized');
136
191
  const stage = route.stage, handoff = !!route.predecessor, queue = !!(proposalReview || implementationReview);
137
- if (!route.run || route.issue !== issueNumber || !(env.LIGHTHOUSE_ENABLED_STAGES ?? stages.join(',')).split(',').includes(stage) || env.LIGHTHOUSE_STAGE && env.LIGHTHOUSE_STAGE !== stage) throw Error('Stage dispatch is not authorized');
192
+ const productEntry = ['product-change', 'apply-product-change'].includes(route.worker);
193
+ const installed = productEntry ? env.LIGHTHOUSE_PRODUCT_CHANGE_ENABLED === 'true' : (env.LIGHTHOUSE_ENABLED_STAGES ?? stages.join(',')).split(',').includes(stage);
194
+ if (!route.run || route.issue !== issueNumber || !installed || env.LIGHTHOUSE_STAGE && env.LIGHTHOUSE_STAGE !== stage) throw Error('Stage dispatch is not authorized');
138
195
  const issue = await api(issuePath), answers = ownerAnswers(await comments());
139
196
  const metadata = await api(''), revision = (await api(`commits/${metadata.default_branch}`)).sha;
140
- if (!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');
197
+ 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');
141
198
  if (handoff) {
142
199
  const prior = await readActionsJson(api, repo, route.predecessor.runId, `lighthouse-input-${issueNumber}-${route.predecessor.attempt}`, 'request.json');
143
200
  const result = await readActionsJson(api, repo, route.predecessor.runId, `lighthouse-result-${issueNumber}-${route.predecessor.attempt}`, 'envelope.json');
@@ -147,13 +204,13 @@ async function main(command) {
147
204
  if (stage === 'implement' && issue.labels.some(x => x.name === 'future')) throw Error('Implementation paused by future label');
148
205
  const eventRunId = after ? String(after.workflow_run.id) : undefined;
149
206
  const feedback = implementationReview ?? proposalReview;
150
- const worker = feedbackWorker(feedback);
151
- const feedbackIdentity = worker ? feedbackKey(feedback) : undefined;
207
+ const worker = route.worker ?? feedbackWorker(feedback);
208
+ const feedbackIdentity = worker === 'apply-review' ? feedbackKey(feedback) : undefined;
152
209
  const router = await api(`actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}`);
153
210
  // Review events can run on a PR merge ref, which is not a dispatchable branch.
154
211
  // Only an explicit owner dispatch selects a workflow branch; native events use main.
155
212
  const manual = env.GITHUB_EVENT_NAME === 'workflow_dispatch';
156
- const request = { schemaVersion: 1, id: dispatchId(repo, feedbackIdentity ?? eventRunId ?? env.GITHUB_RUN_ID, stage), ...(worker ? { worker, feedbackKey: feedbackIdentity } : {}), 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: stage === 'refine' && env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true', taskDigest: taskDigest(issue, answers) };
213
+ const request = { schemaVersion: 1, id: dispatchId(repo, feedbackIdentity ?? eventRunId ?? env.GITHUB_RUN_ID, stage), ...(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.productReview && !route.productApproval?.approval ? { productReview: { pullRequest: route.productReview.pullRequest, head: route.productReview.head, issue: route.productReview.issue, changeName: route.productReview.changeName } } : {}), 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: stage === 'refine' && env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true', taskDigest: taskDigest(issue, answers) };
157
214
  const recorded = await recordDispatch(api, repo, request);
158
215
  await output('send', String(recorded.send));
159
216
  await output('artifact', dispatchArtifact(request));
@@ -163,8 +220,8 @@ async function main(command) {
163
220
  return;
164
221
  }
165
222
  if (command === 'route') {
166
- if (route.run && !(env.LIGHTHOUSE_ENABLED_STAGES ?? stages.join(',')).split(',').includes(route.stage)) route = { run: false, reason: 'Stage is not installed.' };
167
- await output('stage', route.stage ?? ''); await output('run', String(route.run)); await output('issue', route.issue ?? '');
223
+ if (route.run && (['product-change', 'apply-product-change'].includes(route.worker) ? env.LIGHTHOUSE_PRODUCT_CHANGE_ENABLED !== 'true' : !(env.LIGHTHOUSE_ENABLED_STAGES ?? stages.join(',')).split(',').includes(route.stage))) route = { run: false, reason: 'Requested workflow is not installed.' };
224
+ await output('stage', route.stage ?? ''); await output('worker', route.worker ?? route.stage ?? ''); await output('run', String(route.run)); await output('issue', route.issue ?? '');
168
225
  if (implementationReview) await api(`issues/${implementationReview.number}/comments`, 'POST', { body: implementationReview.handled ? 'This feedback has already been applied.' : `${route.reason} [Follow progress](https://github.com/${repo}/actions/runs/${env.GITHUB_RUN_ID}).` });
169
226
  else if (proposalReview) await api(`issues/${proposalReview.number}/comments`, 'POST', { body: proposalReview.handled ? 'This feedback has already been applied. Review the latest proposal diff.' : proposalReview.approved ? `Approval received for commit \`${proposalReview.head}\`. Implementation has not started.` : `Updating the proposal with your feedback. [Follow progress](https://github.com/${repo}/actions/runs/${env.GITHUB_RUN_ID}).` });
170
227
  else if (env.GITHUB_EVENT_NAME === 'issue_comment' && env.GITHUB_ACTOR === owner && !route.run) await api(`${issuePath}/comments`, 'POST', { body: `No agent started: ${route.reason}. If you are reviewing a proposal, comment on its PR. [Routing details](https://github.com/${repo}/actions/runs/${env.GITHUB_RUN_ID}).` });
@@ -173,9 +230,10 @@ async function main(command) {
173
230
  if (command === 'prepare') {
174
231
  const handoff = env.LIGHTHOUSE_IMPLEMENT_HANDOFF === 'true';
175
232
  if (handoff && !after) route = { run: env.GITHUB_ACTOR === owner && !reviewTarget(event, env.GITHUB_EVENT_NAME) && ['issues', 'issue_comment', 'workflow_dispatch'].includes(env.GITHUB_EVENT_NAME), stage: 'implement', issue: issueNumber };
233
+ const productWorker = dispatch?.request.worker === 'product-change';
176
234
  if (!route.run || issueNumber !== route.issue || env.GITHUB_TRIGGERING_ACTOR !== owner) throw Error('Stage not authorized');
177
235
  const issue = await api(issuePath);
178
- if (!eligibleIssueAuthor(issue, owner, route.stage) || issue.state !== 'open' || issue.pull_request || issue.labels.some(x => ['agent-working', 'human-working'].includes(x.name))) throw Error('Issue not eligible');
236
+ if (!productWorker && !eligibleIssueAuthor(issue, owner, route.stage) || issue.state !== 'open' || issue.pull_request || issue.labels.some(x => ['agent-working', 'human-working'].includes(x.name))) throw Error('Issue not eligible');
179
237
  const repository = await api('');
180
238
  const baseRevision = (await api(`commits/${repository.default_branch}`)).sha;
181
239
  const revision = { sha: implementationReview ? implementationReview.head : baseRevision };
@@ -237,7 +295,7 @@ async function main(command) {
237
295
  }
238
296
  }
239
297
  if (proposalReview && (!issue.labels.some(x => x.name === 'ready') || proposalReview.base !== repository.default_branch)) throw Error('Proposal is not ready or targets a non-default branch');
240
- if (stage === 'propose' && !proposalReview && !implementationReview) {
298
+ if (stage === 'propose' && !productWorker && !proposalReview && !implementationReview) {
241
299
  if (!issue.labels.some(x => x.name === 'ready')) throw Error('Proposal requires a ready issue');
242
300
  predecessor = continuation?.request.refinement ?? await loadRefinement(api, repo, all, { owner, issue: issueNumber, sourceRevision: revision.sha, taskDigest: continuation?.request.taskDigest ?? taskDigest(issue, answers) });
243
301
  if (predecessor.request.stage !== 'refine' || predecessor.request.issue !== issueNumber || predecessor.request.sourceRevision !== revision.sha || predecessor.envelope.result.status !== 'passed') throw Error('Continuation refinement is invalid');
@@ -248,7 +306,12 @@ async function main(command) {
248
306
  }
249
307
  const id = digest(`${repo}:${issueNumber}:${stage}:${env.GITHUB_RUN_ID}:${env.GITHUB_RUN_ATTEMPT}`);
250
308
  await mkdir(dir, { recursive: true });
251
- const snapshot = { autoImplement: env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true', ...(dispatch ? { workflowRevision: env.GITHUB_SHA, dispatch: { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest } } : {}), ...(planningReference ? { planningReference } : {}), ...(implementationReview ? { implementationReview, baseRevision } : {}), ...(proposalReview ? { proposalReview } : {}), 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: event.comment.id } : {}), ...(implementation ? { implementation } : {}), ...(seed ? { seedDigest: changesDigest(seed) } : {}) };
309
+ const authorizationDigest = digest(JSON.stringify({ repository: repo, issue: issueNumber, owner, routerRunId: dispatch?.request.routerRunId ?? env.GITHUB_RUN_ID, routerAttempt: dispatch?.request.routerAttempt ?? Number(env.GITHUB_RUN_ATTEMPT), eventName: dispatch?.request.eventName ?? env.GITHUB_EVENT_NAME, eventId: dispatch?.request.event?.comment?.id ?? dispatch?.request.event?.inputs?.issue ?? issueNumber, stage, worker: dispatch?.request.worker ?? stage }));
310
+ const policyDigest = digest(JSON.stringify({ repository: repo, owner, workflowRevision: dispatch?.request.workflowRevision ?? env.GITHUB_SHA, sourceRevision: revision.sha, productChangeEnabled: env.LIGHTHOUSE_PRODUCT_CHANGE_ENABLED === 'true' }));
311
+ const productChangeName = productWorker ? dispatch.request.productFeedback?.changeName ?? dispatch.request.event.inputs?.change_name?.trim() ?? `issue-${issueNumber}-product-change` : undefined;
312
+ if (productWorker && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(productChangeName)) throw Error('Invalid Product Change name');
313
+ const baselineDigest = productWorker ? await productModelDigest(process.cwd()) : undefined;
314
+ const snapshot = { autoImplement: env.LIGHTHOUSE_AUTO_IMPLEMENT === '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 } : {}), ...(planningReference ? { planningReference } : {}), ...(implementationReview ? { implementationReview, baseRevision } : {}), ...(proposalReview ? { proposalReview } : {}), 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: event.comment.id } : {}), ...(implementation ? { implementation } : {}), ...(seed ? { seedDigest: changesDigest(seed) } : {}) };
252
315
  if (stage === 'triage') snapshot.originalRequest = issue.body ?? '';
253
316
  if (triage) snapshot.triageEstimate = triage.envelope.result.output.estimate;
254
317
  await output('source_revision', revision.sha);
@@ -257,6 +320,27 @@ async function main(command) {
257
320
  await writeFile(join(dir, 'issue.json'), JSON.stringify({ ...issue, ownerAnswers: answers.map(c => ({ body: c.body, url: c.html_url, author: c.user.login })) }));
258
321
  const contract = fileURLToPath(new URL('./host-contract.mjs', import.meta.url));
259
322
  const context = JSON.parse(execFileSync(process.execPath, [contract, 'context', join(dir, 'request.json'), join(dir, 'issue.json')], { encoding: 'utf8' }));
323
+ if (productWorker) {
324
+ const bridge = await loadProductBridge();
325
+ let productReview = dispatch.request.productReview;
326
+ if (!productReview) {
327
+ const pulls = await api('pulls?state=open&per_page=100');
328
+ if (pulls.length >= 100) throw Error('Open PR list exceeds bounds while resuming Product Change');
329
+ const existing = pulls.find(pr => (pr.head?.ref?.startsWith(`lighthouse/implement-${issueNumber}-`) || pr.head?.ref?.startsWith(`lighthouse/product-change-${issueNumber}-`)) && pr.body?.includes('<!-- lighthouse-product-change-review:'));
330
+ if (existing) productReview = { pullRequest: existing.number, head: existing.head.sha };
331
+ }
332
+ let existingFiles;
333
+ if (productReview) {
334
+ const pr = await api(`pulls/${productReview.pullRequest}`);
335
+ if (pr.head.sha !== productReview.head) throw Error('Product Change PR changed before feedback was prepared');
336
+ existingFiles = (await (await import('./product-change-actions.mjs')).loadProductCandidateFromPullRequest(api, repo, pr, productChangeName)).files;
337
+ }
338
+ const productChange = await prepareProductChange(process.cwd(), productChangeName, existingFiles, bridge);
339
+ context.request.worker = 'product-change';
340
+ context.authorization = { status: 'verified', verifiedBy: 'GitHub Actions workflow', authorizedStage: stage, owner, entry: 'product-idea', event: env.GITHUB_EVENT_NAME };
341
+ context.productChange = { ...productChange, baselineDigest, idea: `${issue.title}\n\n${issue.body ?? ''}`, scope: issue.body ?? issue.title, ...(dispatch.request.productFeedback ? { feedback: dispatch.request.productFeedback } : {}), ...(productReview ? { review: productReview } : {}), parent: { issue: issueNumber, stage, authorizationDigest, policyDigest, sourceRevision: revision.sha } };
342
+ context.contract.rules += ' Prepare only the Product Change candidate in context.productChange. The issue is the authorized idea. Do not require Ready or Planned stage evidence. Return productChange outcome and proposed files under its exact change root. A product approval and trusted apply must occur before a parent delivery handoff; this request grants no implementation authority.';
343
+ }
260
344
  if (triage) context.triage = triage.envelope.result.output;
261
345
  if (stage === 'triage') context.contract.rules += ' Preserve the exact issue body in output.requestAnalysis.originalRequest. Return output.estimate and output.requestAnalysis following /harness/request-triage.md. Stop at triaged; never authorize or dispatch refinement.';
262
346
  if (predecessor || proposalReview) {
@@ -344,7 +428,29 @@ async function main(command) {
344
428
  const existing = (await comments()).find(c => c.user.type === 'Bot' && c.body.includes(marker));
345
429
  const runUrl = `https://github.com/${repo}/actions/runs/${env.GITHUB_RUN_ID}`;
346
430
  let proposalLink = '', prStateProof = '';
347
- if (snapshot.stage === 'propose' && value.result.status === 'passed') {
431
+ if (snapshot.worker === 'product-change') {
432
+ const product = value.result.output?.productChange;
433
+ if (!product || product.entry !== 'idea') throw Error('Product Change worker returned no authorized idea outcome');
434
+ if (product.outcome === 'candidate') {
435
+ if (product.changeName !== snapshot.productChangeName || product.baselineDigest !== snapshot.baselineDigest) throw Error('Product Change candidate differs from its authenticated preparation context');
436
+ const bridge = await loadProductBridge();
437
+ const validation = await validateProductCandidate(process.cwd(), product.changeName, product.files, bridge);
438
+ if (validation.digest !== product.proposalDigest || validation.changeId !== product.changeId || validation.baselineDigest !== snapshot.baselineDigest) throw Error('Product Change candidate or baseline changed during validation');
439
+ if (product.providerValidation.status !== 'passed' || product.providerValidation.diagnosticCount !== validation.diagnosticCount) throw Error('Product Change provider validation evidence does not match the host result');
440
+ const published = await publishProductChangePullRequest(api, repo, snapshot, product, { validation, runUrl, beforeWrite: async () => {
441
+ if (snapshot.dispatch) await checkDispatchClaim(api, repo, issueNumber, snapshot.dispatch, env.GITHUB_RUN_ID);
442
+ checkPublication(snapshot, await api(issuePath), ownerAnswers(await comments()), (await api(`commits/${snapshot.defaultBranch}`)).sha);
443
+ } });
444
+ await writeFile(join(dir, 'product-review.json'), JSON.stringify({ pullRequest: published.pullRequest.number, head: published.binding.head, reviewKey: published.binding.key, changeName: product.changeName, changeId: product.changeId, proposalDigest: product.proposalDigest, baselineDigest: product.baselineDigest }));
445
+ proposalLink = `\n\n[Product decision on PR #${published.pullRequest.number}](${published.pullRequest.html_url}). The explicit action authorizes trusted product apply; implementation and merge remain separate.`;
446
+ 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.`);
447
+ } else if (product.outcome === 'aligned') {
448
+ proposalLink = '\n\nAccepted product intent already covers this idea. No Product Change was created; delivery still needs its own authorization.';
449
+ } else {
450
+ proposalLink = `\n\nProduct preparation is waiting for one owner decision: ${product.question}`;
451
+ }
452
+ }
453
+ if (snapshot.worker !== 'product-change' && snapshot.stage === 'propose' && value.result.status === 'passed') {
348
454
  const files = await prepareProposal(issueNumber, value.result.output.files);
349
455
  const recheck = async () => {
350
456
  const issue = await api(issuePath);
@@ -395,7 +501,8 @@ async function main(command) {
395
501
  if (snapshot.proposalReview && value.result.status === 'blocked') await api(`issues/${snapshot.proposalReview.number}/comments`, 'POST', { body: `Proposal revision is blocked. ${value.effect.body}\n\n[Execution](${runUrl}). Implementation has not started.` });
396
502
  const continuationHint = snapshot.proposalReview ? `\n\nContinue the review in [PR #${snapshot.proposalReview.number}](${snapshot.proposalReview.url}).\n<!-- lighthouse-proposal-review:${snapshot.proposalReview.number} -->` : value.result.status === 'blocked' && ['triage', 'refine', 'propose', 'implement'].includes(snapshot.stage) && !proposalLink ? `\n\nThe owner can reply here to continue ${snapshot.stage}.` : '';
397
503
  const next = snapshot.stage === 'refine' && value.result.status === 'passed' && env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true' ? publicationIssue.labels.some(x => x.name === 'future') ? '\n\n⏸️ Implementation is paused by `future`.' : '\n\nRefinement complete. Implementation is next; use `future` to pause it.' : '';
398
- const body = `## ${statusText(snapshot.stage, value.result.status === 'blocked' ? 'waiting' : 'completed')}\n\n${snapshot.stage === 'propose' && value.result.status === 'passed' ? 'Proposal ready. Review it in the linked PR; comment there to request changes.' : value.effect.body}${proposalLink}${continuationHint}${next}\n\n[Follow progress](${runUrl})\n\n<details><summary>Execution details</summary>\n\nOutcome: ${value.result.status}\n\n[Workflow run](${runUrl}) · source \`${snapshot.sourceRevision}\`\n\n${marker}\n${prStateProof}\n<!-- lighthouse-stage:${snapshot.stage}:${value.result.status} -->\n</details>`;
504
+ const productWorkflowMarker = snapshot.worker === 'product-change' ? `\n<!-- lighthouse-product-workflow:${snapshot.productChangeName} -->` : '';
505
+ const body = `## ${statusText(snapshot.stage, value.result.status === 'blocked' ? 'waiting' : 'completed')}\n\n${snapshot.worker === 'product-change' ? value.effect.body : snapshot.stage === 'propose' && value.result.status === 'passed' ? 'Proposal ready. Review it in the linked PR; comment there to request changes.' : value.effect.body}${proposalLink}${continuationHint}${next}\n\n[Follow progress](${runUrl})\n\n<details><summary>Execution details</summary>\n\nOutcome: ${value.result.status}\n\n[Workflow run](${runUrl}) · source \`${snapshot.sourceRevision}\`\n\n${marker}\n${prStateProof}\n<!-- lighthouse-stage:${snapshot.stage}:${value.result.status} -->${productWorkflowMarker}\n</details>`;
399
506
  const report = existing ?? await api(`${issuePath}/comments`, 'POST', { body });
400
507
  const handoff = snapshot.stage === 'refine' && value.result.status === 'passed' && env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true';
401
508
  const label = snapshot.stage === 'implement' && prStateProof ? 'pr-draft' : ['propose', 'implement'].includes(snapshot.stage) ? 'waiting-review' : value.result.status === 'passed' ? snapshot.stage === 'triage' ? 'triaged' : 'ready' : 'needs-info';
@@ -5,7 +5,7 @@ import { openSync, closeSync, appendFileSync } from 'node:fs';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { spawn, execFileSync } from 'node:child_process';
7
7
  import { sourceFiles, collectChanges, materializeCandidate, validateChanges, changesDigest } from './implementation.mjs';
8
- import { prepareImplementationWorkspace, copyEvidence, runImplementationChecks } from './implementation-runtime.mjs';
8
+ import { prepareImplementationWorkspace, copyEvidence, runImplementationChecks, restoreWorkspaceOwnership } from './implementation-runtime.mjs';
9
9
  import { validateStageEnvelope } from './host-contract.mjs';
10
10
  import { formatOpenCodeEvent, progressText, boundedEventLines } from './opencode-progress.mjs';
11
11
  import { selectEnvironment, startEnvironment, environmentArgs, environmentReceipt, redactEnvironmentLog } from './execution-environment.mjs';
@@ -53,7 +53,7 @@ async function main() {
53
53
  const runDir = join(env.RUNNER_TEMP, `lighthouse-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`);
54
54
  const request = JSON.parse(await readFile(join(runDir, 'request.json'), 'utf8'));
55
55
  // Sequential workers share the runner temp directory; only this stage's outputs may survive.
56
- for (const file of ['envelope.json', 'runtime.json', 'changes.json', 'checks.json']) await rm(join(runDir, file), { force: true });
56
+ for (const file of ['envelope.json', 'runtime.json', 'changes.json', 'checks.json', 'engine-error.json']) await rm(join(runDir, file), { force: true });
57
57
  const limits = executionLimits(config, request.stage === 'implement');
58
58
  let projectModels = null;
59
59
  try {
@@ -64,7 +64,7 @@ async function main() {
64
64
  projectModels = JSON.parse(await readFile(path, 'utf8'));
65
65
  }
66
66
  catch (error) { if (error.code !== 'ENOENT') throw error; }
67
- const modelSelection = resolveModelProfile({ project: projectModels, operator: config, worker: env.LIGHTHOUSE_WORKER ?? request.stage });
67
+ const modelSelection = resolveModelProfile({ project: projectModels, operator: config, worker: env.LIGHTHOUSE_WORKER ?? request.stage, ...(env.LIGHTHOUSE_MODEL_PROFILE ? { profile: env.LIGHTHOUSE_MODEL_PROFILE } : {}) });
68
68
  const selected = request.stage === 'implement' ? selectEnvironment(config, request.implementation?.environment) : null;
69
69
  if (request.stage === 'implement') {
70
70
  if (selected) config.implementationImage = selected.image;
@@ -100,7 +100,7 @@ async function main() {
100
100
  await rm(source, { recursive: true, force: true }); await mkdir(source);
101
101
  await materializeCandidate(baseline, seed, source);
102
102
  }
103
- await prepareImplementationWorkspace(source, config);
103
+ await prepareImplementationWorkspace(source, config, request.issue);
104
104
  }
105
105
  const context = join(dir, 'context.json');
106
106
  await copyFile(join(runDir, 'context.json'), context); await chmod(context, 0o444);
@@ -210,15 +210,15 @@ async function main() {
210
210
  runtime?.cleanup();
211
211
  try { docker(['run', '--rm', '--network', 'none', '--user', `${config.uid}:${config.uid}`, '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--mount', `type=volume,src=${config.authVolume},dst=/home/engine/.local/share/opencode`, config.image, 'cleanup-session', `ses_${request.runId}`]); } catch { console.error('Session cleanup failed; operator inspection required'); }
212
212
  if (slot !== undefined) closeSync(slot);
213
- if (implementing) {
214
- try { docker(['run', '--rm', '--network', 'none', '--user', '0:0', '--mount', `type=bind,src=${dir},dst=/cleanup`, '--entrypoint', 'chown', config.image, '-R', `${process.getuid()}:${process.getgid()}`, '/cleanup']); } catch { console.error('Workspace ownership cleanup failed; operator inspection required'); }
215
- }
213
+ // Read-only stages also create engine-owned command receipt directories.
214
+ try { restoreWorkspaceOwnership(dir, config); } catch { console.error('Workspace ownership cleanup failed; operator inspection required'); }
216
215
  await rm(dir, { recursive: true, force: true });
217
216
  }
218
217
  }
219
218
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(async error => {
220
219
  const message = redactLog(error.message);
221
220
  console.error(progressText('Engine error', message));
221
+ try { await writeFile(join(process.env.RUNNER_TEMP, `lighthouse-${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`, 'engine-error.json'), JSON.stringify({ schemaVersion: 1, message: message.slice(0, 3000) })); } catch {}
222
222
  if (process.env.GITHUB_STEP_SUMMARY) {
223
223
  try { await appendFile(process.env.GITHUB_STEP_SUMMARY, `\n### Execution failed\n\n${message.replaceAll('<', '&lt;').replaceAll('>', '&gt;')}\n\nInspect runtime.json and the retained engine events before retrying.\n`); } catch {}
224
224
  }