@juangcarmona/lighthouse-sdlc 0.18.0 → 0.18.2

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
@@ -109,7 +109,9 @@ An active or queued execution without an Actions update for two hours is reporte
109
109
 
110
110
  Read-only observation runs independently of the issue's execution lease, so reporting can inspect a stalled worker. Dispatch and reconciliation wait for that same per-issue lease before changing receipts, labels or handoffs. A separate report records the reconciliation outcome after the lease is available.
111
111
 
112
- For the latest authenticated unattended run, maintenance reports an interrupted failed or cancelled execution whose last handoff still says it is working or queued. The restored notice marks publication effects as unknown and directs the maintainer to inspect them; it does not release a reservation or request a retry.
112
+ For the latest authenticated unattended run, maintenance reports a completed execution whose last handoff still says it is working or queued. It identifies confirmed failures and cancellations, and treats publication effects as unknown until inspected. A successful execution without its terminal handoff also receives an uncertain-outcome notice. Maintenance does not release a reservation or request a retry in these cases.
113
+
114
+ If publication succeeds but reservation cleanup fails, maintenance preserves the published result and asks the maintainer to inspect the reservation before releasing it.
113
115
 
114
116
  ### Worker error reports
115
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juangcarmona/lighthouse-sdlc",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "type": "module",
5
5
  "description": "Provider-neutral SDLC skills, commands and route contracts for Lighthouse adopters",
6
6
  "repository": {
package/src/handoff.mjs CHANGED
@@ -54,7 +54,7 @@ export function handoff({ status, stage, happened, action, next, link, details }
54
54
 
55
55
  // A newer authenticated dispatch owns current status, even if an older cleanup finishes late.
56
56
  export async function publishObservedStop(api, { repository, issue, stage, run, attempt, dispatch, observation, sourceRevision, answerRetained = false, preparationReason }) {
57
- if (!['failure', 'cancelled', 'pre-agent-cancelled', 'preparation-failure', 'provider-preparation-failure', 'preparation-cancelled', 'activation-failure', 'provider-failure', 'provider-cancelled', 'publication-unknown'].includes(observation) || !dispatch?.commentId) return false;
57
+ if (!['failure', 'cancelled', 'pre-agent-cancelled', 'preparation-failure', 'provider-preparation-failure', 'preparation-cancelled', 'activation-failure', 'provider-failure', 'provider-cancelled', 'publication-unknown', 'cleanup-failure'].includes(observation) || !dispatch?.commentId) return false;
58
58
  const { checkDispatchClaim, issueComments, readDispatchReceipt } = await import('./stage-dispatch.mjs');
59
59
  await checkDispatchClaim(api, repository, issue, dispatch, run);
60
60
  const comments = await issueComments(api, issue);
@@ -77,9 +77,9 @@ export async function publishObservedStop(api, { repository, issue, stage, run,
77
77
  }
78
78
  const available = adopted && await api('labels/execution-failed', 'GET', undefined, true);
79
79
  if (available) await api(`issues/${issue}/labels`, 'POST', { labels: ['execution-failed'] });
80
- const status = ['cancelled', 'pre-agent-cancelled', 'preparation-cancelled', 'provider-cancelled'].includes(observation) ? 'cancelled' : observation === 'publication-unknown' ? 'unknown' : 'failed';
80
+ const status = ['cancelled', 'pre-agent-cancelled', 'preparation-cancelled', 'provider-cancelled'].includes(observation) ? 'cancelled' : observation === 'publication-unknown' ? 'unknown' : observation === 'cleanup-failure' ? 'blocked' : 'failed';
81
81
  const missingChecks = observation === 'preparation-failure' && preparationReason === 'missing-implementation-checks';
82
- const result = observation === 'publication-unknown' ? 'Publication did not complete cleanly; its effects and final result are not confirmed.' : missingChecks ? 'Preparation stopped because the repository has no implementation verification policy; the agent did not start.' : observation === 'preparation-failure' ? 'Preparation failed before the agent started; no validated result was published.' : observation === 'provider-preparation-failure' ? 'Product Change preparation failed before the provider started.' : observation === 'preparation-cancelled' ? 'Preparation was cancelled before work started; no validated result was published.' : observation === 'activation-failure' ? 'The execution environment failed before the agent started; no validated result was published.' : observation === 'provider-failure' ? 'The credential-free Product Change provider failed before publication.' : observation === 'provider-cancelled' ? 'The credential-free Product Change provider was cancelled before publication.' : observation === 'pre-agent-cancelled' ? 'The execution was cancelled before the agent started; no validated result was published.' : observation === 'failure' ? 'The agent execution failed before a validated result could be published.' : 'The execution was cancelled before a validated result could be published.';
82
+ const result = observation === 'cleanup-failure' ? 'The validated result was published, but reservation cleanup did not complete.' : observation === 'publication-unknown' ? 'Publication did not complete cleanly; its effects and final result are not confirmed.' : missingChecks ? 'Preparation stopped because the repository has no implementation verification policy; the agent did not start.' : observation === 'preparation-failure' ? 'Preparation failed before the agent started; no validated result was published.' : observation === 'provider-preparation-failure' ? 'Product Change preparation failed before the provider started.' : observation === 'preparation-cancelled' ? 'Preparation was cancelled before work started; no validated result was published.' : observation === 'activation-failure' ? 'The execution environment failed before the agent started; no validated result was published.' : observation === 'provider-failure' ? 'The credential-free Product Change provider failed before publication.' : observation === 'provider-cancelled' ? 'The credential-free Product Change provider was cancelled before publication.' : observation === 'pre-agent-cancelled' ? 'The execution was cancelled before the agent started; no validated result was published.' : observation === 'failure' ? 'The agent execution failed before a validated result could be published.' : 'The execution was cancelled before a validated result could be published.';
83
83
  const gap = ['failure', 'preparation-failure', 'provider-preparation-failure', 'activation-failure', 'provider-failure'].includes(observation) && !available ? ' The execution-failed label is not adopted or available; the maintainer must review label capability adoption.' : '';
84
84
  const reservation = item.labels.some(l => l.name === 'agent-working') ? ' The maintainer must also inspect the remaining agent-working reservation and release it only after verifying that no newer worker owns it.' : '';
85
85
  const action = (missingChecks ? 'The repository maintainer must configure checks in .lighthouse/implement.json or add npm run verify before requesting implementation again.' : observation === 'publication-unknown' ? 'The workflow maintainer must inspect the linked execution and compare the issue and any linked PR with its publication steps.' : 'The workflow maintainer must inspect the failed or cancelled execution and the configured recovery policy.') + reservation;
@@ -122,6 +122,13 @@ export function continuationHistory(continuation, answer) {
122
122
  export function checkProductEntry(product, snapshot) {
123
123
  if (!product || product.entry !== (snapshot.productEntry ?? 'idea')) throw Error('Product Change worker returned an outcome for a different authorized entry');
124
124
  }
125
+
126
+ export function productGapNextAction({ gap, enabled, dispatched, paused }) {
127
+ if (!gap) return null;
128
+ if (paused) return { action: 'Owner: remove the future label when work may resume.', next: !enabled ? 'The workflow maintainer must also install and enable the Product Change workflow, then request a new authorized stage execution.' : dispatched ? 'Then replay this run through the router using this issue, stage and run ID as after_run.' : 'Then request a new authorized stage execution.' };
129
+ if (enabled && dispatched) return { action: 'None. Wait for the Product Change preparation outcome.', next: 'The workflow will authenticate this prerequisite and check whether product preparation is authorized; implementation remains stopped.' };
130
+ return { action: enabled ? 'Workflow maintainer: request a new authorized stage execution in the router.' : 'Workflow maintainer: install and enable the Product Change workflow.', next: 'Request a new authorized stage execution in the router; implementation remains stopped until the product prerequisite is approved and applied.' };
131
+ }
125
132
  export function productApplyRequest({ dispatch, current, issueNumber, issue, repository, owner }) {
126
133
  if (dispatch?.request.worker !== 'apply-product-change' || !dispatch.request.productApproval) throw Error('Trusted Product Change apply dispatch is missing');
127
134
  const requested = dispatch.request.productApproval;
@@ -762,8 +769,9 @@ async function main(command) {
762
769
  const projection = resultHandoffState({ status: value.result.status, stage: snapshot.stage, question, reviewTarget, draft: !!prStateProof });
763
770
  const isReview = projection.review;
764
771
  const productRecovery = snapshot.worker === 'product-change' ? value.result.output?.productChange?.recovery : null;
765
- 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}` : visibleNextAction(value.result.nextAction) : refinementAction?.action ?? 'None.';
766
- 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' ? 'Work remains stopped until the blocker is resolved and continuation is authorized.' : refinementAction?.next ?? 'Any continuation follows the configured authorization.';
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;
773
+ 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
+ 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.';
767
775
  const happened = productPublicationMessage({ worker: snapshot.worker, outcome: value.result.output?.productChange?.outcome, reviewTarget, agentMessage: value.effect.body });
768
776
  const body = renderHandoff({ status: projection.status, stage: snapshot.stage, happened, action, next: whatNext, link: proposalLink.trim() || undefined, details: `Outcome: ${value.result.status}\n\n[Workflow run](${runUrl}) · source \`${snapshot.sourceRevision}\`\n\n${marker}\n<!-- lighthouse-handoff:v1 -->\n${prStateProof}\n<!-- lighthouse-stage:${snapshot.stage}:${value.result.status} -->${question ? '\n<!-- lighthouse-question -->' : ''}${productWorkflowMarker}${continuationHint}` });
769
777
  const handoff = snapshot.worker !== 'product-change' && snapshot.stage === 'refine' && value.result.status === 'passed' && env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true' && !decompositionPlanned;
@@ -7,6 +7,7 @@ export const maintenancePath = '.github/workflows/lighthouse-worker-maintenance.
7
7
  export const retryLimit = 2;
8
8
  export const retryDelayMs = 30 * 60 * 1000;
9
9
  export const recoveryId = (repository, rootId, attempt) => digest(`${repository}:${rootId}:retry:${attempt}`);
10
+ const publishStep = job => job?.steps?.find(step => ['Recheck authority and publish the validated result', 'Recheck authority and publish'].includes(step.name));
10
11
  const identity = r => ({ issue: r.request.issue, worker: workerFor(r.request), runId: String(r.run.id), rootId: r.request.recovery?.rootId ?? r.request.id, attempt: r.request.recovery?.attempt ?? 0 });
11
12
  const ownerAnswers = comments => comments.filter(c => c.user?.login === c.owner && !c.body.includes('<!-- lighthouse-run:') && !c.body.includes('<!-- lighthouse-actions:'));
12
13
  const intent = (issue, comments, owner) => digest(JSON.stringify({ title: issue.title, body: issue.body, comments: ownerAnswers(comments.map(c => ({ ...c, owner }))).map(c => ({ id: c.id, body: c.body, updated_at: c.updated_at })) }));
@@ -29,9 +30,9 @@ export function recoveryDecision({ request, receipt, run, jobs, issue, comments,
29
30
  if (!['refine', 'propose', 'implement'].includes(request.stage) || !['failure', 'timed_out'].includes(run.conclusion)) return result('uncertain', 'manual-recovery', 'Inspect the outcome and request a new authorized execution if needed.');
30
31
  const preparation = jobs.find(j => j.name === 'Authorize and reserve issue');
31
32
  const publication = jobs.find(j => j.name === 'Validate, publish and release');
32
- const publishStep = publication?.steps?.find(s => s.name === 'Recheck authority and publish the validated result');
33
+ const publish = publishStep(publication);
33
34
  const failedExecution = jobs.some(j => ['pre_activation', 'activation', 'agent'].includes(j.name) && ['failure', 'timed_out'].includes(j.conclusion));
34
- if (preparation?.conclusion !== 'success' || !failedExecution || publishStep?.conclusion !== 'skipped') return result('uncertain', 'publication-uncertain', 'Inspect publication and remote effects before creating another request.');
35
+ if (preparation?.conclusion !== 'success' || !failedExecution || publish?.conclusion !== 'skipped') return result('uncertain', 'publication-uncertain', 'Inspect publication and remote effects before creating another request.');
35
36
  if (base.attempt >= retryLimit) return result('exhausted', 'worker-technical-failure', 'Retry budget exhausted. Inspect the failure and make a new owner decision.');
36
37
  const ended = Date.parse(run.updated_at);
37
38
  if (!Number.isFinite(ended) || !Number.isFinite(now)) return result('uncertain', 'execution-time-unknown', 'Inspect the execution timestamps.');
@@ -63,14 +64,16 @@ export async function observeDispatch(api, repository, owner, comment, issue, co
63
64
  const workflowRevision = (await api(`commits/${encodeURIComponent(dispatch.request.ref)}`)).sha;
64
65
  const agent = jobs.jobs.find(job => job.name === 'agent');
65
66
  const publishJob = jobs.jobs.find(job => job.name === 'Validate, publish and release');
66
- const publish = publishJob?.steps?.find(step => step.name === 'Recheck authority and publish the validated result');
67
+ const publish = publishStep(publishJob);
68
+ const cleanup = publishJob?.steps?.find(step => ['Release the issue reservation', 'Release reservation'].includes(step.name));
67
69
  const prepare = jobs.jobs.find(job => job.name === 'Authorize and reserve issue');
68
70
  const unpublished = publish?.conclusion === 'skipped' || publishJob?.conclusion === 'skipped';
69
71
  const notStarted = !agent || agent.conclusion === 'skipped';
70
72
  const activationFailed = jobs.jobs.some(job => ['pre_activation', 'activation'].includes(job.name) && ['failure', 'timed_out'].includes(job.conclusion));
71
- const observation = ['completed', 'waiting', 'blocked'].includes(receipt.status) ? null
73
+ const observation = publish?.conclusion === 'success' && ['failure', 'timed_out', 'cancelled'].includes(cleanup?.conclusion) ? 'cleanup-failure'
74
+ : ['completed', 'waiting', 'blocked'].includes(receipt.status) ? null
72
75
  : workerFor(dispatch.request) === 'apply-product-change' ? productApplyObservation({ prepare: jobs.jobs.find(job => job.name === 'Recheck product approval')?.conclusion, apply: jobs.jobs.find(job => job.name === 'Apply with the pinned ProductShape provider')?.conclusion, publish: jobs.jobs.find(job => job.name === 'Recheck and publish provider output')?.conclusion })
73
- : agent?.conclusion === 'success' && ['failure', 'timed_out', 'cancelled'].includes(publish?.conclusion) ? 'publication-unknown'
76
+ : agent?.conclusion === 'success' && (!publishJob || ['failure', 'timed_out', 'cancelled', 'skipped'].includes(publish?.conclusion) || ['failure', 'timed_out', 'cancelled', 'skipped'].includes(publishJob.conclusion)) ? 'publication-unknown'
74
77
  : ['failure', 'timed_out'].includes(prepare?.conclusion) && notStarted ? 'preparation-failure'
75
78
  : prepare?.conclusion === 'success' && activationFailed && notStarted && unpublished ? 'activation-failure'
76
79
  : unpublished && ['failure', 'timed_out'].includes(agent?.conclusion) ? 'failure'
@@ -118,7 +121,7 @@ export async function reconcileAbandonedReservation(api, repository, owner, issu
118
121
  const prepare = jobs.find(job => job.name === 'Authorize and reserve issue');
119
122
  const agent = jobs.find(job => job.name === 'agent');
120
123
  const publish = jobs.find(job => job.name === 'Validate, publish and release');
121
- const step = publish?.steps?.find(item => item.name === 'Recheck authority and publish the validated result');
124
+ const step = publishStep(publish);
122
125
  if (prepare?.conclusion !== 'success' || !['failure', 'timed_out', 'cancelled', 'skipped'].includes(agent?.conclusion) || !(publish?.conclusion === 'skipped' || step?.conclusion === 'skipped')) return false;
123
126
  let snapshot;
124
127
  try { snapshot = await readArtifact(api, repository, run.id, `lighthouse-input-${issue.number}-${run.run_attempt}`, 'request.json'); } catch { return false; }
@@ -167,7 +170,7 @@ export async function reconcilePublishedStage(api, repository, owner, issue, com
167
170
  const { dispatch, run, jobs = [], revision, workflowRevision } = observation;
168
171
  if (!dispatch || !run) return 'not-applicable';
169
172
  const publication = jobs.find(j => j.name === 'Validate, publish and release');
170
- const publish = publication?.steps?.find(s => s.name === 'Recheck authority and publish the validated result');
173
+ const publish = publishStep(publication);
171
174
  const failedPublication = run.conclusion === 'failure' && jobs.find(j => j.name === 'agent')?.conclusion === 'success' && publish?.conclusion === 'failure';
172
175
  const interrupted = failedPublication && ['running', 'unknown'].includes(dispatch.receipt.status);
173
176
  const completed = (run.conclusion === 'success' || failedPublication) && ['completed', 'waiting', 'blocked'].includes(dispatch.receipt.status);
@@ -235,12 +238,12 @@ export async function reconcileUnattendedStop(api, repository, owner, issue, com
235
238
  if (!last.body.includes(marker)) return 'uncertain';
236
239
  const run = await api(`actions/runs/${runId}`);
237
240
  if (run?.repository?.full_name !== repository || run.path !== '.github/workflows/lighthouse-sdlc-unattended.yml' || run.event !== 'workflow_dispatch' || run.actor?.login !== owner || run.triggering_actor?.login !== owner || run.display_title !== `${route} #${issue.number} · ${owner}` || run.run_attempt !== 1) return 'uncertain';
238
- if (run.status !== 'completed' || !['failure', 'timed_out', 'cancelled'].includes(run.conclusion)) return 'not-applicable';
241
+ if (run.status !== 'completed' || !['success', 'failure', 'timed_out', 'cancelled'].includes(run.conclusion)) return 'not-applicable';
239
242
  const fresh = await api(`issues/${issue.number}`), latest = await issueComments(api, issue.number);
240
243
  const current = latest.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && /<!-- lighthouse-(?:unattended:|unattended-recovery:|dispatch:|actions:|stop:)/.test(c.body ?? '')).at(-1);
241
244
  if (fresh.state !== 'open' || fresh.user?.login !== owner || current?.id !== last.id) return 'uncertain';
242
245
  const reservation = fresh.labels.some(l => l.name === 'agent-working') ? ' The agent-working reservation remains; verify ownership before releasing it.' : '';
243
- const body = handoff({ status: 'unknown', stage: route, happened: `The unattended execution ended ${run.conclusion}, but its terminal handoff is missing. Publication and integration effects have not been confirmed.`, action: `Workflow maintainer: inspect the execution, artifacts, issue and any candidate PR before resuming.${reservation}`, next: 'Do not retry or release a reservation until the observed effects and current ownership are reconciled.', link: `[Execution](https://github.com/${repository}/actions/runs/${runId})`, details: `<!-- lighthouse-unattended-recovery:${runId}:1 -->` });
246
+ const body = handoff({ status: run.conclusion === 'success' ? 'unknown' : run.conclusion === 'cancelled' ? 'cancelled' : 'failed', stage: route, happened: `The unattended execution ended ${run.conclusion}, but its terminal handoff is missing. Publication and integration effects have not been confirmed.`, action: `Workflow maintainer: inspect the execution, artifacts, issue and any candidate PR before resuming.${reservation}`, next: 'Do not retry or release a reservation until the observed effects and current ownership are reconciled.', link: `[Execution](https://github.com/${repository}/actions/runs/${runId})`, details: `<!-- lighthouse-unattended-recovery:${runId}:1 -->` });
244
247
  await api(`issues/${issue.number}/comments`, 'POST', { body });
245
248
  return 'restored';
246
249
  }
@@ -286,7 +289,7 @@ export async function prepareRecovery(api, repository, owner, issueNumber, paren
286
289
  if (preparationRun && await reconcilePreparationFailure(api, repository, owner, issueNumber, comment.id, preparationRun, readArtifact)) { observation.stop = null; Object.assign(publicObservation, { state: 'reconciled', code: 'preparation-failed', nextAction: 'Inspect the confirmed failed execution before requesting new work.' }); }
287
290
  const reconciliation = await reconcilePublishedStage(api, repository, owner, issue, comments, observation, readArtifact);
288
291
  if (reconciliation === 'restored' || reconciliation === 'present') {
289
- observation.stop = null;
292
+ if (observation.stop?.observation !== 'cleanup-failure') observation.stop = null;
290
293
  if (run?.conclusion === 'failure') Object.assign(publicObservation, { state: 'reconciled', code: 'handoff-restored', nextAction: 'Follow the restored handoff.' });
291
294
  }
292
295
  if (reconciliation === 'uncertain') Object.assign(publicObservation, { state: 'uncertain', code: 'handoff-unconfirmed', nextAction: 'Inspect the retained result and current issue state before restoring its handoff.' });