@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 CHANGED
@@ -19,7 +19,22 @@ Create `.lighthouse/models.json` in the consuming repository after verifying ide
19
19
  }
20
20
  ```
21
21
 
22
- The only profile names are `plan`, `build` and `fast`. 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.
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; this still requires operator opt-in and capability permission.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@juangcarmona/lighthouse-sdlc",
3
- "version": "0.16.3",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "Provider-neutral SDLC skills, commands and route contracts for Lighthouse adopters",
6
6
  "repository": {
@@ -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);
@@ -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);
@@ -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 !== 1) throw Error('Model profiles require schemaVersion 1');
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, 'defaultProfile') && !MODEL_PROFILES.includes(value.defaultProfile)) throw Error('Invalid default model profile');
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, MODEL_PROFILES, 'profile names');
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) || Object.entries(value.workers).some(([worker, profile]) => !/^[a-z][a-z0-9-]{0,63}$/.test(worker) || !MODEL_PROFILES.includes(profile))) throw Error('Invalid worker profile assignments');
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 && !MODEL_PROFILES.includes(profile)) throw Error('Invalid requested model profile');
39
- const choices = [[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']];
40
- const choice = choices.find(([name]) => name !== undefined);
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 [name, source] = choice;
43
- const base = project?.profiles?.[name] ?? {}, override = local.profiles?.[name] ?? {};
44
- const selected = { ...base, ...override };
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
- return { mode: 'profile', profile: name, ...selected, engineModel: `${selected.model}#${selected.reasoningEffort}`, source, fields: { model: own(override, 'model') ? 'operator' : 'project', reasoningEffort: own(override, 'reasoningEffort') ? 'operator' : 'project' }, capabilityEvidence: 'operator-configuration-only' };
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 },
@@ -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',
@@ -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