@juangcarmona/lighthouse-sdlc 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +15 -3
  2. package/capabilities.json +2 -1
  3. package/epic-delivery.md +35 -0
  4. package/github-actions.md +19 -3
  5. package/host-contract.md +2 -0
  6. package/package.json +4 -1
  7. package/schemas/product-change.schema.json +23 -4
  8. package/schemas/stage-result.schema.json +23 -1
  9. package/skills/product-change/SKILL.md +3 -3
  10. package/skills/refine/SKILL.md +3 -2
  11. package/src/completion-actions.mjs +49 -12
  12. package/src/completion.mjs +1 -1
  13. package/src/contracts.mjs +10 -0
  14. package/src/decompose.mjs +5 -2
  15. package/src/epic-dispatch.mjs +59 -0
  16. package/src/epic-github.mjs +111 -0
  17. package/src/epic-journal.mjs +104 -0
  18. package/src/epic-merge.mjs +91 -0
  19. package/src/epic-native.mjs +465 -0
  20. package/src/epic.mjs +86 -0
  21. package/src/handoff.mjs +97 -0
  22. package/src/host-contract.mjs +22 -6
  23. package/src/implementation.mjs +33 -5
  24. package/src/install-workflows.mjs +1 -1
  25. package/src/issue-actions.mjs +360 -78
  26. package/src/opencode-execute.mjs +20 -0
  27. package/src/pr-state-actions.mjs +21 -7
  28. package/src/pr-state.mjs +18 -1
  29. package/src/product-change-actions.mjs +158 -46
  30. package/src/product-change.mjs +92 -11
  31. package/src/proposal-review.mjs +16 -6
  32. package/src/proposal.mjs +13 -1
  33. package/src/stacked.mjs +14 -3
  34. package/src/stage-dispatch.mjs +66 -19
  35. package/src/stage-transition.mjs +151 -2
  36. package/src/triage.mjs +4 -2
  37. package/src/unattended-actions.mjs +39 -12
  38. package/src/unattended.mjs +1 -1
  39. package/src/worker-maintenance.mjs +15 -3
  40. package/src/worker-recovery.mjs +204 -6
  41. package/workflows/lighthouse-sdlc-apply-product-change.lock.yml +97 -11
  42. package/workflows/lighthouse-sdlc-apply-product-change.md +75 -6
  43. package/workflows/lighthouse-sdlc-apply-review.lock.yml +33 -2
  44. package/workflows/lighthouse-sdlc-apply-review.md +29 -1
  45. package/workflows/lighthouse-sdlc-epic.yml +79 -0
  46. package/workflows/lighthouse-sdlc-implement.lock.yml +38 -2
  47. package/workflows/lighthouse-sdlc-implement.md +33 -2
  48. package/workflows/lighthouse-sdlc-integrate.lock.yml +4 -1
  49. package/workflows/lighthouse-sdlc-integrate.md +3 -0
  50. package/workflows/lighthouse-sdlc-product-change.lock.yml +39 -4
  51. package/workflows/lighthouse-sdlc-product-change.md +39 -6
  52. package/workflows/lighthouse-sdlc-propose.lock.yml +13 -1
  53. package/workflows/lighthouse-sdlc-propose.md +11 -1
  54. package/workflows/lighthouse-sdlc-refine.lock.yml +29 -2
  55. package/workflows/lighthouse-sdlc-refine.md +25 -2
  56. package/workflows/lighthouse-sdlc-review.lock.yml +4 -1
  57. package/workflows/lighthouse-sdlc-review.md +3 -0
  58. package/workflows/lighthouse-sdlc-router.yml +47 -3
  59. package/workflows/lighthouse-sdlc-triage.lock.yml +30 -3
  60. package/workflows/lighthouse-sdlc-triage.md +26 -3
  61. package/workflows/lighthouse-worker-maintenance.yml +70 -1
@@ -0,0 +1,59 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { readActionsJson } from './actions-artifact.mjs';
5
+ import { issueComments, readDispatchReceipt } from './stage-dispatch.mjs';
6
+ import { epicJournalKey } from './epic-github.mjs';
7
+ import { authenticateEpicChild } from './epic-native.mjs';
8
+
9
+ const positive = value => /^[1-9][0-9]*$/.test(String(value));
10
+ export async function verifyEpicTriageOrigin(api, { repository, owner, child, parent, runId }, { readArtifact = readActionsJson, authenticate = authenticateEpicChild } = {}) {
11
+ if (![child, parent, runId].every(positive)) throw Error('Invalid epic triage origin');
12
+ const run = await api(`actions/runs/${runId}`), repo = await api('');
13
+ if (String(run.id) !== String(runId) || run.path !== '.github/workflows/lighthouse-sdlc-epic.yml' || run.event !== 'workflow_dispatch' || run.status !== 'completed' || run.conclusion !== 'success' || run.run_attempt !== 1 || run.actor?.login !== owner || run.triggering_actor?.login !== owner || run.repository?.full_name !== repository || run.head_branch !== repo.default_branch || run.head_sha !== (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha) throw Error('Epic triage origin is not an authenticated current owner execution');
14
+ const journal = await readArtifact(api, repository, run.id, `lighthouse-epic-journal-${parent}-${run.run_attempt}`, 'journal.json');
15
+ if (journal?.schemaVersion !== 1 || journal.repository !== repository || journal.parent !== Number(parent) || journal.revision !== run.head_sha || String(journal.runId) !== String(run.id) || journal.attempt !== run.run_attempt || !journal.entries || typeof journal.entries !== 'object') throw Error('Epic triage journal is invalid');
16
+ const current = await authenticate({ api, repository, owner, child: Number(child), readArtifact });
17
+ if (current.parent !== Number(parent)) throw Error('Epic triage parent changed');
18
+ const matches = Object.values(journal.entries).filter(entry => entry?.receipt?.state === 'linked' && entry.receipt.correlation?.kind === 'child' && entry.receipt.correlation.parent === Number(parent) && entry.receipt.correlation.slice === current.slice && entry.receipt.correlation.planDigest === current.planDigest && entry.receipt.resource?.number === Number(child));
19
+ if (matches.length !== 1 || journal.entries[epicJournalKey(matches[0].receipt.correlation)] !== matches[0]) throw Error('Epic triage is not in the retained parent execution');
20
+ return { run, journal, current };
21
+ }
22
+
23
+ export async function dispatchEpicTriage(api, { repository, owner, runId }, { readArtifact = readActionsJson, authenticate = authenticateEpicChild } = {}) {
24
+ if (!positive(runId)) throw Error('Invalid epic execution');
25
+ const run = await api(`actions/runs/${runId}`), inventory = await api(`actions/runs/${runId}/artifacts?per_page=100`);
26
+ if (!Array.isArray(inventory.artifacts) || inventory.total_count !== inventory.artifacts.length || inventory.total_count > 100) throw Error('Epic artifact inventory is incomplete');
27
+ const names = inventory.artifacts.filter(item => !item.expired).map(item => item.name).filter(name => /^lighthouse-epic-journal-[1-9][0-9]*-1$/.test(name));
28
+ if (names.length !== 1) throw Error('Epic execution journal is missing or ambiguous');
29
+ const parent = Number(names[0].match(/^lighthouse-epic-journal-([1-9][0-9]*)-1$/)[1]);
30
+ const journal = await readArtifact(api, repository, runId, names[0], 'journal.json');
31
+ const entries = Object.values(journal?.entries ?? {}).filter(entry => entry?.receipt?.state === 'linked' && entry.receipt.correlation?.kind === 'child');
32
+ const sent = [];
33
+ for (const entry of entries) {
34
+ const child = entry.receipt.resource?.number;
35
+ const verified = await verifyEpicTriageOrigin(api, { repository, owner, child, parent, runId }, { readArtifact, authenticate });
36
+ const issue = await api(`issues/${child}`);
37
+ if (issue.state !== 'open' || issue.labels.some(label => ['future', 'human-working', 'agent-working', 'needs-info', 'waiting-review', 'triaged', 'ready'].includes(label.name))) continue;
38
+ const comments = await issueComments(api, child);
39
+ const prior = comments.filter(comment => comment.user?.login === 'github-actions[bot]' && comment.user.type === 'Bot' && comment.body?.includes('<!-- lighthouse-dispatch:')).map(comment => readDispatchReceipt(comment, repository, child));
40
+ if (prior.some(receipt => receipt.stage === 'triage')) continue;
41
+ await api('actions/workflows/lighthouse-sdlc-router.yml/dispatches', 'POST', { ref: verified.run.head_branch, inputs: { issue: String(child), stage: 'triage', workflow: 'stage', epic_parent: String(parent), epic_run: String(runId) } });
42
+ sent.push(child);
43
+ }
44
+ return { status: 'dispatched', parent, children: sent };
45
+ }
46
+
47
+ async function main() {
48
+ const env = process.env, repository = env.GITHUB_REPOSITORY, owner = env.GITHUB_REPOSITORY_OWNER;
49
+ const event = JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8'));
50
+ if (env.GITHUB_EVENT_NAME !== 'workflow_run' || event.action !== 'completed' || !positive(event.workflow_run?.id)) throw Error('Epic triage requires a completed workflow event');
51
+ const api = async (path, method = 'GET', body, optional = false) => {
52
+ const response = await fetch(`https://api.github.com/repos/${repository}${path ? `/${path}` : ''}`, { method, headers: { authorization: `Bearer ${env.GH_TOKEN}`, accept: 'application/vnd.github+json', 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) });
53
+ if (optional && response.status === 404) return null;
54
+ if (!response.ok) throw Error(`GitHub ${method} ${path}: ${response.status}`);
55
+ return response.status === 204 ? null : response.json();
56
+ };
57
+ console.log(JSON.stringify(await dispatchEpicTriage(api, { repository, owner, runId: event.workflow_run.id })));
58
+ }
59
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(error => { console.error(error.message); process.exitCode = 1; });
@@ -0,0 +1,111 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { epicPlanDigest } from './epic.mjs';
3
+ import { buildDecompositionPlan } from './decompose.mjs';
4
+
5
+ const positive = value => Number.isSafeInteger(value) && value > 0;
6
+ const prefix = '<!-- lighthouse-epic:';
7
+
8
+ export function epicCorrelation({ repository, parent, slice, planDigest, base, dependencies, kind = 'child' }) {
9
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? '') || !positive(parent) || typeof slice !== 'string' || !slice.trim() || !/^[a-f0-9]{64}$/.test(planDigest ?? '') || base !== `lighthouse/epic-${parent}` || !Array.isArray(dependencies) || dependencies.some(id => typeof id !== 'string' || !id.trim()) || !['child', 'pull-request', 'aggregate'].includes(kind)) throw Error('Invalid epic correlation');
10
+ return { schemaVersion: 1, kind, repository, parent, slice, planDigest, base, dependencies: [...new Set(dependencies)].sort() };
11
+ }
12
+
13
+ export function epicMarker(correlation) {
14
+ const checked = epicCorrelation(correlation);
15
+ if (!isDeepStrictEqual(correlation, checked)) throw Error('Non-canonical epic correlation');
16
+ return `${prefix}${JSON.stringify(checked).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e')} -->`;
17
+ }
18
+
19
+ export const epicJournalKey = correlation => JSON.stringify([correlation.repository, correlation.parent, correlation.planDigest, correlation.kind, correlation.slice]);
20
+
21
+ export function readEpicMarker(body) {
22
+ if (typeof body !== 'string' || body.split(prefix).length !== 2) return null;
23
+ const match = body.match(/<!-- lighthouse-epic:([^\n]*?) -->/);
24
+ if (!match) return null;
25
+ try {
26
+ const value = JSON.parse(match[1]);
27
+ if (value.schemaVersion !== 1 || !isDeepStrictEqual(value, epicCorrelation(value))) return null;
28
+ return value;
29
+ } catch { return null; }
30
+ }
31
+
32
+ export function epicChildBody(parent, slice, correlation) {
33
+ return `Parent issue: #${parent}${slice.scope.length ? `\n\nScope:\n${slice.scope.map(item => `- ${item}`).join('\n')}` : ''}\n\nAcceptance evidence:\n${slice.acceptance.map(item => `- ${item}`).join('\n')}\n\n${epicMarker(correlation)}`;
34
+ }
35
+
36
+ // The credentialed host supplies a durable journal, serialized parent lease,
37
+ // current authorization check and receipt authentication. This adapter grants none.
38
+ export async function reconcileEpicChildren(plan, context, host) {
39
+ plan = buildDecompositionPlan(plan);
40
+ const digest = epicPlanDigest(plan);
41
+ const { repository, parent, base } = context;
42
+ for (const name of ['withLease', 'readReceipt', 'writeReceipt', 'findCandidates', 'authenticate', 'beforeWrite', 'api']) if (typeof host?.[name] !== 'function') throw Error(`Trusted host must provide ${name}`);
43
+ const idNumber = plan.parent.id.match(/^(?:GH-)?([1-9]\d*)$/)?.[1];
44
+ if (plan.parent.number !== undefined && (!positive(plan.parent.number) || idNumber && String(plan.parent.number) !== idNumber) || String(parent) !== String(plan.parent.number ?? idNumber)) throw Error('Parent identity mismatch');
45
+ const expected = new Map(plan.slices.map(slice => [slice.id, epicCorrelation({ repository, parent, slice: slice.id, planDigest: digest, base, dependencies: slice.dependencies })]));
46
+ return host.withLease(`${repository}:${parent}`, async () => {
47
+ const outcomes = [];
48
+ for (const id of plan.order) {
49
+ const correlation = expected.get(id), slice = plan.slices.find(item => item.id === id);
50
+ const key = epicJournalKey(correlation);
51
+ try {
52
+ const unmet = slice.dependencies.filter(dependency => outcomes.find(item => item.id === dependency)?.state !== 'linked');
53
+ if (unmet.length) {
54
+ outcomes.push({ id, state: 'blocked', reason: `Dependencies are not reconciled: ${unmet.join(', ')}` });
55
+ return { schemaVersion: 1, status: 'blocked', children: outcomes, parent: 'open' };
56
+ }
57
+ let receipt = await host.readReceipt(key);
58
+ if (receipt != null && (receipt.schemaVersion !== 1 || !['intent', 'created', 'linked', 'cancelled'].includes(receipt.state))) throw Error('Unsupported or malformed durable receipt');
59
+ const candidates = await host.findCandidates(correlation);
60
+ if (!Array.isArray(candidates) || candidates.length > 1) throw Error('Duplicate or invalid candidate observations');
61
+ let resource = candidates[0];
62
+ if (receipt && !isDeepStrictEqual(receipt.correlation, correlation)) throw Error('Receipt correlation changed');
63
+ if (resource && (!receipt || !positive(resource.id) || !positive(resource.number) || resource.pull_request || !isDeepStrictEqual(readEpicMarker(resource.body), correlation) || await host.authenticate(resource, receipt) !== true)) throw Error('Candidate lacks matching authenticated receipt');
64
+ if (receipt?.state === 'cancelled') { outcomes.push({ id, state: 'cancelled' }); continue; }
65
+ if (!resource && receipt) throw Error('Prior write is missing or uncertain; reconcile before recreating');
66
+ if (resource && (receipt.resource?.id !== resource.id || receipt.resource?.number !== resource.number)) throw Error('Receipt resource identity mismatch');
67
+ if (!resource) {
68
+ const intent = { schemaVersion: 1, state: 'intent', correlation };
69
+ await host.beforeWrite(correlation);
70
+ await host.writeReceipt(key, intent);
71
+ // Persist intent before POST. A lost response must never trigger another POST.
72
+ await host.beforeWrite(correlation);
73
+ resource = await host.api('issues', 'POST', { title: slice.title, body: epicChildBody(parent, slice, correlation) });
74
+ if (!positive(resource?.id) || !positive(resource?.number)) throw Error('Create response lacks native resource identity');
75
+ receipt = { ...intent, state: 'created', resource: { id: resource.id, number: resource.number } };
76
+ await host.writeReceipt(key, receipt);
77
+ }
78
+ const relation = await host.api(`issues/${resource.number}/parent`);
79
+ if (relation && relation.number !== parent) throw Error('Child belongs to another parent');
80
+ if (!relation) {
81
+ await host.beforeWrite(correlation);
82
+ await host.api(`issues/${parent}/sub_issues`, 'POST', { sub_issue_id: resource.id });
83
+ }
84
+ for (const dependency of slice.dependencies) {
85
+ const prerequisite = outcomes.find(item => item.id === dependency);
86
+ if (prerequisite?.state !== 'linked') throw Error(`Dependency ${dependency} is not reconciled`);
87
+ const links = [];
88
+ for (let page = 1; page <= 10; page++) {
89
+ const batch = await host.api(`issues/${resource.number}/dependencies/blocked_by?per_page=100&page=${page}`);
90
+ if (!Array.isArray(batch)) throw Error('Dependency observations unavailable');
91
+ links.push(...batch);
92
+ if (batch.length < 100) break;
93
+ if (page === 10) throw Error('Dependency inventory exceeds reconciliation bounds');
94
+ }
95
+ if (!links.some(item => item.id === prerequisite.resource.id)) {
96
+ await host.beforeWrite(correlation);
97
+ await host.api(`issues/${resource.number}/dependencies/blocked_by`, 'POST', { issue_id: prerequisite.resource.id });
98
+ }
99
+ }
100
+ receipt = { ...receipt, state: 'linked' };
101
+ await host.writeReceipt(key, receipt);
102
+ outcomes.push({ id, state: 'linked', resource: receipt.resource });
103
+ } catch (error) {
104
+ outcomes.push({ id, state: 'uncertain', reason: error.message });
105
+ // A partial failure is a checkpoint, not permission to create downstream work.
106
+ return { schemaVersion: 1, status: 'blocked', children: outcomes, parent: 'open' };
107
+ }
108
+ }
109
+ return { schemaVersion: 1, status: 'reconciled', children: outcomes, parent: 'open' };
110
+ });
111
+ }
@@ -0,0 +1,104 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { issueComments } from './stage-dispatch.mjs';
4
+ import { readActionsJson } from './actions-artifact.mjs';
5
+ import { epicCorrelation, epicJournalKey } from './epic-github.mjs';
6
+
7
+ const marker = '<!-- lighthouse-epic-journal:';
8
+ const positive = value => Number.isSafeInteger(Number(value)) && /^[1-9][0-9]*$/.test(String(value));
9
+ const sha = value => /^[a-f0-9]{40}$/.test(value ?? '');
10
+ const keyFor = epicJournalKey;
11
+ const artifactName = (parent, attempt) => `lighthouse-epic-journal-${parent}-${attempt}`;
12
+
13
+ function checkedReceipt(key, receipt, repository, parent) {
14
+ if (!receipt || receipt.schemaVersion !== 1 || !['intent', 'created', 'linked', 'cancelled', 'merging', 'accepted'].includes(receipt.state) || !isDeepStrictEqual(receipt.correlation, epicCorrelation(receipt.correlation)) || receipt.correlation.repository !== repository || receipt.correlation.parent !== parent || keyFor(receipt.correlation) !== key) throw Error('Invalid epic journal receipt');
15
+ const resource = receipt.resource;
16
+ if (receipt.state === 'intent' && resource !== undefined || ['created', 'linked', 'merging', 'accepted'].includes(receipt.state) && !resource || resource !== undefined && (!positive(resource.id) || !positive(resource.number) || Object.keys(resource).sort().join(',') !== 'id,number')) throw Error('Epic receipt resource is invalid');
17
+ const publication = receipt.publication;
18
+ const needsPublication = receipt.correlation.kind === 'pull-request' && (['created', 'linked', 'merging', 'accepted'].includes(receipt.state) || receipt.state === 'cancelled' && !!resource);
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
+ const merge = receipt.merge, merging = ['merging', 'accepted'].includes(receipt.state);
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(',');
23
+ if (Object.keys(receipt).sort().join(',') !== expected) throw Error('Epic receipt has unexpected fields');
24
+ return receipt;
25
+ }
26
+
27
+ function parseEntry(comment, repository, parent) {
28
+ if (comment.user?.login !== 'github-actions[bot]' || comment.user.type !== 'Bot' || comment.issue_url !== `https://api.github.com/repos/${repository}/issues/${parent}` || !positive(comment.id)) throw Error('Untrusted epic journal comment');
29
+ const matches = [...(comment.body ?? '').matchAll(/<!-- lighthouse-epic-journal:(\{[^\n]*\}) -->/g)];
30
+ if (matches.length !== 1) throw Error('Missing or ambiguous epic journal marker');
31
+ const value = JSON.parse(matches[0][1]);
32
+ if (Object.keys(value).sort().join(',') !== 'attempt,key,parent,receipt,repository,revision,runId,schemaVersion' || value.schemaVersion !== 1 || value.repository !== repository || value.parent !== parent || !positive(value.runId) || !Number.isSafeInteger(value.attempt) || value.attempt < 1 || !sha(value.revision) || typeof value.key !== 'string') throw Error('Invalid epic journal identity');
33
+ checkedReceipt(value.key, value.receipt, repository, parent);
34
+ return value;
35
+ }
36
+
37
+ // The marker identifies a candidate; only the owner-triggered run and its retained artifact authenticate it.
38
+ export async function readEpicResourceReceipt({ api, repository, owner, correlation, sourceRevision, defaultBranch, resource, allowedStates = ['linked'], readArtifact = readActionsJson }) {
39
+ if (!isDeepStrictEqual(correlation, epicCorrelation(correlation)) || correlation.repository !== repository || !sha(sourceRevision) || !defaultBranch || resource && (!positive(resource.id) || !positive(resource.number)) || !Array.isArray(allowedStates) || !allowedStates.length || allowedStates.some(state => !['linked', 'accepted'].includes(state))) throw Error('Invalid epic resource admission');
40
+ const parent = correlation.parent, key = keyFor(correlation);
41
+ const comments = await issueComments(api, parent);
42
+ const candidates = comments.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body?.includes(marker)).map(c => ({ comment: c, entry: parseEntry(c, repository, parent) })).filter(item => item.entry.key === key);
43
+ if (candidates.length !== 1) throw Error('Epic resource journal is missing or ambiguous');
44
+ const { comment, entry } = candidates[0], receipt = entry.receipt;
45
+ if (entry.revision !== sourceRevision || !allowedStates.includes(receipt.state) || !isDeepStrictEqual(receipt.correlation, correlation) || resource && !isDeepStrictEqual(receipt.resource, resource)) throw Error('Epic resource receipt does not match the current identity');
46
+ const origin = await api(`actions/runs/${entry.runId}/attempts/${entry.attempt}`);
47
+ if (String(origin.id) !== String(entry.runId) || origin.run_attempt !== entry.attempt || origin.path !== '.github/workflows/lighthouse-sdlc-epic.yml' || origin.event !== 'workflow_dispatch' || origin.status !== 'completed' || origin.actor?.login !== owner || origin.triggering_actor?.login !== owner || origin.head_sha !== sourceRevision || origin.head_branch !== defaultBranch || origin.repository?.full_name !== repository) throw Error('Epic resource journal origin is not trusted');
48
+ const stored = await readArtifact(api, repository, entry.runId, artifactName(parent, entry.attempt), 'journal.json');
49
+ if (!stored || stored.schemaVersion !== 1 || stored.repository !== repository || stored.parent !== parent || stored.revision !== sourceRevision || String(stored.runId) !== String(entry.runId) || stored.attempt !== entry.attempt || !isDeepStrictEqual(stored.entries?.[key], { commentId: comment.id, receipt })) throw Error('Epic resource journal artifact is missing or differs from its comment');
50
+ return receipt;
51
+ }
52
+
53
+ export async function readEpicChildReceipt(input) {
54
+ if (input.correlation?.kind !== 'child' || !input.resource) throw Error('Invalid epic child admission');
55
+ return readEpicResourceReceipt(input);
56
+ }
57
+
58
+ export async function createEpicJournal({ api, repository, owner, parent, sourceRevision, defaultBranch, run, path, authorize, readArtifact = readActionsJson }) {
59
+ 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
+ parent = Number(parent);
61
+ const journal = { schemaVersion: 1, repository, parent, revision: sourceRevision, runId: String(run.id), attempt: run.run_attempt, entries: {} };
62
+ const loaded = new Map();
63
+ const save = () => writeFile(path, JSON.stringify(journal));
64
+ await save();
65
+ async function readReceipt(key) {
66
+ const comments = await issueComments(api, parent);
67
+ const candidates = comments.filter(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body?.includes(marker)).map(c => ({ comment: c, entry: parseEntry(c, repository, parent) })).filter(item => item.entry.key === key);
68
+ if (candidates.length > 1) throw Error('Duplicate epic journal receipts');
69
+ if (!candidates.length) { loaded.set(key, null); return null; }
70
+ const { comment, entry } = candidates[0];
71
+ if (entry.revision !== sourceRevision) throw Error('Epic journal source changed');
72
+ let stored;
73
+ if (entry.runId === String(run.id) && entry.attempt === run.run_attempt) stored = journal;
74
+ else {
75
+ const origin = await api(`actions/runs/${entry.runId}/attempts/${entry.attempt}`);
76
+ if (String(origin.id) !== String(entry.runId) || origin.run_attempt !== entry.attempt || origin.path !== run.path || origin.event !== 'workflow_dispatch' || origin.status !== 'completed' || origin.actor?.login !== owner || origin.triggering_actor?.login !== owner || origin.head_sha !== sourceRevision || origin.head_branch !== defaultBranch || origin.repository?.full_name !== repository) throw Error('Epic journal origin is not trusted');
77
+ stored = await readArtifact(api, repository, entry.runId, artifactName(parent, entry.attempt), 'journal.json');
78
+ }
79
+ if (!stored || stored.schemaVersion !== 1 || stored.repository !== repository || stored.parent !== parent || stored.revision !== sourceRevision || String(stored.runId) !== String(entry.runId) || stored.attempt !== entry.attempt || !isDeepStrictEqual(stored.entries?.[key], { commentId: comment.id, receipt: entry.receipt })) throw Error('Epic journal artifact is missing or differs from its comment');
80
+ loaded.set(key, { comment, receipt: entry.receipt });
81
+ return entry.receipt;
82
+ }
83
+ async function writeReceipt(key, receipt) {
84
+ checkedReceipt(key, receipt, repository, parent);
85
+ if (!loaded.has(key)) throw Error('Epic journal must be read before writing');
86
+ const previous = loaded.get(key);
87
+ if (previous && (!isDeepStrictEqual(previous.receipt.correlation, receipt.correlation) || previous.receipt.resource && !isDeepStrictEqual(previous.receipt.resource, receipt.resource) || !({ intent: ['created', 'cancelled'], created: ['created', 'linked', 'cancelled'], linked: ['linked', 'merging', 'cancelled'], merging: ['merging', 'accepted'], accepted: ['accepted'], cancelled: ['cancelled'] })[previous.receipt.state].includes(receipt.state))) throw Error('Epic journal transition is invalid');
88
+ if (previous?.receipt.merge) {
89
+ const { commit: ignored, ...identity } = receipt.merge ?? {};
90
+ const { commit: previousCommit, ...prior } = previous.receipt.merge;
91
+ if (!isDeepStrictEqual(identity, prior) || previousCommit && previousCommit !== receipt.merge?.commit || !isDeepStrictEqual(previous.receipt.publication, receipt.publication)) throw Error('Epic merge identity changed');
92
+ }
93
+ if (!previous && receipt.state !== 'intent') throw Error('Epic journal must start with intent');
94
+ if (await authorize() !== true) throw Error('Epic journal authorization changed');
95
+ const entry = { schemaVersion: 1, repository, parent, revision: sourceRevision, runId: String(run.id), attempt: run.run_attempt, key, receipt };
96
+ const body = `${marker}${JSON.stringify(entry).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e')} -->`;
97
+ const comment = previous ? await api(`issues/comments/${previous.comment.id}`, 'PATCH', { body }) : await api(`issues/${parent}/comments`, 'POST', { body });
98
+ if (!isDeepStrictEqual(parseEntry(comment, repository, parent), entry)) throw Error('Epic journal write could not be confirmed');
99
+ journal.entries[key] = { commentId: comment.id, receipt };
100
+ await save();
101
+ loaded.set(key, { comment, receipt });
102
+ }
103
+ return { readReceipt, writeReceipt };
104
+ }
@@ -0,0 +1,91 @@
1
+ import { checkImplementationPullRequest } from './proposal-review.mjs';
2
+ import { checkCommit, list, loadProfile, requiredPolicy } from './completion.mjs';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+ import { epicCorrelation, epicJournalKey } from './epic-github.mjs';
5
+
6
+ const sha = value => /^[a-f0-9]{40}$/.test(value ?? '');
7
+
8
+ // The caller authenticates the owner's decision and holds the parent lease.
9
+ export async function observeEpicMerge(api, { repository, owner, child, epic, decision }, { authenticateChild, authenticatePullRequest, authorizeDecision, loadPolicy = async () => {
10
+ const profile = await loadProfile(api, repository, epic.sourceRevision);
11
+ return requiredPolicy(api, epic.base, profile);
12
+ } }) {
13
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? '') || typeof owner !== 'string' || !owner || !Number.isSafeInteger(child) || child < 1 || !Number.isSafeInteger(epic?.parent) || epic.parent < 1 || epic.parent === child || epic.base !== `lighthouse/epic-${epic.parent}` || typeof epic.slice !== 'string' || !epic.slice.trim() || !/^[a-f0-9]{64}$/.test(epic.planDigest ?? '') || !sha(epic.sourceRevision) || !Number.isSafeInteger(decision?.pr) || decision.pr < 1 || !Number.isSafeInteger(decision.review) || decision.review < 1 || !sha(decision.head) || !sha(decision.base) || typeof authenticateChild !== 'function' || typeof authenticatePullRequest !== 'function' || typeof authorizeDecision !== 'function') throw Error('Invalid epic merge decision');
14
+ if (await authorizeDecision(decision) !== true) throw Error('Epic merge decision is not authorized');
15
+ const current = await authenticateChild();
16
+ if (current.parent !== epic.parent || current.slice !== epic.slice || current.planDigest !== epic.planDigest || current.base !== epic.base || current.sourceRevision !== epic.sourceRevision) throw Error('Epic child authority changed');
17
+ const branch = await api(`git/ref/heads/${epic.base}`);
18
+ if (branch.ref !== `refs/heads/${epic.base}` || branch.object?.sha !== decision.base) throw Error('Epic base changed before merge');
19
+ const pr = await api(`pulls/${decision.pr}`);
20
+ const target = checkImplementationPullRequest(pr, repository);
21
+ if (target.issue !== child || target.base !== epic.base || target.head !== decision.head || pr.draft || pr.mergeable !== true || !sha(pr.merge_commit_sha)) throw Error('Epic child PR is not eligible for the approved head and base');
22
+ if (await authenticatePullRequest(pr, target) !== true) throw Error('Epic child PR lacks an authenticated receipt');
23
+ const merge = await api(`git/commits/${pr.merge_commit_sha}`);
24
+ if (!Array.isArray(merge.parents) || merge.parents.length !== 2 || merge.parents[0].sha !== decision.base || merge.parents[1].sha !== decision.head) throw Error('GitHub test merge does not bind the approved head and current base');
25
+ const reviews = await list(api, `pulls/${decision.pr}/reviews`);
26
+ 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];
27
+ if (latest?.id !== decision.review || latest.state !== 'APPROVED' || latest.commit_id !== decision.head) throw Error('Current owner review does not approve this child head');
28
+ const policy = await loadPolicy();
29
+ if (!Array.isArray(policy?.checks) || !policy.checks.length) throw Error('Epic merge has no enforced checks');
30
+ const checks = await checkCommit(api, pr.merge_commit_sha, policy.checks);
31
+ if (checks.some(check => check.status !== 'passed')) throw Error('Epic merge checks are missing, pending or failed');
32
+ if (await authorizeDecision(decision) !== true) throw Error('Epic merge decision was revoked');
33
+ const childAgain = await authenticateChild();
34
+ if (childAgain.parent !== current.parent || childAgain.slice !== current.slice || childAgain.planDigest !== current.planDigest || childAgain.base !== current.base || childAgain.sourceRevision !== current.sourceRevision) throw Error('Epic child authority changed');
35
+ const fresh = await api(`pulls/${decision.pr}`), freshBranch = await api(`git/ref/heads/${epic.base}`);
36
+ const freshTarget = checkImplementationPullRequest(fresh, repository, target);
37
+ const freshReviews = await list(api, `pulls/${decision.pr}/reviews`);
38
+ const freshReview = freshReviews.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];
39
+ if (await authenticatePullRequest(fresh, freshTarget) !== true || fresh.mergeable !== true || fresh.merge_commit_sha !== pr.merge_commit_sha || fresh.draft || freshBranch.object?.sha !== decision.base || freshReview?.id !== decision.review || freshReview.state !== 'APPROVED' || freshReview.commit_id !== decision.head) throw Error('Epic merge evidence changed while checking');
40
+ return { child, parent: epic.parent, slice: epic.slice, planDigest: epic.planDigest, pr: target.number, head: decision.head, base: decision.base, mergeCandidate: pr.merge_commit_sha, review: latest.id, checks };
41
+ }
42
+
43
+ // The caller supplies an authenticated journal, owner decision and the parent lease.
44
+ export async function mergeEpicChild(api, { correlation, resource }, { journal, observe, authorize, verifyChecks }) {
45
+ if (correlation?.kind !== 'pull-request' || !isDeepStrictEqual(correlation, epicCorrelation(correlation)) || !Number.isSafeInteger(resource?.id) || resource.id < 1 || !Number.isSafeInteger(resource.number) || resource.number < 1 || typeof journal?.readReceipt !== 'function' || typeof journal?.writeReceipt !== 'function' || typeof observe !== 'function' || typeof authorize !== 'function' || typeof verifyChecks !== 'function') throw Error('Invalid epic merge context');
46
+ const key = epicJournalKey(correlation), receipt = await journal.readReceipt(key);
47
+ if (!receipt || !isDeepStrictEqual(receipt.correlation, correlation) || receipt.resource?.id !== resource.id || receipt.resource?.number !== resource.number || !['linked', 'merging', 'accepted'].includes(receipt.state)) throw Error('Epic PR receipt is missing or changed');
48
+ const confirm = async binding => {
49
+ const pr = await api(`pulls/${resource.number}`);
50
+ if (pr.id !== resource.id || pr.number !== resource.number || !pr.merged || pr.head?.sha !== binding.head || pr.base?.ref !== correlation.base || pr.head.repo?.full_name !== correlation.repository || pr.base.repo?.full_name !== correlation.repository || !sha(pr.merge_commit_sha) || pr.merged_by?.login !== 'github-actions[bot]' || pr.merged_by.type !== 'Bot') throw Error('Epic merge outcome is unconfirmed');
51
+ const actual = await api(`git/commits/${pr.merge_commit_sha}`), candidate = await api(`git/commits/${binding.candidate}`);
52
+ if (!Array.isArray(actual.parents) || actual.parents.length !== 1 || actual.parents[0].sha !== binding.base || !sha(actual.tree?.sha) || actual.tree.sha !== candidate.tree?.sha) throw Error('Epic merge commit does not match the approved base and candidate');
53
+ return pr.merge_commit_sha;
54
+ };
55
+ if (receipt.state === 'accepted') {
56
+ if (await authorize() !== true || await verifyChecks(receipt.merge) !== true) throw Error('Accepted epic merge authority or checks changed');
57
+ if (await confirm(receipt.merge) !== receipt.merge.commit) throw Error('Accepted epic merge changed');
58
+ return { status: 'accepted', commit: receipt.merge.commit, recovered: true };
59
+ }
60
+ if (receipt.state === 'merging') {
61
+ if (await authorize() !== true) throw Error('Epic merge reconciliation is not authorized');
62
+ const commit = await confirm(receipt.merge);
63
+ if (await verifyChecks(receipt.merge) !== true || await authorize() !== true) throw Error('Epic merge authority or checks are no longer valid');
64
+ await journal.writeReceipt(key, { ...receipt, state: 'accepted', merge: { ...receipt.merge, commit } });
65
+ return { status: 'accepted', commit, recovered: true };
66
+ }
67
+ const initial = await observe();
68
+ if (initial.parent !== correlation.parent || initial.slice !== correlation.slice || initial.planDigest !== correlation.planDigest || initial.pr !== resource.number || !sha(initial.head) || initial.head !== receipt.publication?.head || !sha(initial.base) || !sha(initial.mergeCandidate) || !Number.isSafeInteger(initial.review) || initial.review < 1) throw Error('Epic merge observation does not match the receipt');
69
+ const merge = { head: initial.head, base: initial.base, candidate: initial.mergeCandidate, review: initial.review };
70
+ const intent = { ...receipt, state: 'merging', merge };
71
+ if (await authorize() !== true) throw Error('Epic merge is not authorized');
72
+ await journal.writeReceipt(key, intent);
73
+ const fresh = await observe();
74
+ if (!isDeepStrictEqual(fresh, initial)) throw Error('Epic merge evidence changed before publication');
75
+ if (await authorize() !== true) throw Error('Epic merge authorization changed');
76
+ let response;
77
+ try { response = await api(`pulls/${resource.number}/merge`, 'PUT', { sha: merge.head, merge_method: 'squash' }); }
78
+ catch (error) {
79
+ const commit = await confirm(merge).catch(() => null);
80
+ if (!commit) throw error;
81
+ if (await verifyChecks(merge) !== true || await authorize() !== true) throw Error('Epic merge authority or checks are no longer valid');
82
+ await journal.writeReceipt(key, { ...intent, state: 'accepted', merge: { ...merge, commit } });
83
+ return { status: 'accepted', commit, recovered: true };
84
+ }
85
+ if (!response?.merged || !sha(response.sha)) throw Error('Epic merge response is unconfirmed');
86
+ const commit = await confirm(merge);
87
+ if (commit !== response.sha) throw Error('Epic merge response does not match GitHub state');
88
+ if (await verifyChecks(merge) !== true || await authorize() !== true) throw Error('Epic merge authority or checks are no longer valid');
89
+ await journal.writeReceipt(key, { ...intent, state: 'accepted', merge: { ...merge, commit } });
90
+ return { status: 'accepted', commit, recovered: false };
91
+ }