@juangcarmona/lighthouse-sdlc 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +6 -1
  2. package/execution-environments.md +23 -0
  3. package/github-actions.md +2 -0
  4. package/meta-commands/ff.md +2 -2
  5. package/meta-commands/hotfix.md +3 -1
  6. package/package.json +8 -3
  7. package/schemas/product-change.schema.json +118 -0
  8. package/skills/implement/SKILL.md +9 -1
  9. package/skills/independent-review/SKILL.md +13 -0
  10. package/skills/product-change/SKILL.md +18 -0
  11. package/src/completion.mjs +5 -3
  12. package/src/contracts.mjs +2 -0
  13. package/src/execution-resources.mjs +52 -0
  14. package/src/host-contract.mjs +11 -3
  15. package/src/implementation-runtime.mjs +37 -13
  16. package/src/implementation.mjs +2 -2
  17. package/src/install-workflows.mjs +17 -5
  18. package/src/integration-evidence.mjs +4 -1
  19. package/src/issue-actions.mjs +125 -18
  20. package/src/managed-command.mjs +82 -0
  21. package/src/opencode-execute.mjs +34 -19
  22. package/src/product-change-actions.mjs +308 -0
  23. package/src/product-change.mjs +277 -0
  24. package/src/proposal-review.mjs +1 -1
  25. package/src/proposal.mjs +4 -4
  26. package/src/sdlc.mjs +1 -1
  27. package/src/stage-dispatch.mjs +11 -3
  28. package/src/unattended-actions.mjs +287 -0
  29. package/src/unattended-runtime.mjs +115 -0
  30. package/src/unattended.mjs +161 -0
  31. package/templates/unattended.json +37 -0
  32. package/unattended.md +40 -0
  33. package/workflows/lighthouse-sdlc-apply-product-change.lock.yml +1838 -0
  34. package/workflows/lighthouse-sdlc-apply-product-change.md +183 -0
  35. package/workflows/lighthouse-sdlc-product-change.lock.yml +1329 -0
  36. package/workflows/lighthouse-sdlc-product-change.md +195 -0
  37. package/workflows/lighthouse-sdlc-router.yml +12 -2
  38. package/workflows/lighthouse-sdlc-unattended.yml +67 -0
@@ -0,0 +1,277 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createRequire } from 'node:module';
3
+ import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { dirname, join, resolve, sep } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { pathToFileURL } from 'node:url';
8
+
9
+ export const PRODUCT_CHANGE_SCHEMA_VERSION = 1;
10
+ export const PRODUCT_CHANGE_WORKER = 'product-change';
11
+ export const PRODUCT_FILE_LIMITS = Object.freeze({ files: 64, fileBytes: 65536, totalBytes: 524288 });
12
+ const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
+ const hashPattern = /^[a-f0-9]{64}$/;
14
+ const revisionPattern = /^[a-f0-9]{40}$/;
15
+ const require = createRequire(import.meta.url);
16
+ const openspec = (cwd, args) => execFileSync('npx', ['--yes', '--package', '@fission-ai/openspec@1.15.0', 'openspec', ...args], { cwd, encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024, env: { ...process.env, OPENSPEC_TELEMETRY: '0', CI: 'true' } });
17
+
18
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
19
+ const exact = (value, fields) => record(value) && Object.keys(value).every(key => fields.includes(key)) && fields.every(key => Object.hasOwn(value, key));
20
+ const sha = value => createHash('sha256').update(value).digest('hex');
21
+ const digest = sha;
22
+ const safePath = value => typeof value === 'string' && !value.includes('\\') && !value.startsWith('/') && !/^[A-Za-z]:/.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..');
23
+
24
+ export function validateProductRequest(value) {
25
+ const errors = [];
26
+ if (!exact(value, ['schemaVersion', 'kind', 'identity', 'entry', 'idea', 'scope', 'createdAt', ...(['changeName'].filter(k => Object.hasOwn(value ?? {}, k))), ...(['feedback'].filter(k => Object.hasOwn(value ?? {}, k))) ])) return ['Product Change request has unsupported or missing fields'];
27
+ if (value.schemaVersion !== 1 || value.kind !== 'request') errors.push('Product Change request identity is unsupported');
28
+ if (!['idea', 'parent-handoff', 'feedback'].includes(value.entry)) errors.push('Product Change request entry is unsupported');
29
+ if (typeof value.idea !== 'string' || !value.idea.trim() || value.idea.length > 12000) errors.push('Product Change request idea must contain 1-12000 characters');
30
+ if (typeof value.scope !== 'string' || !value.scope.trim() || value.scope.length > 12000) errors.push('Product Change request scope must contain 1-12000 characters');
31
+ if (!Number.isFinite(Date.parse(value.createdAt))) errors.push('Product Change request createdAt must be a timestamp');
32
+ if (value.changeName !== undefined && !namePattern.test(value.changeName)) errors.push('Product Change request changeName must be a kebab-case OpenSpec name');
33
+ if (value.feedback !== undefined && (typeof value.feedback !== 'string' || value.feedback.length > 12000)) errors.push('Product Change request feedback exceeds its bound');
34
+ errors.push(...validateProductIdentity(value.identity));
35
+ if (value.entry === 'feedback' && (!value.changeName || !value.feedback?.trim())) errors.push('Product feedback must identify the existing change and contain feedback');
36
+ return errors;
37
+ }
38
+
39
+ export function validateProductChangeOutput(value) {
40
+ const fields = ['schemaVersion', 'outcome', 'entry', 'affectedAreas', 'decisions', ...(['changeName', 'changeId', 'files', 'question', 'proposalDigest', 'baselineDigest', 'providerValidation'].filter(key => Object.hasOwn(value ?? {}, key)))];
41
+ if (!exact(value, fields)) return ['result.output.productChange has unsupported or missing fields'];
42
+ const errors = [];
43
+ if (value.schemaVersion !== 1 || !['aligned', 'candidate', 'blocked'].includes(value.outcome) || !['idea', 'parent-handoff', 'feedback'].includes(value.entry)) errors.push('result.output.productChange identity is unsupported');
44
+ if (!Array.isArray(value.affectedAreas) || value.affectedAreas.length > 32 || value.affectedAreas.some(item => typeof item !== 'string' || !item.trim() || item.length > 256)) errors.push('result.output.productChange affectedAreas is invalid');
45
+ if (!Array.isArray(value.decisions) || value.decisions.length > 32 || value.decisions.some(item => typeof item !== 'string' || !item.trim() || item.length > 1000)) errors.push('result.output.productChange decisions is invalid');
46
+ if (value.question !== undefined && (typeof value.question !== 'string' || value.question.length > 4000)) errors.push('result.output.productChange question exceeds its bound');
47
+ if (value.changeName !== undefined && !namePattern.test(value.changeName)) errors.push('result.output.productChange changeName is invalid');
48
+ if (value.changeId !== undefined && !/^CHG-[A-Z0-9-]+$/.test(value.changeId)) errors.push('result.output.productChange changeId is invalid');
49
+ if (value.proposalDigest !== undefined && !hashPattern.test(value.proposalDigest)) errors.push('result.output.productChange proposalDigest is invalid');
50
+ if (value.baselineDigest !== undefined && !hashPattern.test(value.baselineDigest)) errors.push('result.output.productChange baselineDigest is invalid');
51
+ if (value.outcome === 'candidate') {
52
+ if (!value.changeName || !value.changeId || !value.proposalDigest || !value.baselineDigest || !Array.isArray(value.files)) errors.push('candidate Product Change output needs its change identity, proposal and baseline digests, and files');
53
+ else try {
54
+ const checked = validateProductChangeFiles(value.files, { changeName: value.changeName });
55
+ if (checked.changeId !== value.changeId || checked.digest !== value.proposalDigest) errors.push('candidate Product Change identity or digest does not match its files');
56
+ } catch (error) { errors.push(error.message); }
57
+ if (!exact(value.providerValidation, ['status', 'diagnosticCount']) || value.providerValidation.status !== 'passed' || !Number.isSafeInteger(value.providerValidation.diagnosticCount) || value.providerValidation.diagnosticCount < 0) errors.push('candidate Product Change needs successful provider overlay validation');
58
+ }
59
+ if (value.outcome === 'aligned' && (value.files !== undefined || value.changeName !== undefined || value.changeId !== undefined)) errors.push('aligned Product Change output cannot include a delta');
60
+ if (value.outcome === 'blocked' && (!value.question?.trim() || value.files !== undefined)) errors.push('blocked Product Change output needs one decision question and no candidate files');
61
+ if (value.files !== undefined && value.outcome !== 'candidate') errors.push('only a candidate Product Change can include files');
62
+ return errors;
63
+ }
64
+
65
+ export async function productModelDigest(root) {
66
+ const modelRoot = await productModelDirectory(root);
67
+ const base = resolve(root, modelRoot);
68
+ const files = [];
69
+ async function collect(directory, relative = '') {
70
+ for (const entry of await (await import('node:fs/promises')).readdir(directory, { withFileTypes: true })) {
71
+ const path = relative ? `${relative}/${entry.name}` : entry.name;
72
+ const target = join(directory, entry.name), stat = await lstat(target);
73
+ if (stat.isSymbolicLink() || !stat.isDirectory() && !stat.isFile()) throw Error(`Unsafe accepted product model path: ${path}`);
74
+ if (stat.isDirectory()) await collect(target, path);
75
+ else {
76
+ if (files.length >= 512 || stat.size > 65536) throw Error('Accepted product model exceeds fingerprint bounds');
77
+ files.push({ path, content: await readFile(target) });
78
+ }
79
+ }
80
+ }
81
+ await collect(base);
82
+ return digest(files.sort((a, b) => a.path.localeCompare(b.path)).map(file => `${file.path}\0${sha(file.content)}\n`).join(''));
83
+ }
84
+
85
+ export async function productModelDirectory(root) {
86
+ let productRoot = 'docs/product';
87
+ try {
88
+ const config = await readFile(resolve(root, '.product/config.yaml'), 'utf8');
89
+ const declared = config.match(/^product-root:\s*(.*?)\s*$/m)?.[1]?.replace(/^(?:"(.*)"|'(.*)')$/, (_match, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted);
90
+ if (declared) productRoot = declared;
91
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
92
+ if (!safePath(productRoot) || productRoot.includes('\\') || productRoot.split('/').some(part => part.startsWith('.'))) throw Error('ProductShape product-root is unsafe');
93
+ return `${productRoot}/model`;
94
+ }
95
+
96
+ export function validateProductIdentity(value) {
97
+ if (!exact(value, ['repository', 'issue', 'parentRunId', 'authorizationDigest', 'policyDigest', 'workflowRevision', 'head'])) return ['Product Change identity has unsupported or missing fields'];
98
+ const errors = [];
99
+ if (typeof value.repository !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository)) errors.push('Product Change repository identity is invalid');
100
+ if (!Number.isSafeInteger(value.issue) || value.issue < 1) errors.push('Product Change issue identity is invalid');
101
+ if (typeof value.parentRunId !== 'string' || !value.parentRunId.trim() || value.parentRunId.length > 128) errors.push('Product Change parent run identity is invalid');
102
+ if (!hashPattern.test(value.authorizationDigest ?? '') || !hashPattern.test(value.policyDigest ?? '')) errors.push('Product Change authority and policy digests must be SHA-256');
103
+ if (!revisionPattern.test(value.workflowRevision ?? '') || !revisionPattern.test(value.head ?? '')) errors.push('Product Change revisions must be full commit SHAs');
104
+ return errors;
105
+ }
106
+
107
+ export function validateProductCheckpoint(value) {
108
+ const fields = ['schemaVersion', 'kind', 'identity', 'requestDigest', 'changeName', 'changeId', 'proposalDigest', 'baselineDigest', 'state', 'effectIds'];
109
+ const optional = ['pendingQuestion'];
110
+ if (!exact(value, [...fields, ...optional.filter(key => Object.hasOwn(value ?? {}, key))])) return ['Product Change checkpoint has unsupported or missing fields'];
111
+ const errors = [...validateProductIdentity(value.identity)];
112
+ if (value.schemaVersion !== 1 || value.kind !== 'checkpoint') errors.push('Product Change checkpoint identity is unsupported');
113
+ if (!namePattern.test(value.changeName ?? '') || !/^CHG-[A-Z0-9-]{1,156}$/.test(value.changeId ?? '')) errors.push('Product Change checkpoint identity is invalid');
114
+ for (const field of ['requestDigest', 'proposalDigest', 'baselineDigest']) if (!hashPattern.test(value[field] ?? '')) errors.push(`${field} must be SHA-256`);
115
+ if (!['preparing', 'waiting-product-decision', 'approved', 'applying', 'applied', 'blocked'].includes(value.state)) errors.push('Product Change checkpoint state is unsupported');
116
+ if (!Array.isArray(value.effectIds) || value.effectIds.length > 32 || value.effectIds.some(item => typeof item !== 'string' || !item.trim() || item.length > 256) || new Set(value.effectIds).size !== value.effectIds.length) errors.push('Product Change checkpoint effectIds are invalid');
117
+ if (value.pendingQuestion !== undefined && (typeof value.pendingQuestion !== 'string' || value.pendingQuestion.length > 4000)) errors.push('Product Change checkpoint pendingQuestion exceeds its bound');
118
+ return errors;
119
+ }
120
+
121
+ function parseStatus(path, content) {
122
+ if (!path.endsWith('/product-change/change.md')) return null;
123
+ const frontmatter = content.match(/^---\s*\n([\s\S]*?)\n---(?:\n|$)/);
124
+ if (!frontmatter) throw Error('Product Change manifest needs YAML frontmatter');
125
+ const status = frontmatter[1].match(/^status:\s*([A-Za-z-]+)\s*$/m)?.[1];
126
+ if (!status) throw Error('Product Change manifest needs a status');
127
+ if (!['draft', 'in-review', 'rejected', 'superseded'].includes(status)) throw Error(`Worker output cannot set Product Change status '${status}'`);
128
+ const changeId = frontmatter[1].match(/^id:\s*([A-Z0-9-]+)\s*$/m)?.[1];
129
+ if (!/^CHG-[A-Z0-9-]+$/.test(changeId ?? '')) throw Error('Product Change manifest needs a valid CHG ID');
130
+ return { status, changeId };
131
+ }
132
+
133
+ export function validateProductChangeFiles(files, { changeName, root = 'openspec/changes' } = {}) {
134
+ if (!namePattern.test(changeName ?? '')) throw Error('Product Change name must be lowercase kebab-case');
135
+ if (!Array.isArray(files) || !files.length || files.length > PRODUCT_FILE_LIMITS.files) throw Error('Product Change candidate must contain a bounded non-empty file list');
136
+ const seen = new Set();
137
+ let total = 0, manifest;
138
+ const base = `${root}/${changeName}/`;
139
+ for (const file of files) {
140
+ if (!exact(file, ['path', 'content', ...(['mode'].filter(key => Object.hasOwn(file ?? {}, key))) ])) throw Error('Product Change candidate file has unsupported or missing fields');
141
+ if (!safePath(file.path) || !file.path.startsWith(base)) throw Error(`Product Change file is outside its hosted change: ${file.path}`);
142
+ if (seen.has(file.path)) throw Error(`Duplicate Product Change path: ${file.path}`);
143
+ seen.add(file.path);
144
+ if (file.mode !== undefined && file.mode !== '100644' && file.mode !== '100755') throw Error(`Product Change file mode is not regular: ${file.path}`);
145
+ const relative = file.path.slice(base.length);
146
+ const allowed = relative === '.openspec.yaml' || relative === 'proposal.md' || relative === 'product-change/change.md' || relative === 'product-change/impact.json' || /^product-change\/proposed\/[A-Za-z0-9._/-]+\.(?:md|json|ya?ml)$/.test(relative);
147
+ if (!allowed || relative.split('/').some(part => part.startsWith('.'))) throw Error(`Unsupported Product Change file: ${file.path}`);
148
+ if (file.path.startsWith('docs/product/model/')) throw Error('Product Change candidates cannot write accepted model artifacts');
149
+ if (typeof file.content !== 'string') throw Error(`Product Change file content must be text: ${file.path}`);
150
+ const bytes = Buffer.byteLength(file.content, 'utf8');
151
+ if (bytes > PRODUCT_FILE_LIMITS.fileBytes) throw Error(`Product Change file exceeds its size bound: ${file.path}`);
152
+ total += bytes;
153
+ if (total > PRODUCT_FILE_LIMITS.totalBytes) throw Error('Product Change candidate exceeds its total size bound');
154
+ const parsed = parseStatus(file.path, file.content);
155
+ if (parsed) manifest = parsed;
156
+ }
157
+ if (!seen.has(`${base}.openspec.yaml`) || !manifest) throw Error('Product Change candidate needs its OpenSpec schema pin and provider manifest');
158
+ const spec = files.find(file => file.path === `${base}.openspec.yaml`)?.content;
159
+ if (!/^schema:\s*product-change\s*$/m.test(spec ?? '')) throw Error('Product Change must use the installed product-change schema');
160
+ if (!files.some(file => file.path.startsWith(`${base}product-change/proposed/`))) throw Error('Product Change candidate must include proposed provider artifacts');
161
+ const digestValue = sha([...files].sort((a, b) => a.path.localeCompare(b.path)).map(file => `${file.path}\0${sha(file.content)}\n`).join(''));
162
+ return { files: files.length, bytes: total, ...manifest, digest: digestValue };
163
+ }
164
+
165
+ export async function validateProductWorkspace(root, changeName, bridge) {
166
+ if (!bridge || typeof bridge.validateOpenSpecProductChange !== 'function') throw Error('Pinned ProductShape OpenSpec bridge is unavailable');
167
+ const result = await bridge.validateOpenSpecProductChange(resolve(root), changeName);
168
+ const blocking = result.blocking ?? [];
169
+ if (blocking.length) throw Error(`Product Change overlay validation failed: ${blocking.map(item => item.message ?? item.code).join('; ')}`);
170
+ return result;
171
+ }
172
+
173
+ export async function prepareProductChange(root, changeName, files, bridge) {
174
+ if (!namePattern.test(changeName ?? '')) throw Error('Product Change name must be lowercase kebab-case');
175
+ const temp = await mkdtemp(join(tmpdir(), 'lighthouse-product-workflow-'));
176
+ try {
177
+ const productRoot = dirname(await productModelDirectory(root));
178
+ for (const relative of [productRoot, '.product/config.yaml', 'openspec/config.yaml', 'openspec/schemas/product-change']) {
179
+ const source = join(root, relative), target = join(temp, relative);
180
+ await mkdir(dirname(target), { recursive: true });
181
+ await cp(source, target, { recursive: true, errorOnExist: true, force: false });
182
+ }
183
+ openspec(temp, ['new', 'change', changeName, '--schema', 'product-change', '--json']);
184
+ const rootPath = `openspec/changes/${changeName}`;
185
+ if (!files) {
186
+ const instructions = {};
187
+ for (const artifact of ['intent', 'delta']) {
188
+ const value = JSON.parse(openspec(temp, ['instructions', artifact, '--change', changeName, '--json']));
189
+ instructions[artifact] = { outputPath: value.outputPath, instruction: value.instruction, template: value.template };
190
+ }
191
+ return { provider: '@fission-ai/openspec@1.15.0', schema: 'product-change', root: rootPath, changeName, instructions };
192
+ }
193
+ const summary = validateProductChangeFiles(files, { changeName });
194
+ for (const file of files) {
195
+ const target = join(temp, file.path);
196
+ await mkdir(dirname(target), { recursive: true });
197
+ await writeFile(target, file.content);
198
+ }
199
+ openspec(temp, ['validate', changeName, '--strict', '--no-interactive']);
200
+ const validation = bridge ? await validateProductWorkspace(temp, changeName, bridge) : null;
201
+ const instructions = {};
202
+ for (const artifact of ['intent', 'delta']) {
203
+ const value = JSON.parse(openspec(temp, ['instructions', artifact, '--change', changeName, '--json']));
204
+ instructions[artifact] = { outputPath: value.outputPath, instruction: value.instruction, template: value.template };
205
+ }
206
+ if (bridge && validation) return { ...summary, provider: '@prodshape/integration-openspec', root: rootPath, openSpec: 'passed', diagnostics: validation.diagnostics, instructions, files };
207
+ return { ...summary, root: rootPath, openSpec: 'passed', provider: 'unavailable', instructions, files };
208
+ } finally { await rm(temp, { recursive: true, force: true }); }
209
+ }
210
+
211
+ export async function loadProductBridge({ entry = process.env.PRODSHAPE_INTEGRATION_OPENSPEC_ENTRY } = {}) {
212
+ const spec = entry ?? require.resolve('@prodshape/integration-openspec');
213
+ const bridge = await import(spec.startsWith('/') ? pathToFileURL(spec).href : spec);
214
+ for (const name of ['validateOpenSpecProductChange', 'applyOpenSpecProductChange']) if (typeof bridge[name] !== 'function') throw Error(`Pinned ProductShape bridge does not provide ${name}`);
215
+ return bridge;
216
+ }
217
+
218
+ export function productReviewBinding(input) {
219
+ const fields = ['repository', 'issue', 'pullRequest', 'changeName', 'changeId', 'proposalDigest', 'baselineDigest', 'policyDigest', 'authorityDigest', 'head'];
220
+ if (!exact(input, fields)) throw Error('Product review binding has unsupported or missing fields');
221
+ if (typeof input.repository !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(input.repository) || !Number.isSafeInteger(input.issue) || input.issue < 1 || !Number.isSafeInteger(input.pullRequest) || input.pullRequest < 1) throw Error('Product review target is invalid');
222
+ if (!namePattern.test(input.changeName) || !/^CHG-[A-Z0-9-]+$/.test(input.changeId)) throw Error('Product review change identity is invalid');
223
+ for (const field of ['proposalDigest', 'baselineDigest', 'policyDigest', 'authorityDigest']) if (!hashPattern.test(input[field] ?? '')) throw Error(`Product review ${field} is invalid`);
224
+ if (!revisionPattern.test(input.head ?? '')) throw Error('Product review head must be a full commit SHA');
225
+ const key = digest(JSON.stringify(input));
226
+ return { ...input, key };
227
+ }
228
+
229
+ export function parseProductDecision(text) {
230
+ const match = typeof text === 'string' && text.trim().match(/^\/product-change (approve|request-changes|answer) ([a-z0-9]+(?:-[a-z0-9]+)*) ([a-f0-9]{64})(?:\n([\s\S]{1,4000}))?$/);
231
+ if (!match) return null;
232
+ return { action: match[1], changeName: match[2], reviewKey: match[3], detail: match[4]?.trim() ?? '' };
233
+ }
234
+
235
+ export function verifyProductDecision({ decision, actor, owner, actorType = 'User', source, submitted = true, currentBinding, observedBinding, decisionId }) {
236
+ const errors = [];
237
+ if (!decision || !['approve', 'request-changes', 'answer'].includes(decision.action)) errors.push('Explicit Product Change action is required');
238
+ if (actor !== owner || actorType !== 'User') errors.push('Product Change decision must come from the authenticated repository owner');
239
+ if (!['pull-request-comment', 'pull-request-review', 'issue-comment'].includes(source) || submitted !== true) errors.push('Product Change decision must come from a newly submitted supported GitHub event');
240
+ if (!currentBinding || !observedBinding || currentBinding.key !== observedBinding.key || currentBinding.key !== decision?.reviewKey || currentBinding.changeName !== decision?.changeName) errors.push('Product Change decision does not match the current reviewed binding');
241
+ if (!decisionId || typeof decisionId !== 'string') errors.push('Product Change decision event identity is missing');
242
+ if (errors.length) throw Error(errors.join('; '));
243
+ return { schemaVersion: 1, kind: 'approval', actor, source, reviewKey: currentBinding.key, changeId: currentBinding.changeId, proposalDigest: currentBinding.proposalDigest, baselineDigest: currentBinding.baselineDigest, policyDigest: currentBinding.policyDigest, head: currentBinding.head, decisionId, observedAt: new Date().toISOString() };
244
+ }
245
+
246
+ export function authorizeProductApply({ checkpoint, approval, currentBinding, currentIdentity }) {
247
+ const errors = [...validateProductCheckpoint(checkpoint)];
248
+ if (!approval || approval.kind !== 'approval' || approval.schemaVersion !== 1) errors.push('Authenticated Product Change approval is required');
249
+ if (!currentBinding || !currentIdentity) errors.push('Fresh product authority and review observations are required');
250
+ else {
251
+ const { key: currentKey, ...bindingFields } = currentBinding;
252
+ const fresh = productReviewBinding(bindingFields);
253
+ if (currentKey !== fresh.key) errors.push('Current Product Change review binding is malformed');
254
+ if (fresh.key !== approval?.reviewKey) errors.push('Product Change approval is stale');
255
+ if (currentIdentity.repository !== checkpoint.identity.repository || currentIdentity.issue !== checkpoint.identity.issue || currentIdentity.parentRunId !== checkpoint.identity.parentRunId || currentIdentity.authorizationDigest !== checkpoint.identity.authorizationDigest || currentIdentity.policyDigest !== checkpoint.identity.policyDigest || currentIdentity.workflowRevision !== checkpoint.identity.workflowRevision || currentIdentity.head !== checkpoint.identity.head) errors.push('Product Change parent authority or branch head changed');
256
+ }
257
+ if (checkpoint.changeId !== approval?.changeId || checkpoint.proposalDigest !== approval?.proposalDigest || checkpoint.baselineDigest !== approval?.baselineDigest || checkpoint.state !== 'waiting-product-decision') errors.push('Product Change checkpoint does not match the approved candidate');
258
+ if (errors.length) throw Error(errors.join('; '));
259
+ return { intentId: digest(`${checkpoint.identity.repository}:${checkpoint.identity.issue}:${checkpoint.changeId}:${approval.reviewKey}:apply`), approvalDigest: digest(JSON.stringify(approval)) };
260
+ }
261
+
262
+ export function createProductApplyReceipt({ intent, approval, checkpoint, headAfter, resultingModelDigest, validated, appliedAt = new Date().toISOString() }) {
263
+ if (!intent || intent.intentId !== digest(`${checkpoint.identity.repository}:${checkpoint.identity.issue}:${checkpoint.changeId}:${approval.reviewKey}:apply`) || intent.approvalDigest !== digest(JSON.stringify(approval))) throw Error('Product apply intent does not match its approval');
264
+ if (!revisionPattern.test(headAfter ?? '') || !hashPattern.test(resultingModelDigest ?? '') || validated !== true || !Number.isFinite(Date.parse(appliedAt))) throw Error('Product apply result is not validated');
265
+ return { schemaVersion: 1, kind: 'apply-receipt', intentId: intent.intentId, approvalDigest: intent.approvalDigest, changeId: checkpoint.changeId, headBefore: checkpoint.identity.head, headAfter, proposalDigest: checkpoint.proposalDigest, baselineDigest: checkpoint.baselineDigest, resultingModelDigest, validated: true, appliedAt };
266
+ }
267
+
268
+ export function verifyProductChangePath(root, relativePath) {
269
+ if (!safePath(relativePath)) throw Error('Invalid Product Change path');
270
+ const absolute = resolve(root, relativePath);
271
+ const prefix = `${resolve(root)}${sep}`;
272
+ if (!absolute.startsWith(prefix)) throw Error('Product Change path escapes its workspace');
273
+ return lstat(absolute).then(stat => {
274
+ if (!stat.isFile() || stat.isSymbolicLink()) throw Error(`Product Change path is not a regular file: ${relativePath}`);
275
+ return absolute;
276
+ });
277
+ }
@@ -21,7 +21,7 @@ export function checkProposal(pr, repo, expected) {
21
21
  export function checkImplementationPullRequest(pr, repo, expected) {
22
22
  const match = pr.head?.ref?.match(/^lighthouse\/implement-([1-9][0-9]*)-([a-f0-9]{12})$/);
23
23
  const marker = pr.body?.match(/<!-- lighthouse-implementation:([a-f0-9]{64}) -->/)?.[1];
24
- if (!match || !marker?.startsWith(match[2]) || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo) throw Error('Not an open repository-owned implementation PR');
24
+ if (!match || pr.body?.includes('<!-- lighthouse-product-change:') || !marker?.startsWith(match[2]) || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo) throw Error('Not an open repository-owned implementation PR');
25
25
  if (expected && (pr.head.sha !== expected.head || pr.head.ref !== expected.branch || Number(match[1]) !== expected.issue || pr.base.ref !== expected.base)) throw Error('Implementation changed during review');
26
26
  return { number: pr.number, issue: Number(match[1]), head: pr.head.sha, branch: pr.head.ref, base: pr.base.ref, url: pr.html_url };
27
27
  }
package/src/proposal.mjs CHANGED
@@ -37,18 +37,18 @@ export async function prepareProposal(issue, files, provider = openspec) {
37
37
  const dir = await mkdtemp(join(tmpdir(), 'lighthouse-proposal-'));
38
38
  const name = changeName(issue);
39
39
  try {
40
- provider(dir, ['new', 'change', name, '--schema', 'spec-driven', '--json']);
40
+ await provider(dir, ['new', 'change', name, '--schema', 'spec-driven', '--json']);
41
41
  if (!files) {
42
42
  const instructions = {};
43
43
  for (const artifact of ['proposal', 'specs', 'design', 'tasks']) {
44
- const value = JSON.parse(provider(dir, ['instructions', artifact, '--change', name, '--json']));
44
+ const value = JSON.parse(await provider(dir, ['instructions', artifact, '--change', name, '--json']));
45
45
  instructions[artifact] = { outputPath: value.outputPath, instruction: value.instruction, template: value.template };
46
46
  }
47
47
  return { provider: '@fission-ai/openspec@1.15.0', schema: 'spec-driven', root: proposalRoot(issue), instructions };
48
48
  }
49
49
  for (const file of files) { const path = join(dir, file.path); await mkdir(dirname(path), { recursive: true }); await writeFile(path, file.content); }
50
- provider(dir, ['validate', name, '--strict', '--no-interactive']);
51
- const status = JSON.parse(provider(dir, ['status', '--change', name, '--json']));
50
+ await provider(dir, ['validate', name, '--strict', '--no-interactive']);
51
+ const status = JSON.parse(await provider(dir, ['status', '--change', name, '--json']));
52
52
  if (!status.isPlanningComplete) throw Error('OpenSpec planning artifacts are incomplete');
53
53
  const metadata = `${proposalRoot(issue)}/.openspec.yaml`;
54
54
  return [...files, { path: metadata, content: await readFile(join(dir, metadata), 'utf8') }];
package/src/sdlc.mjs CHANGED
@@ -45,7 +45,7 @@ async function verifyContract() {
45
45
  for (const asset of [contract.assets?.routes, contract.assets?.gates, contract.assets?.stages, contract.assets?.controlPlaneSchema, contract.assets?.triageSchema, contract.assets?.stageResultSchema, contract.assets?.stateSchema, contract.assets?.decompositionSchema, contract.assets?.capabilitiesSchema, contract.assets?.capabilityCatalog, contract.assets?.capabilityCatalogSchema, contract.assets?.browserE2eSchema, contract.assets?.evidenceRecordSchema, contract.assets?.evidenceIndexSchema, contract.assets?.commandAliases, contract.assets?.commandAliasesSchema]) {
46
46
  if (!asset || !(await exists(resolve(packageRoot, asset)))) problems.push(`missing contract asset ${asset ?? '(empty)'}`);
47
47
  }
48
- for (const asset of ['routes.json', 'gates.json', 'stages.json', 'command-aliases.json', 'schemas/routes.schema.json', 'schemas/gates.schema.json', 'schemas/stages.schema.json', 'schemas/control-plane.schema.json', 'schemas/triage.schema.json', 'schemas/stage-result.schema.json', 'schemas/workflow-state.schema.json', 'schemas/decomposition.schema.json', 'schemas/capabilities.schema.json', 'schemas/capability-catalog.schema.json', 'schemas/browser-e2e.schema.json', 'schemas/evidence-record.schema.json', 'schemas/evidence-index.schema.json', 'schemas/command-aliases.schema.json', 'src/aliases.mjs', 'src/browser-e2e.mjs', 'src/evidence.mjs', 'src/capabilities.mjs', 'src/contracts.mjs', 'src/control-plane.mjs', 'src/decompose.mjs', 'src/github.mjs', 'src/memory.mjs', 'src/reconcile.mjs', 'src/routes.mjs', 'src/stacked.mjs', 'src/triage.mjs', 'src/workflow.mjs']) {
48
+ for (const asset of ['routes.json', 'gates.json', 'stages.json', 'command-aliases.json', 'schemas/routes.schema.json', 'schemas/gates.schema.json', 'schemas/stages.schema.json', 'schemas/control-plane.schema.json', 'schemas/triage.schema.json', 'schemas/stage-result.schema.json', 'schemas/product-change.schema.json', 'schemas/workflow-state.schema.json', 'schemas/decomposition.schema.json', 'schemas/capabilities.schema.json', 'schemas/capability-catalog.schema.json', 'schemas/browser-e2e.schema.json', 'schemas/evidence-record.schema.json', 'schemas/evidence-index.schema.json', 'schemas/command-aliases.schema.json', 'src/aliases.mjs', 'src/browser-e2e.mjs', 'src/evidence.mjs', 'src/capabilities.mjs', 'src/contracts.mjs', 'src/control-plane.mjs', 'src/decompose.mjs', 'src/github.mjs', 'src/memory.mjs', 'src/product-change.mjs', 'src/product-change-actions.mjs', 'src/reconcile.mjs', 'src/routes.mjs', 'src/stacked.mjs', 'src/triage.mjs', 'src/workflow.mjs']) {
49
49
  if (!(await exists(resolve(packageRoot, asset)))) problems.push(`missing package asset ${asset}`);
50
50
  }
51
51
  try {
@@ -3,7 +3,7 @@ import { readActionsJson } from './actions-artifact.mjs';
3
3
 
4
4
  const routerPath = '.github/workflows/lighthouse-sdlc-router.yml';
5
5
  export const stages = ['triage', 'refine', 'propose', 'implement', 'integrate', 'review'];
6
- export const workers = [...stages, 'apply-review'];
6
+ export const workers = [...stages, 'apply-review', 'product-change', 'apply-product-change'];
7
7
  export const workerFor = request => request.worker ?? request.stage ?? 'implement';
8
8
  export const stagePath = stage => { if (!workers.includes(stage)) throw Error('Unknown dispatch worker'); return `.github/workflows/lighthouse-sdlc-${stage}.lock.yml`; };
9
9
  export const implementPath = stagePath('implement');
@@ -67,11 +67,19 @@ export async function sendDispatch(api, repository, request, commentId) {
67
67
  if (receipt.id !== request.id || receipt.digest !== digest(JSON.stringify(request))) throw Error('Dispatch request changed');
68
68
  if (receipt.workerRunId) return;
69
69
  // Never retry this POST automatically: its response can be lost after admission.
70
- return api(`actions/workflows/lighthouse-sdlc-${workerFor(request)}.lock.yml/dispatches`, 'POST', { ref: request.ref, inputs: { issue: String(request.issue), 'dispatch-request': String(commentId) } });
70
+ return api(`actions/workflows/lighthouse-sdlc-${workerFor(request)}.lock.yml/dispatches`, 'POST', { ref: request.ref, inputs: { issue: String(request.issue), 'dispatch-request': String(commentId), ...(workerFor(request) === 'apply-product-change' ? { 'pull-request': String(request.productApproval.pullRequest), head: request.productApproval.head } : {}) } });
71
71
  }
72
72
 
73
73
  export function verifyDispatchOrigin(request, receipt, parent, worker, repository, owner, issue) {
74
- if (workerFor(request) !== workerFor(receipt) || request.feedbackKey !== receipt.feedbackKey || workerFor(request) !== request.stage && !(workerFor(request) === 'apply-review' && ['propose', 'implement'].includes(request.stage))) throw Error('Dispatch worker responsibility mismatch');
74
+ const delegatedWorker = workerFor(request) === 'apply-review' && ['propose', 'implement'].includes(request.stage) || workerFor(request) === 'product-change' && ['propose', 'implement'].includes(request.stage);
75
+ const applyProductWorker = workerFor(request) === 'apply-product-change' && ['propose', 'implement'].includes(request.stage);
76
+ if (workerFor(request) !== workerFor(receipt) || request.feedbackKey !== receipt.feedbackKey || workerFor(request) !== request.stage && !delegatedWorker && !applyProductWorker) throw Error('Dispatch worker responsibility mismatch');
77
+ if (workerFor(request) === 'product-change') {
78
+ const idea = request.eventName === 'workflow_dispatch' && request.event?.inputs?.workflow === 'product-change';
79
+ const feedback = request.productFeedback && ['answer', 'request-changes'].includes(request.productFeedback.action) && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.productFeedback.changeName ?? '') && typeof request.productFeedback.text === 'string' && request.productFeedback.text.trim().length > 0 && request.productFeedback.text.length <= 12000 && ['issue_comment', 'pull_request_review'].includes(request.eventName);
80
+ if (!idea && !feedback) throw Error('Product Change worker needs an authorized idea selector or authenticated owner feedback');
81
+ }
82
+ if (workerFor(request) === 'apply-product-change' && (!request.productApproval || request.productApproval.kind !== 'approval' || !hash(request.productApproval.reviewKey))) throw Error('Trusted Product Change apply requires a bound approval');
75
83
  if (JSON.stringify(request.recovery) !== JSON.stringify(receipt.recovery)) throw Error('Recovery receipt identity mismatch');
76
84
  const events = ['issues', 'issue_comment', 'pull_request_review', 'pull_request_review_comment', 'workflow_dispatch', 'workflow_run'];
77
85
  if (request.schemaVersion !== 1 || request.id !== receipt.id || request.repository !== repository || request.issue !== issue || request.stage !== (receipt.stage ?? 'implement') || !stages.includes(request.stage) || !sha(request.sourceRevision) || !sha(request.workflowRevision) || !hash(request.taskDigest) || typeof request.handoff !== 'boolean' || digest(JSON.stringify(request)) !== receipt.digest) throw Error('Dispatch artifact identity mismatch');