@juangcarmona/lighthouse-sdlc 0.16.3 → 0.17.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.
- package/model-profiles.md +21 -2
- package/package.json +1 -1
- package/src/completion-actions.mjs +2 -2
- package/src/issue-actions.mjs +3 -2
- package/src/model-profiles.mjs +48 -22
- package/src/opencode-execute.mjs +2 -2
- package/src/stage-dispatch.mjs +13 -0
- package/workflows/lighthouse-sdlc-router.yml +4 -0
package/model-profiles.md
CHANGED
|
@@ -19,7 +19,22 @@ Create `.lighthouse/models.json` in the consuming repository after verifying ide
|
|
|
19
19
|
}
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Schema version 1 supports the profile names `plan`, `build` and `fast`. Schema version 2 also supports other lowercase names and direct `{ "model": "openai/model", "reasoningEffort": "high" }` assignments for workers and defaults. Models must be OpenAI provider-qualified identifiers without an embedded variant. Effort is independent: `none`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`, but only values explicitly permitted for that model by the operator can run. The resolver rejects unknown configuration keys, providers, profile names, effort values and unsupported tier or transport fields. It never chooses an alternative on failure.
|
|
23
|
+
|
|
24
|
+
A version 2 project can configure four independent combinations, subject to operator capabilities:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"schemaVersion": 2,
|
|
29
|
+
"workers": {
|
|
30
|
+
"triage": { "model": "openai/verified-model-a", "reasoningEffort": "medium" },
|
|
31
|
+
"refine": { "model": "openai/verified-model-a", "reasoningEffort": "high" },
|
|
32
|
+
"implement": { "model": "openai/verified-model-b", "reasoningEffort": "high" },
|
|
33
|
+
"review": "review-preset"
|
|
34
|
+
},
|
|
35
|
+
"profiles": { "review-preset": { "model": "openai/verified-model-b", "reasoningEffort": "medium" } }
|
|
36
|
+
}
|
|
37
|
+
```
|
|
23
38
|
|
|
24
39
|
## Operator opt-in and precedence
|
|
25
40
|
|
|
@@ -43,7 +58,7 @@ Merge these fields into the existing operator engine configuration. Retain `imag
|
|
|
43
58
|
|
|
44
59
|
Selection precedence, highest first:
|
|
45
60
|
|
|
46
|
-
1. A delegated task's explicit profile
|
|
61
|
+
1. A direct manual execution selection, when the operator permits it, or a delegated task's explicit profile. These selectors cannot be combined.
|
|
47
62
|
2. Operator worker assignment.
|
|
48
63
|
3. Project worker assignment.
|
|
49
64
|
4. Operator default profile.
|
|
@@ -99,3 +114,7 @@ For every task/candidate record revision, exact prompt or fixture, model, effort
|
|
|
99
114
|
Record the account route as operator-confirmed subscription, API or unknown; do not inspect credentials or switch routes. For API evaluations record uncached input, cached input, applicable cache writes, output/reasoning as actually billed, context regime and processing tier. Avoid counting reasoning twice when it is included in billed output. Record dated rate sources and compute API-equivalent estimates separately from actual charges. For subscriptions report only exposed usage; unknown is neither free nor unlimited. Flex and Batch are different transports and neither is implemented here.
|
|
100
115
|
|
|
101
116
|
Choose the least costly setting that meets the declared bar only when comparable accepted-task and account evidence supports that decision. Otherwise report selection as unresolved. This delivery supplies deterministic configuration and scheduling tests; it does not claim live candidate results or preset acceptance.
|
|
117
|
+
|
|
118
|
+
## Individual executions
|
|
119
|
+
|
|
120
|
+
With operator schema version 2 and `allowExecutionSelection: true`, an owner can set the router's optional `model_selection` input to JSON such as `{"profile":"plan"}` or `{"model":"openai/verified-planning-model","reasoningEffort":"high"}`. The choice applies only to that direct manual stage dispatch. Automated continuations, recovery and Product Change workers do not inherit it. Operator capabilities still constrain the selection; an unavailable model or effort fails without fallback. Leave the input empty to use normal worker and default precedence.
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { admitDispatch, checkDispatchClaim, finishDispatch, completedEvent, statusText } from './stage-dispatch.mjs';
|
|
1
|
+
import { admitDispatch, checkDispatchClaim, finishDispatch, completedEvent, statusText, executionModelSelection } from './stage-dispatch.mjs';
|
|
2
2
|
import { readFile, writeFile, mkdir, appendFile, access } from 'node:fs/promises';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -42,7 +42,7 @@ async function main(command) {
|
|
|
42
42
|
if (!completion.merged) assertChecks(completion);
|
|
43
43
|
const postMerge = completion.merged ? await postMergeRuns(api, completion, completion.merge) : [];
|
|
44
44
|
const id = fingerprint({ repository, stage, issue: authorization.issue, run: env.GITHUB_RUN_ID, attempt: env.GITHUB_RUN_ATTEMPT });
|
|
45
|
-
const request = { ...(dispatch ? { dispatch: { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest }, dispatchOrigin: { request: dispatch.request, receipt: dispatch.receipt } } : {}), runId: id, issue: authorization.issue, stage, title: completion.title, sourceRevision: completion.merged ? completion.merge : completion.binding.head, taskDigest: completion.binding.intent, workflowRunId: env.GITHUB_RUN_ID, attempt: env.GITHUB_RUN_ATTEMPT, observedAt: new Date().toISOString(), harnessRevision: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), runUrl: `https://github.com/${repository}/actions/runs/${env.GITHUB_RUN_ID}`, authorization, completion, postMerge };
|
|
45
|
+
const request = { ...(dispatch && executionModelSelection(dispatch.request) ? { modelSelection: executionModelSelection(dispatch.request) } : {}), ...(dispatch ? { dispatch: { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest }, dispatchOrigin: { request: dispatch.request, receipt: dispatch.receipt } } : {}), runId: id, issue: authorization.issue, stage, title: completion.title, sourceRevision: completion.merged ? completion.merge : completion.binding.head, taskDigest: completion.binding.intent, workflowRunId: env.GITHUB_RUN_ID, attempt: env.GITHUB_RUN_ATTEMPT, observedAt: new Date().toISOString(), harnessRevision: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), runUrl: `https://github.com/${repository}/actions/runs/${env.GITHUB_RUN_ID}`, authorization, completion, postMerge };
|
|
46
46
|
request.priorIntegration = priorIntegration;
|
|
47
47
|
if (Buffer.byteLength(JSON.stringify(request)) > 2 * 1024 * 1024) throw Error('Completion context exceeds bounds');
|
|
48
48
|
await mkdir(dir, { recursive: true }); await save('request.json', request);
|
package/src/issue-actions.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { refinementAfterRun, signalRefinementContinuation } from './stage-transition.mjs';
|
|
2
|
-
import { dispatchId, dispatchArtifact, recordDispatch, sendDispatch, admitDispatch, checkDispatchClaim, finishDispatch, completedEvent, statusText, stages } from './stage-dispatch.mjs';
|
|
2
|
+
import { dispatchId, dispatchArtifact, recordDispatch, sendDispatch, admitDispatch, checkDispatchClaim, finishDispatch, completedEvent, statusText, stages, executionModelSelection } from './stage-dispatch.mjs';
|
|
3
3
|
import { readActionsJson } from './actions-artifact.mjs';
|
|
4
4
|
import { deliveryAfterChecks, dispatchDeliveryChecks } from './delivery-events.mjs';
|
|
5
5
|
import { readFile, writeFile, appendFile, mkdir } from 'node:fs/promises';
|
|
@@ -216,6 +216,7 @@ async function main(command) {
|
|
|
216
216
|
// Only an explicit owner dispatch selects a workflow branch; native events use main.
|
|
217
217
|
const manual = env.GITHUB_EVENT_NAME === 'workflow_dispatch';
|
|
218
218
|
const request = { schemaVersion: 1, id: dispatchId(repo, feedbackIdentity ?? eventRunId ?? env.GITHUB_RUN_ID, stage), ...(worker ? { worker, ...(feedbackIdentity ? { feedbackKey: feedbackIdentity } : {}) } : {}), ...(route.productApproval?.approval ? { productApproval: { ...route.productApproval.approval, pullRequest: route.productApproval.pullRequest, head: route.productApproval.head, issue: route.productApproval.issue, changeName: route.productApproval.changeName } } : {}), ...(route.productFeedback ? { productFeedback: route.productFeedback } : {}), ...(route.productReview && !route.productApproval?.approval ? { productReview: { pullRequest: route.productReview.pullRequest, head: route.productReview.head, issue: route.productReview.issue, changeName: route.productReview.changeName } } : {}), repository: repo, issue: issueNumber, stage, routerRunId: env.GITHUB_RUN_ID, routerAttempt: Number(env.GITHUB_RUN_ATTEMPT), routerRevision: router.head_sha, routerRef: router.head_branch, workflowRevision: manual ? router.head_sha : revision, sourceRevision: revision, ref: manual ? router.head_branch : metadata.default_branch, eventName: env.GITHUB_EVENT_NAME, event, handoff, queue, ...(handoff ? { predecessor: route.predecessor } : {}), ...(eventRunId ? { eventRunId } : {}), autoImplement: stage === 'refine' && env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true', taskDigest: taskDigest(issue, answers) };
|
|
219
|
+
executionModelSelection(request);
|
|
219
220
|
const recorded = await recordDispatch(api, repo, request);
|
|
220
221
|
await output('send', String(recorded.send));
|
|
221
222
|
await output('artifact', dispatchArtifact(request));
|
|
@@ -316,7 +317,7 @@ async function main(command) {
|
|
|
316
317
|
const productChangeName = productWorker ? dispatch.request.productFeedback?.changeName ?? dispatch.request.event.inputs?.change_name?.trim() ?? `issue-${issueNumber}-product-change` : undefined;
|
|
317
318
|
if (productWorker && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(productChangeName)) throw Error('Invalid Product Change name');
|
|
318
319
|
const baselineDigest = productWorker ? await productModelDigest(process.cwd()) : undefined;
|
|
319
|
-
const snapshot = { autoImplement: env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true', ...(dispatch ? { worker: dispatch.request.worker, workflowRevision: env.GITHUB_SHA, dispatch: { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest, routerRunId: dispatch.request.routerRunId, routerAttempt: dispatch.request.routerAttempt } } : {}), ...(productWorker ? { authorizationDigest, policyDigest, productChangeName, baselineDigest } : {}), ...(planningReference ? { planningReference } : {}), ...(implementationReview ? { implementationReview, baseRevision } : {}), ...(proposalReview ? { proposalReview } : {}), runId: id, issue: issueNumber, stage, title: issue.title, defaultBranch: repository.default_branch, sourceRevision: revision.sha, taskDigest: taskDigest(issue, answers), workflowRunId: env.GITHUB_RUN_ID, ...(predecessor ? { refinement: predecessor } : {}), ...(continuation ? { replyTo: continuation.request.runId, commentId: event.comment.id } : {}), ...(implementation ? { implementation } : {}), ...(seed ? { seedDigest: changesDigest(seed) } : {}) };
|
|
320
|
+
const snapshot = { ...(dispatch && executionModelSelection(dispatch.request) ? { modelSelection: executionModelSelection(dispatch.request) } : {}), autoImplement: env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true', ...(dispatch ? { worker: dispatch.request.worker, workflowRevision: env.GITHUB_SHA, dispatch: { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest, routerRunId: dispatch.request.routerRunId, routerAttempt: dispatch.request.routerAttempt } } : {}), ...(productWorker ? { authorizationDigest, policyDigest, productChangeName, baselineDigest } : {}), ...(planningReference ? { planningReference } : {}), ...(implementationReview ? { implementationReview, baseRevision } : {}), ...(proposalReview ? { proposalReview } : {}), runId: id, issue: issueNumber, stage, title: issue.title, defaultBranch: repository.default_branch, sourceRevision: revision.sha, taskDigest: taskDigest(issue, answers), workflowRunId: env.GITHUB_RUN_ID, ...(predecessor ? { refinement: predecessor } : {}), ...(continuation ? { replyTo: continuation.request.runId, commentId: event.comment.id } : {}), ...(implementation ? { implementation } : {}), ...(seed ? { seedDigest: changesDigest(seed) } : {}) };
|
|
320
321
|
if (stage === 'triage') snapshot.originalRequest = issue.body ?? '';
|
|
321
322
|
if (triage) snapshot.triageEstimate = triage.envelope.result.output.estimate;
|
|
322
323
|
await output('source_revision', revision.sha);
|
package/src/model-profiles.mjs
CHANGED
|
@@ -3,54 +3,80 @@ export const MODEL_PROFILES = Object.freeze(['plan', 'build', 'fast']);
|
|
|
3
3
|
export const REASONING_EFFORTS = Object.freeze(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
|
4
4
|
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
5
5
|
const own = (value, key) => Object.hasOwn(value ?? {}, key);
|
|
6
|
+
const profileName = (name, version) => typeof name === 'string' && (version === 1 ? MODEL_PROFILES.includes(name) : /^[a-z][a-z0-9-]{0,63}$/.test(name));
|
|
6
7
|
function keys(value, allowed, label) {
|
|
7
8
|
if (!record(value) || Object.keys(value).some(key => !allowed.includes(key))) throw Error(`Invalid ${label}`);
|
|
8
9
|
}
|
|
10
|
+
function validatePair(value, complete = false) {
|
|
11
|
+
keys(value, ['model', 'reasoningEffort'], 'model profile settings');
|
|
12
|
+
if (complete && (!own(value, 'model') || !own(value, 'reasoningEffort'))) throw Error('Direct model selection must include model and reasoning effort');
|
|
13
|
+
if (own(value, 'model') && (typeof value.model !== 'string' || !/^openai\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value.model))) throw Error('Profile model must be an OpenAI provider/model identifier without a variant');
|
|
14
|
+
if (own(value, 'reasoningEffort') && !REASONING_EFFORTS.includes(value.reasoningEffort)) throw Error('Invalid reasoning effort');
|
|
15
|
+
}
|
|
16
|
+
function selector(value, version) {
|
|
17
|
+
if (profileName(value, version)) return value;
|
|
18
|
+
if (version === 2 && record(value)) { validatePair(value, true); return value; }
|
|
19
|
+
throw Error('Invalid model selector');
|
|
20
|
+
}
|
|
9
21
|
function validateSettings(value, operator = false) {
|
|
10
|
-
keys(value, ['schemaVersion', 'profiles', 'workers', 'defaultProfile', ...(operator ? ['enabled'] : [])], 'model profile configuration');
|
|
11
|
-
if (value.schemaVersion
|
|
22
|
+
keys(value, ['schemaVersion', 'profiles', 'workers', 'defaultProfile', ...(operator ? ['enabled', 'allowExecutionSelection'] : [])], 'model profile configuration');
|
|
23
|
+
if (![1, 2].includes(value.schemaVersion)) throw Error('Model profiles require schemaVersion 1 or 2');
|
|
12
24
|
if (operator && typeof value.enabled !== 'boolean') throw Error('Operator model profiles require explicit enabled');
|
|
13
|
-
if (own(value, '
|
|
25
|
+
if (own(value, 'allowExecutionSelection') && (value.schemaVersion !== 2 || typeof value.allowExecutionSelection !== 'boolean')) throw Error('Execution selection requires version 2 and a boolean opt-in');
|
|
26
|
+
if (own(value, 'defaultProfile')) selector(value.defaultProfile, value.schemaVersion);
|
|
14
27
|
if (own(value, 'profiles')) {
|
|
15
|
-
keys(value.profiles
|
|
16
|
-
for (const profile of Object.values(value.profiles))
|
|
17
|
-
keys(profile, ['model', 'reasoningEffort'], 'model profile settings');
|
|
18
|
-
if (own(profile, 'model') && !/^openai\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(profile.model)) throw Error('Profile model must be an OpenAI provider/model identifier without a variant');
|
|
19
|
-
if (own(profile, 'reasoningEffort') && !REASONING_EFFORTS.includes(profile.reasoningEffort)) throw Error('Invalid reasoning effort');
|
|
20
|
-
}
|
|
28
|
+
if (!record(value.profiles) || Object.keys(value.profiles).some(name => !profileName(name, value.schemaVersion))) throw Error('Invalid profile names');
|
|
29
|
+
for (const profile of Object.values(value.profiles)) validatePair(profile);
|
|
21
30
|
}
|
|
22
31
|
if (own(value, 'workers')) {
|
|
23
|
-
if (!record(value.workers)
|
|
32
|
+
if (!record(value.workers)) throw Error('Invalid worker profile assignments');
|
|
33
|
+
for (const [worker, valueForWorker] of Object.entries(value.workers)) {
|
|
34
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(worker)) throw Error('Invalid worker profile assignments');
|
|
35
|
+
selector(valueForWorker, value.schemaVersion);
|
|
36
|
+
}
|
|
24
37
|
}
|
|
25
38
|
return value;
|
|
26
39
|
}
|
|
27
40
|
|
|
28
|
-
export function resolveModelProfile({ project = null, operator, worker, profile } = {}) {
|
|
41
|
+
export function resolveModelProfile({ project = null, operator, worker, profile, executionSelection } = {}) {
|
|
29
42
|
if (!record(operator)) throw Error('Operator model configuration required');
|
|
30
43
|
if (project !== null) validateSettings(project);
|
|
31
44
|
const local = operator.modelProfiles;
|
|
32
45
|
if (local !== undefined) validateSettings(local, true);
|
|
46
|
+
if (local?.enabled && project?.schemaVersion === 2 && local.schemaVersion !== 2) throw Error('Version 2 model settings require operator version 2');
|
|
33
47
|
if (!local?.enabled) {
|
|
34
|
-
if (profile !== undefined) throw Error('Delegated profiles require operator opt-in');
|
|
48
|
+
if (profile !== undefined || executionSelection !== undefined) throw Error('Delegated profiles require operator opt-in');
|
|
35
49
|
if (typeof operator.model !== 'string' || !operator.model.trim() || /[\0\r\n]/.test(operator.model)) throw Error('Legacy operator model required');
|
|
36
50
|
return { mode: 'legacy', profile: null, model: operator.model, reasoningEffort: null, engineModel: operator.model, source: 'operator.model', capabilityEvidence: 'not-checked-legacy' };
|
|
37
51
|
}
|
|
38
|
-
if (profile !== undefined && !
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
if (profile !== undefined && !profileName(profile, local.schemaVersion)) throw Error('Invalid requested model profile');
|
|
53
|
+
if (profile !== undefined && executionSelection !== undefined) throw Error('Conflicting model selections');
|
|
54
|
+
let execution;
|
|
55
|
+
if (executionSelection !== undefined) {
|
|
56
|
+
if (local.schemaVersion !== 2 || local.allowExecutionSelection !== true) throw Error('Execution selection requires operator opt-in');
|
|
57
|
+
keys(executionSelection, ['profile', 'model', 'reasoningEffort'], 'execution model selection');
|
|
58
|
+
if (own(executionSelection, 'profile')) {
|
|
59
|
+
if (Object.keys(executionSelection).length !== 1 || !profileName(executionSelection.profile, 2)) throw Error('Invalid execution model selection');
|
|
60
|
+
execution = executionSelection.profile;
|
|
61
|
+
} else { validatePair(executionSelection, true); execution = executionSelection; }
|
|
62
|
+
}
|
|
63
|
+
const choices = [[execution, 'execution'], [profile, 'task.profile'], [own(local.workers, worker) ? local.workers[worker] : undefined, 'operator.workers'], [own(project?.workers, worker) ? project.workers[worker] : undefined, 'project.workers'], [local.defaultProfile, 'operator.defaultProfile'], [project?.defaultProfile, 'project.defaultProfile']];
|
|
64
|
+
const choice = choices.find(([value]) => value !== undefined);
|
|
41
65
|
if (!choice) throw Error('No model profile assigned to worker');
|
|
42
|
-
const [
|
|
43
|
-
const base = project?.profiles
|
|
44
|
-
const
|
|
66
|
+
const [value, source] = choice, name = typeof value === 'string' ? value : null;
|
|
67
|
+
const base = name && own(project?.profiles, name) ? project.profiles[name] : {};
|
|
68
|
+
const override = name && own(local.profiles, name) ? local.profiles[name] : {};
|
|
69
|
+
const selected = name ? { ...base, ...override } : value;
|
|
45
70
|
if (!selected.model || !selected.reasoningEffort) throw Error(`Incomplete model profile: ${name}`);
|
|
46
71
|
const capability = operator.modelCapabilities?.[selected.model];
|
|
47
|
-
if (!record(capability) || capability.available !== true || capability.tools !== true || !Array.isArray(capability.reasoningEfforts) || !capability.reasoningEfforts.includes(selected.reasoningEffort)) throw Error(`Model or reasoning unavailable in operator capabilities: ${name}`);
|
|
48
|
-
|
|
72
|
+
if (!record(capability) || capability.available !== true || capability.tools !== true || !Array.isArray(capability.reasoningEfforts) || !capability.reasoningEfforts.includes(selected.reasoningEffort)) throw Error(`Model or reasoning unavailable in operator capabilities: ${name ?? source}`);
|
|
73
|
+
const directSource = source === 'execution' ? 'execution' : source.startsWith('operator.') ? 'operator' : 'project';
|
|
74
|
+
return { mode: name ? 'profile' : 'direct', profile: name, ...selected, engineModel: `${selected.model}#${selected.reasoningEffort}`, source, fields: name ? { model: own(override, 'model') ? 'operator' : 'project', reasoningEffort: own(override, 'reasoningEffort') ? 'operator' : 'project' } : { model: directSource, reasoningEffort: directSource }, capabilityEvidence: 'operator-configuration-only' };
|
|
49
75
|
}
|
|
50
76
|
|
|
51
|
-
export function modelExecutionReceipt(selection) {
|
|
77
|
+
export function modelExecutionReceipt(selection, executionSelection) {
|
|
52
78
|
return {
|
|
53
|
-
requested: { profile: selection.profile, model: selection.model, reasoningEffort: selection.reasoningEffort },
|
|
79
|
+
requested: { profile: selection.profile, model: selection.model, reasoningEffort: selection.reasoningEffort, ...(executionSelection ? { selector: executionSelection } : {}) },
|
|
54
80
|
resolved: selection,
|
|
55
81
|
providerReported: { model: null, reasoningEffort: null, status: 'unverified', reason: 'The current adapter has no authenticated provider-settings receipt' },
|
|
56
82
|
billing: { route: 'unchanged', actualCost: null, estimatedApiEquivalentCost: null, subscriptionUsage: null },
|
package/src/opencode-execute.mjs
CHANGED
|
@@ -64,7 +64,7 @@ async function main() {
|
|
|
64
64
|
projectModels = JSON.parse(await readFile(path, 'utf8'));
|
|
65
65
|
}
|
|
66
66
|
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
67
|
-
const modelSelection = resolveModelProfile({ project: projectModels, operator: config, worker: env.LIGHTHOUSE_WORKER ?? request.stage, ...(env.LIGHTHOUSE_MODEL_PROFILE ? { profile: env.LIGHTHOUSE_MODEL_PROFILE } : {}) });
|
|
67
|
+
const modelSelection = resolveModelProfile({ project: projectModels, operator: config, worker: env.LIGHTHOUSE_WORKER ?? request.stage, ...(request.modelSelection ? { executionSelection: request.modelSelection } : {}), ...(env.LIGHTHOUSE_MODEL_PROFILE ? { profile: env.LIGHTHOUSE_MODEL_PROFILE } : {}) });
|
|
68
68
|
const selected = request.stage === 'implement' ? selectEnvironment(config, request.implementation?.environment) : null;
|
|
69
69
|
if (request.stage === 'implement') {
|
|
70
70
|
if (selected) config.implementationImage = selected.image;
|
|
@@ -109,7 +109,7 @@ async function main() {
|
|
|
109
109
|
await writeFile(prompt, await readFile(env.GH_AW_PROMPT, 'utf8') + completionBoundary, { mode: 0o444 });
|
|
110
110
|
const harness = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
111
111
|
const pkg = JSON.parse(await readFile(join(harness, 'package.json'), 'utf8'));
|
|
112
|
-
const runtimeReceipt = { package: { name: pkg.name, version: pkg.version }, harnessRevision: execFileSync('git', ['-C', harness, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), image: config.image, limits, environment: environmentReceipt(selected), model: modelSelection.model, modelExecution: modelExecutionReceipt(modelSelection), engine: 'OpenCode 2.1.0', isolation: 'Docker; no AWF egress filtering', skills: await readdir(join(harness, 'skills')), sourceRevision: request.sourceRevision, seed: request.implementation?.policy.seed ?? null, seedDigest: request.seedDigest ?? null };
|
|
112
|
+
const runtimeReceipt = { package: { name: pkg.name, version: pkg.version }, harnessRevision: execFileSync('git', ['-C', harness, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), image: config.image, limits, environment: environmentReceipt(selected), model: modelSelection.model, modelExecution: modelExecutionReceipt(modelSelection, request.modelSelection), engine: 'OpenCode 2.1.0', isolation: 'Docker; no AWF egress filtering', skills: await readdir(join(harness, 'skills')), sourceRevision: request.sourceRevision, seed: request.implementation?.policy.seed ?? null, seedDigest: request.seedDigest ?? null };
|
|
113
113
|
await writeFile(join(runDir, 'runtime.json'), JSON.stringify(runtimeReceipt));
|
|
114
114
|
const command = imageCommand(config.image);
|
|
115
115
|
const args = ['run', '--rm', '--init', '--name', name, '--user', `${config.uid}:${config.uid}`, ...resourceArgs(limits), '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--read-only',
|
package/src/stage-dispatch.mjs
CHANGED
|
@@ -70,6 +70,18 @@ export async function sendDispatch(api, repository, request, commentId) {
|
|
|
70
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
|
+
// Called only for a dispatch whose router identity is authenticated by the host.
|
|
74
|
+
export function executionModelSelection(request) {
|
|
75
|
+
const input = request?.event?.inputs?.model_selection;
|
|
76
|
+
if (input === undefined || input === '') return undefined;
|
|
77
|
+
if (request.eventName !== 'workflow_dispatch' || request.recovery || request.handoff || request.worker || request.event.inputs.after_run || (request.event.inputs.workflow && request.event.inputs.workflow !== 'stage')) throw Error('Model selection requires a direct owner stage dispatch');
|
|
78
|
+
if (typeof input !== 'string' || Buffer.byteLength(input) > 512) throw Error('Invalid execution model selection');
|
|
79
|
+
let value;
|
|
80
|
+
try { value = JSON.parse(input); } catch { throw Error('Invalid execution model selection JSON'); }
|
|
81
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('Invalid execution model selection');
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
73
85
|
export function verifyDispatchOrigin(request, receipt, parent, worker, repository, owner, issue) {
|
|
74
86
|
const delegatedWorker = workerFor(request) === 'apply-review' && ['propose', 'implement'].includes(request.stage) || workerFor(request) === 'product-change' && ['propose', 'implement'].includes(request.stage);
|
|
75
87
|
const applyProductWorker = workerFor(request) === 'apply-product-change' && ['propose', 'implement'].includes(request.stage);
|
|
@@ -88,6 +100,7 @@ export function verifyDispatchOrigin(request, receipt, parent, worker, repositor
|
|
|
88
100
|
const originEvent = recovery ? request.routerEventName : request.eventName;
|
|
89
101
|
const continuation = !recovery && isBotContinuation(request.event, parent.event, parent.actor?.login, parent.triggering_actor?.login) && request.stage === 'implement' && request.handoff && !request.worker && !request.feedbackKey && Number(request.event.inputs.issue) === issue && request.predecessor?.runId === request.event.inputs.after_run;
|
|
90
102
|
if (String(parent.id) !== String(receipt.routerRunId) || parent.run_attempt !== receipt.routerAttempt || request.routerRunId !== receipt.routerRunId || request.routerAttempt !== receipt.routerAttempt || parent.path !== originPath || (parent.event !== 'workflow_run' && !continuation && (parent.actor?.login !== owner || parent.triggering_actor?.login !== owner)) || !(recovery ? ['schedule', 'workflow_dispatch'] : events).includes(parent.event) || parent.event !== originEvent || parent.head_sha !== (request.routerRevision ?? request.workflowRevision) || parent.head_branch !== (request.routerRef ?? request.ref) || parent.repository?.full_name !== repository) throw Error('Dispatch origin is not an authorized router execution');
|
|
103
|
+
executionModelSelection(request);
|
|
91
104
|
const eventRunId = request.eventName === 'workflow_run' ? request.event?.workflow_run?.id : request.event?.inputs?.after_run;
|
|
92
105
|
if (String(eventRunId ?? '') !== String(receipt.eventRunId ?? '') || String(request.eventRunId ?? '') !== String(receipt.eventRunId ?? '')) throw Error('Completion event identity mismatch');
|
|
93
106
|
if (worker.path !== stagePath(workerFor(request)) || worker.event !== 'workflow_dispatch' || worker.head_sha !== request.workflowRevision || worker.head_branch !== request.ref || worker.repository?.full_name !== repository || ![owner, 'github-actions[bot]'].includes(worker.actor?.login) || ![owner, 'github-actions[bot]'].includes(worker.triggering_actor?.login)) throw Error('Dispatch worker identity mismatch');
|
|
@@ -35,6 +35,10 @@ on:
|
|
|
35
35
|
default: stage
|
|
36
36
|
type: choice
|
|
37
37
|
options: [stage, product-change]
|
|
38
|
+
model_selection:
|
|
39
|
+
description: Optional JSON model selection for this execution
|
|
40
|
+
required: false
|
|
41
|
+
type: string
|
|
38
42
|
after_run:
|
|
39
43
|
description: Recheck a completed execution through the same continuation rules
|
|
40
44
|
required: false
|