@juangcarmona/lighthouse-sdlc 0.18.5 → 0.18.7

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/epic-delivery.md CHANGED
@@ -22,7 +22,7 @@ All accepted children make the aggregate `publication-eligible`; the parent stil
22
22
 
23
23
  The native epic workflow is not installed or enabled. The implementation includes authenticated child admission, epic-targeted candidates, owner-authorized internal acceptance and draft aggregate publication, but automatic child dispatch, review recovery and complete native acceptance remain unfinished. The current `github-decompose --apply` legacy helper is not this lifecycle and does not provide retry-safe native sub-issue orchestration. Do not use it as a substitute.
24
24
 
25
- Native admission currently requires the default branch to remain at the authenticated refinement revision. A change to that branch stops the epic; post-merge acceptance and recovery against an advanced default branch are not yet connected.
25
+ Native child delivery requires the default branch to remain at the authenticated refinement revision. Aggregate acceptance can inspect an advanced default branch after merge: it requires retained readiness evidence, accepted child history, passing checks, owner approval and the merged commit in the current branch. Missing or expired evidence stops acceptance and leaves the parent open. This path has not yet been validated end to end in an installed native workflow.
26
26
 
27
27
  ## GitHub reconciliation adapter
28
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juangcarmona/lighthouse-sdlc",
3
- "version": "0.18.5",
3
+ "version": "0.18.7",
4
4
  "type": "module",
5
5
  "description": "Provider-neutral SDLC skills, commands and route contracts for Lighthouse adopters",
6
6
  "repository": {
@@ -19,7 +19,9 @@ function checkedReceipt(key, receipt, repository, parent) {
19
19
  if (needsPublication && (!publication || !positive(publication.run) || !Number.isSafeInteger(publication.attempt) || publication.attempt < 1 || !sha(publication.head) || Object.keys(publication).sort().join(',') !== 'attempt,head,run') || !needsPublication && publication !== undefined) throw Error('Epic publication receipt is invalid');
20
20
  const merge = receipt.merge, merging = ['merging', 'accepted'].includes(receipt.state);
21
21
  if (merging && (receipt.correlation.kind !== 'pull-request' || !merge || !sha(merge.head) || merge.head !== publication.head || !sha(merge.base) || !sha(merge.candidate) || !Number.isSafeInteger(merge.review) || merge.review < 1 || receipt.state === 'accepted' && !sha(merge.commit) || Object.keys(merge).sort().join(',') !== (receipt.state === 'accepted' ? 'base,candidate,commit,head,review' : 'base,candidate,head,review')) || !merging && merge !== undefined) throw Error('Epic merge receipt is invalid');
22
- const expected = ['correlation', ...(merge ? ['merge'] : []), ...(publication ? ['publication'] : []), ...(resource ? ['resource'] : []), 'schemaVersion', 'state'].sort().join(',');
22
+ const readiness = receipt.readiness;
23
+ if (readiness !== undefined && (receipt.correlation.kind !== 'aggregate' || receipt.state !== 'linked' || !readiness || !sha(readiness.head) || !sha(readiness.base) || !sha(readiness.candidate) || Object.keys(readiness).sort().join(',') !== 'base,candidate,head')) throw Error('Epic aggregate readiness receipt is invalid');
24
+ const expected = ['correlation', ...(merge ? ['merge'] : []), ...(publication ? ['publication'] : []), ...(readiness ? ['readiness'] : []), ...(resource ? ['resource'] : []), 'schemaVersion', 'state'].sort().join(',');
23
25
  if (Object.keys(receipt).sort().join(',') !== expected) throw Error('Epic receipt has unexpected fields');
24
26
  return receipt;
25
27
  }
@@ -55,6 +57,18 @@ export async function readEpicChildReceipt(input) {
55
57
  return readEpicResourceReceipt(input);
56
58
  }
57
59
 
60
+ export async function readEpicAggregateReceipt(input) {
61
+ if (input.correlation?.kind !== 'aggregate' || !input.resource) throw Error('Invalid epic aggregate admission');
62
+ const { api, repository, correlation } = input;
63
+ const comments = await issueComments(api, correlation.parent);
64
+ const matches = comments.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body?.includes(marker)).map(c => parseEntry(c, repository, correlation.parent)).filter(entry => entry.key === keyFor(correlation));
65
+ if (matches.length !== 1) throw Error('Epic aggregate journal is missing or ambiguous');
66
+ const sourceRevision = matches[0].revision;
67
+ const receipt = await readEpicResourceReceipt({ ...input, sourceRevision });
68
+ if (!receipt.readiness) throw Error('Epic aggregate has no readiness evidence');
69
+ return { sourceRevision, receipt };
70
+ }
71
+
58
72
  export async function createEpicJournal({ api, repository, owner, parent, sourceRevision, defaultBranch, run, path, authorize, readArtifact = readActionsJson }) {
59
73
  if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? '') || typeof owner !== 'string' || !positive(parent) || !sha(sourceRevision) || !defaultBranch || !positive(run?.id) || !Number.isSafeInteger(run?.run_attempt) || run.run_attempt < 1 || run.status !== 'in_progress' || run.path !== '.github/workflows/lighthouse-sdlc-epic.yml' || run.event !== 'workflow_dispatch' || run.actor?.login !== owner || run.triggering_actor?.login !== owner || run.head_sha !== sourceRevision || run.head_branch !== defaultBranch || run.repository?.full_name !== repository || typeof authorize !== 'function' || typeof path !== 'string') throw Error('Epic journal requires an authenticated owner workflow');
60
74
  parent = Number(parent);
@@ -7,7 +7,7 @@ import { loadRefinement } from './proposal.mjs';
7
7
  import { validateStageEnvelope } from './host-contract.mjs';
8
8
  import { assessDecomposition, epicPlanDigest } from './epic.mjs';
9
9
  import { reconcileEpicChildren, readEpicMarker, epicMarker, epicCorrelation, epicChildBody, epicJournalKey } from './epic-github.mjs';
10
- import { createEpicJournal, readEpicChildReceipt, readEpicResourceReceipt } from './epic-journal.mjs';
10
+ import { createEpicJournal, readEpicAggregateReceipt, readEpicChildReceipt, readEpicResourceReceipt } from './epic-journal.mjs';
11
11
  import { isDeepStrictEqual } from 'node:util';
12
12
  import { checkImplementationPullRequest } from './proposal-review.mjs';
13
13
  import { readActionsJson } from './actions-artifact.mjs';
@@ -22,11 +22,12 @@ import { setPullRequestDraft, projectImplementationPrState } from './pr-state.mj
22
22
  const isOwnerAnswer = (comment, owner) => comment.user?.login === owner && !comment.body?.includes('<!-- lighthouse-run:') && !comment.body?.includes('<!-- lighthouse-actions:');
23
23
  const paused = issue => issue.state !== 'open' || issue.pull_request || issue.labels.some(label => ['future', 'human-working', 'agent-working', 'needs-info', 'waiting-review', 'execution-failed', 'triage', 'refine', 'propose', 'implement'].includes(label.name));
24
24
 
25
- export async function prepareNativeEpic(api, repository, owner, issueNumber, load = loadRefinement) {
25
+ export async function prepareNativeEpic(api, repository, owner, issueNumber, load = loadRefinement, historicalRevision) {
26
26
  if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? '') || typeof owner !== 'string' || !Number.isSafeInteger(issueNumber) || issueNumber < 1) throw Error('Invalid epic identity');
27
27
  const issue = await api(`issues/${issueNumber}`), comments = await issueComments(api, issueNumber), repo = await api('');
28
28
  if (paused(issue) || !issue.labels.some(label => label.name === 'ready')) throw Error('Epic parent is not ready or is paused');
29
- const sourceRevision = (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha;
29
+ const sourceRevision = historicalRevision ?? (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha;
30
+ if (!/^[a-f0-9]{40}$/.test(sourceRevision ?? '')) throw Error('Epic source revision is invalid');
30
31
  const current = { owner, issue: issueNumber, sourceRevision, taskDigest: taskDigest(issue, comments.filter(comment => isOwnerAnswer(comment, owner))) };
31
32
  const evidence = await load(api, repository, comments, current);
32
33
  if (evidence.request?.issue !== issueNumber || evidence.request.stage !== 'refine' || evidence.request.sourceRevision !== sourceRevision || evidence.request.taskDigest !== current.taskDigest || validateStageEnvelope(evidence.envelope, evidence.request).length) throw Error('Epic refinement result is invalid or stale');
@@ -39,26 +40,27 @@ export async function prepareNativeEpic(api, repository, owner, issueNumber, loa
39
40
  return { issue: issueNumber, repository, owner, sourceRevision, defaultBranch: repo.default_branch, taskDigest: current.taskDigest, reportId: report.id, reportBody: report.body, plan: assessment.plan };
40
41
  }
41
42
 
42
- export async function authorizeNativeEpic(api, snapshot) {
43
+ export async function authorizeNativeEpic(api, snapshot, expectedHead = snapshot.sourceRevision) {
43
44
  const issue = await api(`issues/${snapshot.issue}`), comments = await issueComments(api, snapshot.issue), repo = await api('');
44
45
  const report = comments.find(comment => comment.id === snapshot.reportId);
45
46
  const later = comments.some(comment => Number(comment.id) > Number(snapshot.reportId) && comment.user?.login === 'github-actions[bot]' && comment.user.type === 'Bot' && /<!-- lighthouse-(?:stage:refine:|dispatch:)/.test(comment.body ?? ''));
46
- if (paused(issue) || !issue.labels.some(label => label.name === 'ready') || repo.default_branch !== snapshot.defaultBranch || (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha !== snapshot.sourceRevision || taskDigest(issue, comments.filter(comment => isOwnerAnswer(comment, snapshot.owner))) !== snapshot.taskDigest || !report || report.user?.login !== 'github-actions[bot]' || report.user.type !== 'Bot' || report.body !== snapshot.reportBody || later) throw Error('Epic parent authority or refinement changed');
47
+ if (paused(issue) || !issue.labels.some(label => label.name === 'ready') || repo.default_branch !== snapshot.defaultBranch || (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha !== expectedHead || taskDigest(issue, comments.filter(comment => isOwnerAnswer(comment, snapshot.owner))) !== snapshot.taskDigest || !report || report.user?.login !== 'github-actions[bot]' || report.user.type !== 'Bot' || report.body !== snapshot.reportBody || later) throw Error('Epic parent authority or refinement changed');
47
48
  return true;
48
49
  }
49
50
 
50
- export async function authenticateEpicChild({ api, repository, owner, child, load, readArtifact, allowClosed = false }) {
51
+ export async function authenticateEpicChild({ api, repository, owner, child, load, readArtifact, allowClosed = false, historicalSnapshot, expectedHead }) {
51
52
  if (!Number.isSafeInteger(child) || child < 1) throw Error('Invalid epic child');
52
53
  const issue = await api(`issues/${child}`), marker = readEpicMarker(issue.body);
53
54
  if (issue.number !== child || !Number.isSafeInteger(issue.id) || issue.id < 1 || issue.state !== 'open' && !(allowClosed && issue.state === 'closed') || issue.pull_request || issue.user?.login !== 'github-actions[bot]' || issue.user.type !== 'Bot' || !marker || marker.kind !== 'child' || marker.repository !== repository || marker.parent === child) throw Error('Epic child identity is not trusted');
54
- const snapshot = await prepareNativeEpic(api, repository, owner, marker.parent, load);
55
+ const snapshot = historicalSnapshot ?? await prepareNativeEpic(api, repository, owner, marker.parent, load);
56
+ if (snapshot.issue !== marker.parent || snapshot.repository !== repository || snapshot.owner !== owner) throw Error('Epic child snapshot identity changed');
55
57
  const slice = snapshot.plan.slices.find(item => item.id === marker.slice);
56
58
  const expected = slice && epicCorrelation({ repository, parent: marker.parent, slice: slice.id, planDigest: epicPlanDigest(snapshot.plan), base: `lighthouse/epic-${marker.parent}`, dependencies: slice.dependencies });
57
59
  if (!expected || !isDeepStrictEqual(marker, expected) || issue.title !== slice.title || issue.body !== epicChildBody(marker.parent, slice, expected)) throw Error('Epic child no longer matches the current plan');
58
60
  const parent = await api(`issues/${child}/parent`);
59
61
  if (parent?.number !== marker.parent) throw Error('Epic child parent link changed');
60
62
  await readEpicChildReceipt({ api, repository, owner, correlation: expected, sourceRevision: snapshot.sourceRevision, defaultBranch: snapshot.defaultBranch, resource: { id: issue.id, number: child }, ...(readArtifact ? { readArtifact } : {}) });
61
- await authorizeNativeEpic(api, snapshot);
63
+ await authorizeNativeEpic(api, snapshot, expectedHead);
62
64
  return { parent: marker.parent, slice: marker.slice, planDigest: marker.planDigest, base: marker.base, sourceRevision: snapshot.sourceRevision };
63
65
  }
64
66
 
@@ -349,6 +351,10 @@ export async function readyEpicAggregate({ api, repository, owner, snapshot, jou
349
351
  return { pr, head: published.head, base: base.object.sha, candidate: pr.merge_commit_sha };
350
352
  };
351
353
  const first = await observe();
354
+ const correlation = epicCorrelation({ repository, parent: snapshot.issue, slice: 'aggregate', planDigest: epicPlanDigest(snapshot.plan), base: `lighthouse/epic-${snapshot.issue}`, dependencies: snapshot.plan.slices.map(slice => slice.id), kind: 'aggregate' });
355
+ const key = epicJournalKey(correlation), receipt = await journal.readReceipt(key);
356
+ if (receipt?.state !== 'linked' || receipt.resource?.id !== first.pr.id || receipt.resource?.number !== first.pr.number || !isDeepStrictEqual(receipt.correlation, correlation)) throw Error('Aggregate readiness journal changed');
357
+ await journal.writeReceipt(key, { ...receipt, readiness: { head: first.head, base: first.base, candidate: first.candidate } });
352
358
  if (first.pr.draft) {
353
359
  try { await setDraft(first.pr, false); }
354
360
  catch (error) { if ((await api(`pulls/${first.pr.number}`)).draft) throw error; }
@@ -358,6 +364,55 @@ export async function readyEpicAggregate({ api, repository, owner, snapshot, jou
358
364
  return { status: 'ready', pullRequest: fresh.pr.number, head: fresh.head, base: fresh.base, parent: 'open' };
359
365
  }
360
366
 
367
+ export async function acceptEpicAggregate({ api, repository, owner, issue, run, load, loadPolicy, readArtifact }) {
368
+ const repo = await api(''), branch = `lighthouse/epic-${issue}`;
369
+ const head = (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha;
370
+ if (!/^[a-f0-9]{40}$/.test(head ?? '') || run.status !== 'in_progress' || run.path !== '.github/workflows/lighthouse-sdlc-epic.yml' || run.event !== 'workflow_dispatch' || run.actor?.login !== owner || run.triggering_actor?.login !== owner || run.head_sha !== head || run.head_branch !== repo.default_branch || run.repository?.full_name !== repository) throw Error('Epic acceptance requires an authenticated owner workflow');
371
+ await checkEpicLeaseConfiguration(api, head);
372
+ const pulls = await api(`pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&base=${encodeURIComponent(repo.default_branch)}&per_page=100`);
373
+ if (!Array.isArray(pulls) || pulls.length !== 1) throw Error('Aggregate PR inventory is missing or ambiguous');
374
+ const pr = await api(`pulls/${pulls[0].number}`), correlation = readEpicMarker(pr.body);
375
+ if (!correlation || correlation.kind !== 'aggregate' || correlation.repository !== repository || correlation.parent !== issue || correlation.base !== branch || correlation.slice !== 'aggregate' || !Number.isSafeInteger(pr.id) || !Number.isSafeInteger(pr.number)) throw Error('Aggregate PR identity is invalid');
376
+ const { sourceRevision, receipt } = await readEpicAggregateReceipt({ api, repository, owner, correlation, defaultBranch: repo.default_branch, resource: { id: pr.id, number: pr.number }, ...(readArtifact ? { readArtifact } : {}) });
377
+ const snapshot = await prepareNativeEpic(api, repository, owner, issue, load, sourceRevision);
378
+ const expected = epicCorrelation({ repository, parent: issue, slice: 'aggregate', planDigest: epicPlanDigest(snapshot.plan), base: branch, dependencies: snapshot.plan.slices.map(slice => slice.id), kind: 'aggregate' });
379
+ if (!isDeepStrictEqual(correlation, expected)) throw Error('Aggregate plan changed');
380
+ const expectedBody = `Aggregate changes for #${issue}.\n\nRefs #${issue}.\n\nReview and merge remain separate decisions.\n\n${epicMarker(expected)}`;
381
+ const journal = { readReceipt: async key => {
382
+ const match = snapshot.plan.slices.map(slice => epicCorrelation({ repository, parent: issue, slice: slice.id, planDigest: expected.planDigest, base: branch, dependencies: slice.dependencies, kind: 'pull-request' })).find(item => epicJournalKey(item) === key);
383
+ if (!match) throw Error('Unknown epic receipt');
384
+ return readEpicResourceReceipt({ api, repository, owner, correlation: match, sourceRevision, defaultBranch: repo.default_branch, allowedStates: ['accepted'], ...(readArtifact ? { readArtifact } : {}) });
385
+ } };
386
+ const verify = async () => {
387
+ await authorizeNativeEpic(api, snapshot, head);
388
+ const currentReceipt = await readEpicAggregateReceipt({ api, repository, owner, correlation, defaultBranch: repo.default_branch, resource: { id: pr.id, number: pr.number }, ...(readArtifact ? { readArtifact } : {}) });
389
+ if (currentReceipt.sourceRevision !== sourceRevision || !isDeepStrictEqual(currentReceipt.receipt, receipt)) throw Error('Aggregate readiness evidence changed');
390
+ const fresh = await api(`pulls/${pr.number}`), binding = receipt.readiness;
391
+ if (fresh.id !== pr.id || !fresh.merged || fresh.state !== 'closed' || fresh.user?.login !== 'github-actions[bot]' || fresh.user.type !== 'Bot' || fresh.head?.ref !== branch || fresh.head.sha !== binding.head || fresh.head.repo?.full_name !== repository || fresh.base?.ref !== repo.default_branch || fresh.base.repo?.full_name !== repository || fresh.body !== expectedBody || !/^[a-f0-9]{40}$/.test(fresh.merge_commit_sha ?? '')) throw Error('Aggregate merge differs from readiness evidence');
392
+ const commit = await api(`git/commits/${fresh.merge_commit_sha}`), candidate = await api(`git/commits/${binding.candidate}`);
393
+ if (!Array.isArray(commit.parents) || ![1, 2].includes(commit.parents.length) || commit.parents[0].sha !== binding.base || commit.parents.length === 2 && commit.parents[1].sha !== binding.head || !/^[a-f0-9]{40}$/.test(commit.tree?.sha ?? '') || commit.tree.sha !== candidate?.tree?.sha || candidate.parents?.length !== 2 || candidate.parents[0].sha !== binding.base || candidate.parents[1].sha !== binding.head) throw Error('Aggregate merge commit differs from its checked candidate');
394
+ const reviews = await list(api, `pulls/${pr.number}/reviews`);
395
+ const latest = reviews.filter(review => review.user?.login === owner && review.user.type === 'User' && ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)).sort((a, b) => b.id - a.id)[0];
396
+ if (latest?.state !== 'APPROVED' || latest.commit_id !== binding.head) throw Error('Aggregate owner approval is missing or revoked');
397
+ if (!await verifyEpicChecks(api, { repository, sourceRevision: head, base: repo.default_branch, candidate: binding.candidate, loadPolicy })) throw Error('Aggregate checks are no longer valid');
398
+ const compare = await api(`compare/${fresh.merge_commit_sha}...${head}`);
399
+ if (!['identical', 'ahead'].includes(compare.status)) throw Error('Aggregate merge is absent from current main history');
400
+ await verifyEpicBranchHistory(api, snapshot, journal, binding.head, true, loadPolicy);
401
+ for (const slice of snapshot.plan.slices) {
402
+ const childCorrelation = epicCorrelation({ repository, parent: issue, slice: slice.id, planDigest: expected.planDigest, base: branch, dependencies: slice.dependencies });
403
+ const children = await findEpicCandidates(api, childCorrelation);
404
+ if (children.length !== 1 || children[0].state !== 'closed') throw Error('Epic child completion is missing or ambiguous');
405
+ await authenticateEpicChild({ api, repository, owner, child: children[0].number, readArtifact, allowClosed: true, historicalSnapshot: snapshot, expectedHead: head });
406
+ }
407
+ await authorizeNativeEpic(api, snapshot, head);
408
+ };
409
+ await verify();
410
+ await verify();
411
+ try { await api(`issues/${issue}`, 'PATCH', { state: 'closed', state_reason: 'completed' }); }
412
+ catch (error) { if ((await api(`issues/${issue}`)).state !== 'closed') throw error; }
413
+ return { status: 'accepted', pullRequest: pr.number, parent: 'closed' };
414
+ }
415
+
361
416
  export async function ensureEpicBase(api, snapshot, authorize, verifyAdvanced = async () => false) {
362
417
  const branch = `lighthouse/epic-${snapshot.issue}`;
363
418
  const read = () => api(`git/ref/heads/${branch}`, 'GET', undefined, true);
@@ -397,6 +452,7 @@ export async function runNativeEpic({ api, repository, owner, issue, run, journa
397
452
 
398
453
  export async function runEpicAction({ api, repository, owner, issue, run, journalPath, action, child, implementationRun, decision, readArtifact, load, loadPolicy, setDraft = (pr, draft) => setPullRequestDraft(process.env.GH_TOKEN, pr, draft) }) {
399
454
  if (action === 'reconcile') return runNativeEpic({ api, repository, owner, issue, run, journalPath, readArtifact, load, loadPolicy });
455
+ if (action === 'accept-aggregate') return acceptEpicAggregate({ api, repository, owner, issue, run, load, loadPolicy, readArtifact });
400
456
  if (!['register-pr', 'ready-child', 'accept-child', 'publish-aggregate', 'ready-aggregate'].includes(action) || !['publish-aggregate', 'ready-aggregate'].includes(action) && (!Number.isSafeInteger(child) || child < 1)) throw Error('Invalid epic action or child');
401
457
  const snapshot = await prepareNativeEpic(api, repository, owner, issue, load);
402
458
  await checkEpicLeaseConfiguration(api, snapshot.sourceRevision);
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { checkImplementationPullRequest } from './proposal-review.mjs';
3
- import { authorizedCiCallback, isCurrentPullRequestEvent, productOnlyCheckpoint, isStaleProductDecision, projectImplementationPrState, reconcileImplementationPr, scheduledImplementationPulls, setPullRequestDraft } from './pr-state.mjs';
3
+ import { authorizedCiCallback, draftIntentMarker, isCurrentPullRequestEvent, pendingDraftIntent, productOnlyCheckpoint, isStaleProductDecision, projectImplementationPrState, reconcileImplementationPr, scheduledImplementationPulls, setPullRequestDraft } from './pr-state.mjs';
4
4
  import { handoff } from './handoff.mjs';
5
5
  import { productProposalProof } from './product-change.mjs';
6
6
 
@@ -113,8 +113,19 @@ async function main() {
113
113
  }
114
114
  const hold = comments.some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(`<!-- lighthouse-pr-hold:${pr.number}:${pr.head.sha} -->`));
115
115
  const auto = (['workflow_run', 'schedule'].includes(env.GITHUB_EVENT_NAME) || ciCallback) && !hold;
116
- const result = await reconcileImplementationPr(api, repo, pr, issue, comments, requiredChecks(profile), (current, draft) => setPullRequestDraft(env.GH_TOKEN, current, draft), { auto });
117
- if (event.action === 'ready_for_review' && result.status === 'draft') await api(`issues/${target.issue}/comments`, 'POST', { body: handoff({ status: 'blocked', stage: 'implement', happened: `PR #${pr.number} remains a draft: ${result.reason}.`, action: 'The PR owner should resolve the stated blocker.', next: 'The workflow rechecks readiness when the relevant checks or PR state change.', link: `[Open PR](${pr.html_url})` }) });
116
+ await reconcileImplementationPr(api, repo, pr, issue, comments, requiredChecks(profile), (current, draft) => setPullRequestDraft(env.GH_TOKEN, current, draft), { auto, beforeDraft: async () => {
117
+ const marker = draftIntentMarker(pr, env.GITHUB_RUN_ID);
118
+ if (!(await commentsFor(api, target.issue)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.user.type === 'Bot' && comment.body?.includes(marker))) await api(`issues/${target.issue}/comments`, 'POST', { body: marker });
119
+ } });
120
+ const fresh = await api(`pulls/${number}`);
121
+ if (fresh.state !== 'open' || !fresh.draft || fresh.head.sha !== pr.head.sha) continue;
122
+ const currentComments = await commentsFor(api, target.issue), notice = pendingDraftIntent(currentComments, fresh);
123
+ if (!notice) continue;
124
+ const currentIssue = await api(`issues/${target.issue}`);
125
+ if (currentIssue.state !== 'open') continue;
126
+ const current = await reconcileImplementationPr(api, repo, fresh, currentIssue, currentComments, requiredChecks(profile), (currentPr, draft) => setPullRequestDraft(env.GH_TOKEN, currentPr, draft));
127
+ const confirmed = await api(`pulls/${number}`);
128
+ if (current.status === 'draft' && current.reason !== 'PR is a draft' && confirmed.state === 'open' && confirmed.draft && confirmed.head.sha === fresh.head.sha && confirmed.head.ref === fresh.head.ref && confirmed.base.ref === fresh.base.ref && confirmed.body === fresh.body && !(await commentsFor(api, target.issue)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.user.type === 'Bot' && comment.body?.includes(notice))) await api(`issues/${target.issue}/comments`, 'POST', { body: handoff({ status: 'blocked', stage: 'implement', happened: `PR #${pr.number} remains a draft: ${current.reason}.`, action: 'The PR owner should resolve the stated blocker.', next: 'The workflow rechecks readiness when the relevant checks or PR state change.', link: `[Open PR](${pr.html_url})`, details: notice }) });
118
129
  }
119
130
  }
120
131
  main().catch(error => { console.error(`::error::${error.message}`); process.exitCode = 1; });
package/src/pr-state.mjs CHANGED
@@ -3,6 +3,23 @@ import { checkCommit } from './completion.mjs';
3
3
  import { validateProductCheckpoint, productProposalProof } from './product-change.mjs';
4
4
 
5
5
  const marker = /<!-- lighthouse-pr-state:(\{[^\n]*\}) -->/;
6
+ const draftIntent = /<!-- lighthouse-pr-draft-intent:([1-9][0-9]*):([a-f0-9]{40}):([1-9][0-9]*) -->/;
7
+
8
+ export const draftIntentMarker = (pr, run) => {
9
+ if (!Number.isSafeInteger(pr?.number) || pr.number < 1 || !/^[a-f0-9]{40}$/.test(pr.head?.sha ?? '') || !/^[1-9][0-9]*$/.test(String(run ?? ''))) throw Error('Invalid draft handoff identity');
10
+ return `<!-- lighthouse-pr-draft-intent:${pr.number}:${pr.head.sha}:${run} -->`;
11
+ };
12
+
13
+ export function pendingDraftIntent(comments, pr) {
14
+ for (const comment of [...comments].reverse()) {
15
+ if (comment.user?.login !== 'github-actions[bot]' || comment.user.type !== 'Bot') continue;
16
+ const match = comment.body?.match(draftIntent);
17
+ if (!match || Number(match[1]) !== pr.number || match[2] !== pr.head.sha) continue;
18
+ const done = `<!-- lighthouse-pr-draft-outcome:${match[1]}:${match[2]}:${match[3]} -->`;
19
+ return comments.some(item => item.user?.login === 'github-actions[bot]' && item.user.type === 'Bot' && item.body?.includes(done)) ? null : done;
20
+ }
21
+ return null;
22
+ }
6
23
 
7
24
  export function isCurrentPullRequestEvent(action, pr) {
8
25
  if (action === 'converted_to_draft') return pr?.state === 'open' && pr.draft === true;
@@ -76,7 +93,7 @@ export async function projectImplementationPrState(api, issue, draft) {
76
93
  if (!current.labels.some(item => item.name === wanted)) await api(`issues/${issue}/labels`, 'POST', { labels: [wanted] });
77
94
  }
78
95
 
79
- export async function reconcileImplementationPr(api, repo, pr, issue, comments, requiredChecks, setDraft, { auto = false } = {}) {
96
+ export async function reconcileImplementationPr(api, repo, pr, issue, comments, requiredChecks, setDraft, { auto = false, beforeDraft = async () => {} } = {}) {
80
97
  const target = checkImplementationPullRequest(pr, repo);
81
98
  if (target.issue !== issue.number || issue.state !== 'open') return { status: 'ignored', reason: 'Issue or PR changed' };
82
99
  const proof = implementationPrProof(comments, pr);
@@ -90,11 +107,12 @@ export async function reconcileImplementationPr(api, repo, pr, issue, comments,
90
107
  return { status: 'ready', reason: 'Candidate and required checks passed' };
91
108
  }
92
109
  if (!pr.draft && !complete) {
110
+ await beforeDraft();
93
111
  try { await setDraft(pr, true); }
94
112
  catch (error) { if (!(await api(`pulls/${pr.number}`)).draft) throw error; }
95
113
  await projectImplementationPrState(api, issue.number, true);
96
114
  return { status: 'draft', reason: reserved ? 'Implementation or owner work is still active' : !proof?.complete ? 'Implementation is incomplete' : 'Required checks have not passed' };
97
115
  }
98
116
  await projectImplementationPrState(api, issue.number, pr.draft);
99
- return { status: pr.draft ? 'draft' : 'ready', reason: pr.draft ? 'PR is a draft' : 'Candidate and required checks passed' };
117
+ return { status: pr.draft ? 'draft' : 'ready', reason: pr.draft && !complete ? reserved ? 'Implementation or owner work is still active' : !proof?.complete ? 'Implementation is incomplete' : 'Required checks have not passed' : pr.draft ? 'PR is a draft' : 'Candidate and required checks passed' };
100
118
  }
@@ -12,7 +12,7 @@ on:
12
12
  required: true
13
13
  default: reconcile
14
14
  type: choice
15
- options: [reconcile, register-pr, ready-child, accept-child, publish-aggregate, ready-aggregate]
15
+ options: [reconcile, register-pr, ready-child, accept-child, publish-aggregate, ready-aggregate, accept-aggregate]
16
16
  child:
17
17
  description: Child issue number for PR registration or acceptance
18
18
  required: false
@@ -70,7 +70,7 @@ jobs:
70
70
  GH_TOKEN: ${{ github.token }}
71
71
  run: node src/packages/sdlc/src/epic-native.mjs
72
72
  - name: Preserve the epic journal
73
- if: always()
73
+ if: always() && inputs.action != 'accept-aggregate'
74
74
  uses: actions/upload-artifact@v7
75
75
  with:
76
76
  name: lighthouse-epic-journal-${{ inputs.issue }}-${{ github.run_attempt }}