@juangcarmona/lighthouse-sdlc 0.15.2 → 0.16.1

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 (49) hide show
  1. package/README.md +6 -1
  2. package/execution-environments.md +2 -0
  3. package/meta-commands/ff.md +2 -2
  4. package/meta-commands/hotfix.md +3 -1
  5. package/package.json +8 -3
  6. package/schemas/product-change.schema.json +118 -0
  7. package/skills/independent-review/SKILL.md +13 -0
  8. package/skills/product-change/SKILL.md +18 -0
  9. package/src/completion.mjs +5 -3
  10. package/src/contracts.mjs +2 -0
  11. package/src/host-contract.mjs +11 -3
  12. package/src/implementation-runtime.mjs +14 -5
  13. package/src/implementation.mjs +2 -2
  14. package/src/install-workflows.mjs +17 -5
  15. package/src/integration-evidence.mjs +4 -1
  16. package/src/issue-actions.mjs +124 -17
  17. package/src/opencode-execute.mjs +7 -7
  18. package/src/product-change-actions.mjs +308 -0
  19. package/src/product-change.mjs +277 -0
  20. package/src/proposal-review.mjs +1 -1
  21. package/src/proposal.mjs +4 -4
  22. package/src/sdlc.mjs +1 -1
  23. package/src/stage-dispatch.mjs +11 -3
  24. package/src/unattended-actions.mjs +287 -0
  25. package/src/unattended-runtime.mjs +115 -0
  26. package/src/unattended.mjs +161 -0
  27. package/templates/unattended.json +37 -0
  28. package/unattended.md +40 -0
  29. package/workflows/lighthouse-sdlc-apply-product-change.lock.yml +1838 -0
  30. package/workflows/lighthouse-sdlc-apply-product-change.md +183 -0
  31. package/workflows/lighthouse-sdlc-apply-review.lock.yml +4 -4
  32. package/workflows/lighthouse-sdlc-apply-review.md +2 -2
  33. package/workflows/lighthouse-sdlc-implement.lock.yml +4 -4
  34. package/workflows/lighthouse-sdlc-implement.md +2 -2
  35. package/workflows/lighthouse-sdlc-integrate.lock.yml +4 -4
  36. package/workflows/lighthouse-sdlc-integrate.md +2 -2
  37. package/workflows/lighthouse-sdlc-product-change.lock.yml +1329 -0
  38. package/workflows/lighthouse-sdlc-product-change.md +195 -0
  39. package/workflows/lighthouse-sdlc-propose.lock.yml +4 -4
  40. package/workflows/lighthouse-sdlc-propose.md +2 -2
  41. package/workflows/lighthouse-sdlc-refine.lock.yml +4 -4
  42. package/workflows/lighthouse-sdlc-refine.md +2 -2
  43. package/workflows/lighthouse-sdlc-review.lock.yml +4 -4
  44. package/workflows/lighthouse-sdlc-review.md +2 -2
  45. package/workflows/lighthouse-sdlc-router.yml +12 -2
  46. package/workflows/lighthouse-sdlc-triage.lock.yml +4 -4
  47. package/workflows/lighthouse-sdlc-triage.md +2 -2
  48. package/workflows/lighthouse-sdlc-unattended.yml +67 -0
  49. package/workflows/shared/lighthouse-opencode.md +1 -1
@@ -0,0 +1,161 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { withinScope } from './implementation.mjs';
3
+ import { validateStageCompletion } from './contracts.mjs';
4
+
5
+ export const unattendedDigest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
6
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ const boundedPath = path => typeof path === 'string' && path !== '*' && !/[?*\[\]{}]/.test(path) && withinScope(path.replace(/\/$/, ''), { paths: ['*'] }) && !path.startsWith('.github') && !/^docs\/product(?:\/|$)/.test(path) && !/^openspec(?:\/|$)/.test(path);
8
+ const contains = (prefix, path) => prefix.endsWith('/') ? path.startsWith(prefix) : prefix === path;
9
+ const strings = (list, max = 40) => Array.isArray(list) && list.length > 0 && list.length <= max && list.every(boundedPath) && new Set(list).size === list.length;
10
+ const command = value => Array.isArray(value) && value.length > 0 && value.length <= 30 && value.every(arg => typeof arg === 'string' && arg.length > 0 && arg.length <= 1000 && !/[\0\r\n]/.test(arg));
11
+
12
+ export function validateUnattendedPolicy(policy, route) {
13
+ if (!record(policy) || Object.keys(policy).some(k => !['schemaVersion', 'enabled', 'routes', 'paths', 'reviewerProfile', 'allowFivePoint', 'merge', 'maxCorrections', 'timeoutSeconds', 'hotfix'].includes(k)) ||
14
+ policy.schemaVersion !== 1 || policy.enabled !== true || !['ff', 'hotfix'].includes(route) ||
15
+ !Array.isArray(policy.routes) || !policy.routes.includes(route) || policy.routes.some(r => !['ff', 'hotfix'].includes(r)) ||
16
+ !strings(policy.paths) || !['plan', 'build', 'fast'].includes(policy.reviewerProfile) ||
17
+ typeof policy.allowFivePoint !== 'boolean' || typeof policy.merge !== 'boolean' ||
18
+ !Number.isInteger(policy.maxCorrections) || policy.maxCorrections < 0 || policy.maxCorrections > 2 ||
19
+ !Number.isInteger(policy.timeoutSeconds) || policy.timeoutSeconds < 60 || policy.timeoutSeconds > 7200) throw Error('Invalid or disabled bounded unattended policy');
20
+ if (route === 'hotfix') {
21
+ const hotfix = policy.hotfix;
22
+ if (!record(hotfix) || Object.keys(hotfix).sort().join(',') !== 'failurePattern,modules,regressionCommand,setupCommands,testPaths' || !strings(hotfix.testPaths) ||
23
+ !command(hotfix.regressionCommand) || !Array.isArray(hotfix.setupCommands) || hotfix.setupCommands.length > 8 || hotfix.setupCommands.some(c => !command(c)) || !Array.isArray(hotfix.failurePattern) || !hotfix.failurePattern.length ||
24
+ hotfix.failurePattern.length > 8 || hotfix.failurePattern.some(s => typeof s !== 'string' || !s.trim() || s.length > 200) ||
25
+ !record(hotfix.modules) || Object.keys(hotfix.modules).length === 0 || Object.keys(hotfix.modules).length > 40 ||
26
+ Object.entries(hotfix.modules).some(([name, paths]) => !/^[a-z][a-z0-9-]{0,63}$/.test(name) || !strings(paths))) throw Error('Hotfix needs declared test paths and module boundaries');
27
+ }
28
+ return policy;
29
+ }
30
+
31
+ export function admitUnattended(assessment, policy, route) {
32
+ validateUnattendedPolicy(policy, route);
33
+ if (!record(assessment) || assessment.risk !== 'low' || assessment.reversible !== true || assessment.dependenciesResolved !== true ||
34
+ assessment.scopeKnown !== true || assessment.productChangeNeeded !== false || assessment.humanDecisionRequired !== false ||
35
+ typeof assessment.reason !== 'string' || !assessment.reason.trim() || assessment.reason.length > 4000) throw Error('Issue needs a human decision or lacks low-risk eligibility evidence');
36
+ if (route === 'ff' && ![1, 2, 3, ...(policy.allowFivePoint ? [5] : [])].includes(assessment.points)) throw Error('Fast-forward point estimate is outside the delegated limit');
37
+ if (route === 'hotfix' && (assessment.confirmedDefect !== true || typeof assessment.rollback !== 'string' || !assessment.rollback.trim())) throw Error('Hotfix needs confirmed defect and rollback context');
38
+ return true;
39
+ }
40
+
41
+ export function hotfixScope(files, policy) {
42
+ validateUnattendedPolicy(policy, 'hotfix');
43
+ if (!Array.isArray(files) || !files.length || files.length > 10 || new Set(files.map(f => f.path)).size !== files.length) throw Error('Hotfix exceeds 10 changed files or has an invalid inventory');
44
+ let productionLines = 0;
45
+ const modules = new Set(), tests = [];
46
+ for (const file of files) {
47
+ if (!boundedPath(file.path) || !policy.paths.some(p => contains(p, file.path)) || file.binary ||
48
+ !Number.isSafeInteger(file.additions) || file.additions < 0 || !Number.isSafeInteger(file.deletions) || file.deletions < 0) throw Error('Hotfix has out-of-scope or unassessed binary/generated changes');
49
+ const owners = Object.entries(policy.hotfix.modules).filter(([, paths]) => paths.some(p => contains(p, file.path)));
50
+ if (owners.length !== 1) throw Error('Hotfix file needs exactly one declared module');
51
+ modules.add(owners[0][0]);
52
+ if (policy.hotfix.testPaths.some(p => contains(p, file.path))) tests.push(file.path);
53
+ else productionLines += file.additions + file.deletions;
54
+ }
55
+ if (productionLines > 100 || modules.size > 2) throw Error('Hotfix exceeds 100 production-code lines or two modules');
56
+ return { productionLines, files: files.length, modules: [...modules].sort(), tests };
57
+ }
58
+
59
+ export function regressionPlan(value) {
60
+ if (!record(value) || !command(value.command) || typeof value.assertion !== 'string' || !value.assertion.trim() ||
61
+ value.assertion.length > 1000 || !Number.isInteger(value.estimatedProductionLines) || value.estimatedProductionLines < 0 || value.estimatedProductionLines > 100 || typeof value.rootCause !== 'string' || !value.rootCause.trim() ||
62
+ !Array.isArray(value.plannedFiles) || !value.plannedFiles.length || value.plannedFiles.length > 10 || value.plannedFiles.some(p => !boundedPath(p))) throw Error('Hotfix needs bounded regression command, assertion, root cause and planned files');
63
+ return value;
64
+ }
65
+
66
+ export function assertRegression(receipt, expected, color) {
67
+ const check = receipt?.checks?.find(c => JSON.stringify(c.command) === JSON.stringify(expected.command));
68
+ if (!check || !Number.isInteger(check.exitCode) || check.execution?.status !== (color === 'red' ? 'failed' : 'passed') ||
69
+ check.execution.signal || !Number.isInteger(check.execution.exitCode) || check.execution.exitCode !== check.exitCode ||
70
+ (color === 'red' ? check.exitCode <= 0 || check.exitCode >= 124 : check.exitCode !== 0)) throw Error('Regression did not demonstrate ' + color.toUpperCase());
71
+ if (color === 'red' && (typeof check.log !== 'string' || !check.log.includes(expected.assertion))) throw Error('RED did not contain the identified behavioral assertion');
72
+ return check;
73
+ }
74
+
75
+ export function assertIndependentReview(result, binding, producer, reviewer) {
76
+ if (!producer?.model || !reviewer?.model || producer.model === reviewer.model || !reviewer.runId || reviewer.runId === producer.runId) throw Error('Independent review requires a separate execution and distinct resolved model');
77
+ const errors = validateStageCompletion(result, { stage: 'integrate', runId: reviewer.runId });
78
+ const review = result?.output?.review;
79
+ if (errors.length || !record(review) || review.binding !== binding || review.verdict !== 'accept' ||
80
+ !Array.isArray(review.findings) || review.findings.length !== 0) throw Error('Independent review is blocked, invalid or stale');
81
+ return true;
82
+ }
83
+
84
+ export async function verifyUnattendedIntegration(api, authorization, run, binding) {
85
+ const delegation = authorization?.unattended;
86
+ if (!delegation || delegation.owner !== binding.repository.split('/')[0] ||
87
+ run.path !== '.github/workflows/lighthouse-sdlc-unattended.yml' || run.event !== 'workflow_dispatch' ||
88
+ run.actor?.login !== delegation.owner || run.triggering_actor?.login !== delegation.owner ||
89
+ run.head_sha !== binding.base) return false;
90
+ const file = await api('contents/.lighthouse/unattended.json?ref=' + binding.base);
91
+ const policy = validateUnattendedPolicy(JSON.parse(Buffer.from(file.content, 'base64').toString()), delegation.route);
92
+ const review = delegation.review;
93
+ return policy.merge === true && unattendedDigest(policy) === delegation.policyDigest &&
94
+ review?.execution?.status === 'passed' && review.result?.stage === 'integrate' &&
95
+ review.result.status === 'passed' && review.result.output?.review?.binding === review.binding &&
96
+ review.result.output.review.verdict === 'accept' && review.result.output.review.findings?.length === 0;
97
+ }
98
+
99
+ // All effects are trusted host callbacks; the model cannot supply authorization or receipts.
100
+ export async function runUnattended({ route, policy, host, signal }) {
101
+ validateUnattendedPolicy(policy, route);
102
+ const started = Date.now(), deadline = started + policy.timeoutSeconds * 1000;
103
+ const check = async () => {
104
+ if (signal?.aborted || Date.now() >= deadline) throw Error('Unattended execution stopped or exceeded its deadline');
105
+ await host.recheck();
106
+ };
107
+ const evidence = [];
108
+ try {
109
+ await check();
110
+ const refined = await host.refine();
111
+ admitUnattended(refined.eligibility, policy, route);
112
+ const plan = route === 'hotfix' ? regressionPlan(refined.hotfix) : await host.propose(refined);
113
+ await check();
114
+ await host.reviewPlan({ refined, plan });
115
+ let red = null;
116
+ if (route === 'hotfix') {
117
+ red = await host.reproduce(plan);
118
+ assertRegression(red.receipt, plan, 'red');
119
+ const regression = red.receipt.checks.find(c => JSON.stringify(c.command) === JSON.stringify(plan.command));
120
+ if (JSON.stringify(plan.command) !== JSON.stringify(policy.hotfix.regressionCommand) ||
121
+ !policy.hotfix.failurePattern.some(pattern => regression.log.includes(pattern))) throw Error('RED did not use the owner-declared regression runner and assertion failure signal');
122
+ const scope = hotfixScope(red.files, policy);
123
+ if (scope.tests.length !== red.files.length) throw Error('Reproduction phase may change regression tests only');
124
+ evidence.push({ kind: 'red', receipt: red.receipt, tests: red.tests });
125
+ }
126
+ let candidate, accepted = false;
127
+ for (let round = 0; round <= policy.maxCorrections; round++) {
128
+ await check();
129
+ candidate = await host.implement({ refined, plan, red, previous: candidate, round });
130
+ admitUnattended(candidate.eligibility, policy, route);
131
+ if (route === 'hotfix') {
132
+ hotfixScope(candidate.files, policy);
133
+ if (unattendedDigest(candidate.tests) !== unattendedDigest(red.tests)) throw Error('Hotfix repair changed its regression assertions');
134
+ }
135
+ if (!candidate.receipt?.checks?.length) throw Error('Required candidate checks are unavailable');
136
+ if (candidate.receipt.checks.some(c => c.exitCode !== 0)) {
137
+ candidate.feedback = ['Required host checks failed; preserve regression assertions and repair only the authorized scope.'];
138
+ evidence.push({ kind: 'failed-checks', round, checks: candidate.receipt.checks });
139
+ continue;
140
+ }
141
+ if (route === 'hotfix') assertRegression(candidate.receipt, plan, 'green');
142
+ await check();
143
+ const review = await host.reviewCandidate(candidate);
144
+ evidence.push({ kind: 'candidate', round, review });
145
+ if (review.accepted === true) { accepted = true; break; }
146
+ if (review.verdict !== 'request-changes') throw Error('Independent review requires a human decision');
147
+ candidate.feedback = review.findings;
148
+ }
149
+ if (!accepted) throw Error('Independent review correction limit exhausted');
150
+ await check();
151
+ const published = await host.publish(candidate);
152
+ await check();
153
+ if (!policy.merge) return { status: 'waiting-human', published, evidence, nextAction: 'Authorize integration of the reviewed PR head' };
154
+ const integrated = await host.integrate(candidate, published);
155
+ evidence.push({ kind: 'integration', result: integrated });
156
+ return { status: 'passed', published, integrated, evidence, durationMs: Date.now() - started, nextAction: 'Follow existing post-merge review and release policy' };
157
+ } catch (error) {
158
+ await host.stop(error.message, evidence);
159
+ return { status: 'blocked', evidence, reason: error.message, nextAction: 'Inspect execution evidence and resolve the identified blocker' };
160
+ }
161
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "enabled": false,
4
+ "routes": [
5
+ "ff",
6
+ "hotfix"
7
+ ],
8
+ "paths": [
9
+ "src/features/example/",
10
+ "tests/example/"
11
+ ],
12
+ "reviewerProfile": "plan",
13
+ "allowFivePoint": false,
14
+ "merge": false,
15
+ "maxCorrections": 2,
16
+ "timeoutSeconds": 3600,
17
+ "hotfix": {
18
+ "setupCommands": [],
19
+ "testPaths": [
20
+ "tests/example/"
21
+ ],
22
+ "modules": {
23
+ "example": [
24
+ "src/features/example/",
25
+ "tests/example/"
26
+ ]
27
+ },
28
+ "regressionCommand": [
29
+ "node",
30
+ "--test",
31
+ "tests/example/regression.test.mjs"
32
+ ],
33
+ "failurePattern": [
34
+ "ERR_ASSERTION"
35
+ ]
36
+ }
37
+ }
package/unattended.md ADDED
@@ -0,0 +1,40 @@
1
+ # Bounded unattended delivery
2
+
3
+ Install the opt-in composition with `node node_modules/@juangcarmona/lighthouse-sdlc/src/install-workflows.mjs . <immutable-lighthouse-commit> --with-unattended`. Preview first; add `--apply` after reviewing the plan. This also installs completion workers. Existing policy is preserved. The initial `.lighthouse/unattended.json` template has execution and merge disabled.
4
+
5
+ Use the existing adopted GitHub completion profile, enforced checks, evidence branch and provisioned OpenCode runner. Configure implementation and reviewer profiles to distinct available model identifiers. Different profile names can resolve to the same model; admission rejects that combination. The runtime records requested/resolved configuration separately from provider-reported settings, which may remain unverified.
6
+
7
+ ## Configure and request work
8
+
9
+ Edit the project-owned policy using [the template](templates/unattended.json). Replace the example paths and module map with actual repository boundaries, choose enabled routes and a finite time/correction budget, then explicitly enable execution. `merge: true` is a separate bounded delegation of integration; `merge: false` leaves the reviewed PR for owner authorization. The policy cannot change itself through a candidate.
10
+
11
+ For hotfix, declare trusted setup commands (an empty list when unnecessary), the regression command and assertion-failure signals in project policy. The model must use that command; it cannot authorize a custom shell script or choose an arbitrary failure as regression proof. Setup runs before the distinct regression check in each clean verification workspace. Test authoring remains limited to the declared test paths. The reviewer inspects the actual test and RED/GREEN evidence against the reported behavior.
12
+
13
+ Run either composition from the repository's default branch:
14
+
15
+ ```sh
16
+ gh workflow run lighthouse-sdlc-unattended.yml -f issue=<number> -f route=ff
17
+ gh workflow run lighthouse-sdlc-unattended.yml -f issue=<number> -f route=hotfix
18
+ ```
19
+
20
+ Only an authenticated owner dispatch is admitted initially. The issue must be open, authored by the owner, and free of human reservations, pauses and pending answer/review gates. External intake uses the existing owner-admission route before it can become eligible; this composition does not broaden that route. Installation and workflow availability are separate from successful execution.
21
+
22
+ ## Fast-forward
23
+
24
+ Refinement must establish low risk, known reversible scope, resolved dependencies, no missing product decision and an explained numeric estimate of 1, 2 or 3. Five points requires `allowFivePoint: true`; legacy size labels are not converted into points. A separate read-only reviewer checks readiness and the real provider-generated engineering plan before code changes. The implementation and required host checks are then reviewed independently against the original issue, owner answers and actual before/after source.
25
+
26
+ At most the configured correction rounds are permitted, with two as the maximum. Changed scope, failed or unavailable evidence, human-only decisions, cancellation, revoked authority or exhausted corrections stop dependent advancement and retain the candidate. Required checks cannot be waived by a reviewer verdict.
27
+
28
+ ## Hotfix
29
+
30
+ The expedited route assesses the confirmed defect and root cause, records a compact correction plan and authors regression tests before changing production code. The trusted host runs the declared command against unchanged production code and requires both the identified assertion and an owner-declared assertion-failure signal. An environment/compilation failure is not acceptable proof. Independent review must confirm that the test exercises the actual failing behavior.
31
+
32
+ The complete candidate is limited to 100 added plus deleted production-code lines, 10 files including tests and two declared modules. Test lines are excluded only from the production-code cap. The host measures the actual diff and rejects ambiguous module ownership, binary changes and unassessed scope. The regression-test bytes from RED must survive the repair unchanged. GREEN and adjacent/required checks precede independent candidate review. The RED receipt and test snapshot are part of that review's binding.
33
+
34
+ ## Integration and recovery
35
+
36
+ The host rechecks source, policy, owner intent, reservations, the exact PR head, required CI and hosting rules before a delegated merge. Required GitHub checks must actually pass. A merge queue requires a supported queue-aware adapter; protected decisions retain their configured owners.
37
+
38
+ The trusted host retains the integration bundle on the adopted evidence branch for existing post-merge review. Release, deployment, smoke checks and rollback continue only through their existing separately authorized paths. A merge does not establish deployment.
39
+
40
+ Each request receives an immutable identity before execution. A rerun with an existing receipt stops for inspection rather than repeating uncertain effects. Failed/blocked attempts retain candidate and host execution artifacts; resolve the named blocker before a fresh owner request. GitHub issue concurrency is shared with the stage workers and reservations are released on completion. No runner stays allocated while waiting for a new human decision.