@rockysoft/opencode-hybrid-workflow 0.8.0 → 0.9.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.
package/core/workers.mjs CHANGED
@@ -1,202 +1,202 @@
1
- import {documentTool} from '../lib/public-document.mjs';
2
- import {reviewer} from '../lib/reviewers.mjs';
3
- import { spawn, spawnSync } from 'node:child_process';
4
- import { readFileSync, writeFileSync } from 'node:fs';
5
- import path from 'node:path';
6
- import { Telemetry, jsonLineReader } from './telemetry.mjs';
7
- import { findingSchema, isMajor } from './decision.mjs';
8
- import {processIdentity} from './process-identity.mjs';
9
- import {commandSpec} from '../lib/commands.mjs';
10
- import {commandEnvironment} from '../lib/platform.mjs';
11
- import {prepareBrowser,browserTools,browserInstructions} from '../lib/reviewer-browser.mjs';
12
- import {evidenceServer,queryInstructions,queryTool} from '../lib/evidence-mcp.mjs';
13
-
14
- export const resultSchema = {
15
- type: 'object', additionalProperties: false,
16
- properties: {
17
- verdict: { type: 'string', enum: ['pass', 'revise', 'blocked'] },
18
- report: { type: 'string' },
19
- findings: { type: 'array', items: findingSchema },
20
- verifiedResolutions: {type:'array',items:{type:'object',additionalProperties:false,properties:{id:{type:'string'},evidence:{type:'string'}},required:['id','evidence']}},
21
- }, required: ['verdict', 'report', 'findings', 'verifiedResolutions'],
22
- };
23
-
24
- export function workerArgs(who, config, folder, images = [], browser = null, evidence = null) {
25
- const lane=reviewer(config,who);
26
- // Attachment count is a transport choice, never an evidence quota. Larger
27
- // sets stay in the frozen images/ manifest and are opened with view_image.
28
- const inlineImages = images.length <= 4 && images.join('').length < 2048 ? images : [];
29
- if (lane.adapter === 'codex-cli') return ['exec', '--json', '--ignore-user-config', '--ignore-rules', '--ephemeral', '--skip-git-repo-check',
30
- '--sandbox', 'read-only', '-C', folder, '-m', lane.model,
31
- // User config is intentionally isolated, so Windows' required sandbox
32
- // implementation must be explicit too. Without it shell reads are denied.
33
- ...(process.platform === 'win32' ? ['-c', 'windows.sandbox="elevated"'] : []),
34
- '-c', 'approval_policy="never"',
35
- ...(lane.effort==='default'?[]:['-c', `model_reasoning_effort="${lane.effort??'high'}"`]), '-c', 'web_search="disabled"',
36
- ...(browser ? ['-c',`mcp_servers.review_browser.command=${JSON.stringify(browser.command)}`,
37
- '-c',`mcp_servers.review_browser.args=${JSON.stringify(browser.args)}`,
38
- '-c','mcp_servers.review_browser.startup_timeout_sec=60',
39
- '-c','mcp_servers.review_browser.tool_timeout_sec=120',
40
- '-c','mcp_servers.review_browser.default_tools_approval_mode="approve"',
41
- '-c',`mcp_servers.review_browser.enabled_tools=${JSON.stringify(browserTools)}`] : []),
42
- ...(evidence?['-c',`mcp_servers.review_evidence.command=${JSON.stringify(evidence.command)}`,'-c',`mcp_servers.review_evidence.args=${JSON.stringify(evidence.args)}`,
43
- '-c','mcp_servers.review_evidence.default_tools_approval_mode="approve"','-c',`mcp_servers.review_evidence.enabled_tools=${JSON.stringify([queryTool,documentTool])}`]:[]),
44
- ...inlineImages.flatMap(image=>['--image',image]),
45
- '--output-schema', path.join(folder, 'schema.json'), '-o', path.join(folder, 'result.json'), '-'];
46
- return ['-p', ...(browser||evidence?['--setting-sources','','--disable-slash-commands','--settings','{"disableAllHooks":true,"enabledPlugins":{}}']:['--safe-mode']), '--restricted', '--strict-mcp-config', '--mcp-config', JSON.stringify({mcpServers:{...(browser?{review_browser:browser}:{}),...(evidence?{review_evidence:evidence}:{})}}),
47
- '--no-chrome', '--no-session-persistence', '--permission-mode', 'dontAsk', '--permission-prompts', 'none',
48
- '--tools', 'Read,Glob,Grep', '--allowedTools', ['Read','Glob','Grep',...(browser?browserTools.map(name=>'mcp__review_browser__'+name):[]),...(evidence?[queryTool,documentTool].map(name=>'mcp__review_evidence__'+name):[])].join(','),
49
- '--model', lane.model, ...(lane.effort==='default'?[]:['--effort',lane.effort??'high']), '--output-format', 'stream-json', '--verbose',
50
- '--json-schema', JSON.stringify(resultSchema)];
51
- }
52
-
53
- export function validateResult(value) {
54
- if (!value || !['pass', 'revise', 'blocked'].includes(value.verdict) || typeof value.report !== 'string' || !value.report.trim() ||
55
- !Array.isArray(value.findings)) throw new Error('Invalid reviewer result; checkpoint stays closed.');
56
- if (Object.keys(value).some(key => !['verdict', 'report', 'findings','verifiedResolutions'].includes(key))) throw new Error('Invalid reviewer result fields.');
57
- // Existing receipts remain readable. Omission cannot resolve tracked findings.
58
- if(value.verifiedResolutions!==undefined&&(!Array.isArray(value.verifiedResolutions)||value.verifiedResolutions.some(r=>!r||Object.keys(r).some(k=>!['id','evidence'].includes(k))||typeof r.id!=='string'||!r.id.trim()||typeof r.evidence!=='string'||!r.evidence.trim())||new Set(value.verifiedResolutions.map(r=>r.id)).size!==value.verifiedResolutions.length))throw Error('Invalid verified resolutions.');
59
- const ids = new Set();
60
- for (const f of value.findings) {
61
- if (!f || typeof f !== 'object' || Array.isArray(f) ||
62
- Object.keys(f).some(key => !findingSchema.required.includes(key)) ||
63
- findingSchema.required.some(key => typeof f[key] !== 'string' || !f[key].trim()) ||
64
- !findingSchema.properties.severity.enum.includes(f.severity) ||
65
- !findingSchema.properties.category.enum.includes(f.category) || ids.has(f.id) ||
66
- (f.category === 'preference' && isMajor(f))) throw new Error('Invalid structured finding; checkpoint stays closed.');
67
- ids.add(f.id);
68
- }
69
- if ((value.verdict === 'pass' && value.findings.some(isMajor)) ||
70
- (value.verdict === 'revise' && !value.findings.length) ||
71
- (value.verdict === 'blocked' && !value.findings.some(isMajor))) throw new Error('Inconsistent reviewer verdict; checkpoint stays closed.');
72
- return value;
73
- }
74
-
75
- export function workerEnvironment(source = process.env) {
76
- const env = {};
77
- const allowed = /^(PATH|PATHEXT|SYSTEMROOT|WINDIR|COMSPEC|TEMP|TMP|TMPDIR|HOME|XDG_CONFIG_HOME|XDG_DATA_HOME|XDG_STATE_HOME|XDG_CACHE_HOME|USERPROFILE|APPDATA|LOCALAPPDATA|HOMEDRIVE|HOMEPATH|USERNAME|USERDOMAIN|PROGRAMFILES|PROGRAMFILES\(X86\)|PROGRAMW6432|OS|SYSTEMDRIVE|LANG|LC_ALL)$/i;
78
- for (const [key, value] of Object.entries(source)) if (allowed.test(key)) env[key] = value;
79
- // Official CLIs retain their normal per-user subscription login; never extract tokens.
80
- return commandEnvironment(env);
81
- }
82
-
83
- export function requireSubscription(who, config, folder, invoke = spawnSync) {
84
- const lane=reviewer(config,who);
85
- if(lane.adapter==='opencode')return;
86
- const executable=lane.executable;
87
- const args = lane.adapter === 'codex-cli' ? ['login', 'status'] : ['auth', 'status'];
88
- const spec=invoke===spawnSync?commandSpec(executable):{executable,args:[]};
89
- const result = invoke(spec.executable, [...spec.args,...args], { cwd: folder, env: workerEnvironment(), shell: false, windowsHide: true, encoding: 'utf8', timeout: 15000 });
90
- let valid = false;
91
- if (result.status === 0 && lane.adapter === 'codex-cli') valid = /Logged in using ChatGPT/i.test((result.stdout ?? '') + (result.stderr ?? ''));
92
- if (result.status === 0 && lane.adapter === 'claude-cli') {
93
- try { const auth = JSON.parse(result.stdout); valid = auth.loggedIn === true && auth.authMethod === 'claude.ai' && auth.apiProvider === 'firstParty' && !!auth.subscriptionType; } catch {}
94
- }
95
- if (!valid) throw new Error(`${who}: subscription authentication could not be confirmed. Sign in through the official CLI; API/unknown authentication is refused.`);
96
- }
97
-
98
- export async function runProcess(executable, args, folder, input, timeoutMs, signal, observer = {}) {
99
- if (signal?.aborted) throw new Error('Cancelled before launch.');
100
- return await new Promise((resolve, reject) => {
101
- const spec=commandSpec(executable);
102
- const child = spawn(spec.executable, [...spec.args,...args], { cwd: folder, env: workerEnvironment(), shell: false, windowsHide: true, detached:process.platform!=='win32', stdio: ['pipe', 'pipe', 'pipe'] });
103
- child.once('spawn', () => observer.onStart?.(child.pid));
104
- let output = '', failure, size = 0, reapTimer, settled=false;
105
- const stop = reason => {
106
- if(failure)return;
107
- failure ??= new Error(reason);
108
- if (process.platform === 'win32' && child.pid && child.exitCode === null) {
109
- spawnSync(path.join(process.env.SystemRoot ?? 'C:/Windows', 'System32/taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 5000 });
110
- } else if(child.pid) { try {process.kill(-child.pid,'SIGTERM');} catch {child.kill();} }
111
- // A failed OS termination must not hang the tool forever. Retain an explicit
112
- // live-worker guard so returning an error never unlocks concurrent writers.
113
- reapTimer=setTimeout(()=>{
114
- if(settled)return;settled=true;clearTimeout(timer);signal?.removeEventListener('abort',abort);
115
- failure.unreapedPid=child.pid;failure.workerIdentity=processIdentity(child.pid);
116
- // An exited root does not prove its descendants exited: inherited pipes
117
- // can keep this call open. Never clear this uncertainty by PID lookup.
118
- failure.descendantsUnconfirmed=true;
119
- child.stdout.destroy();child.stderr.destroy();child.stdin.destroy();child.unref();reject(failure);
120
- },5000);
121
- };
122
- const abort = () => stop('Review cancelled; checkpoint remains closed.');
123
- signal?.addEventListener('abort', abort, { once: true });
124
- const timer = Number.isFinite(timeoutMs) && timeoutMs > 0 ? setTimeout(() => stop('Process timed out; checkpoint remains closed.'), timeoutMs) : undefined;
125
- child.stdout.setEncoding('utf8');
126
- child.stdout.on('data', data => {
127
- if (observer.streamOnly) {
128
- // Reviewer CLI tool reads can be much larger than the final report.
129
- // Consume all events without retaining the full transcript in RAM.
130
- output = (output + data).slice(-12000); observer.onData?.(data);
131
- } else {
132
- size += Buffer.byteLength(data);
133
- output += data; observer.onData?.(data);
134
- }
135
- });
136
- child.stderr.setEncoding('utf8');
137
- child.stderr.on('data', data => { if(observer.captureStderr) output=(output+data).slice(-12000); }); // Premium CLI stderr remains private.
138
- child.stdin.on('error', () => {});
139
- child.on('error', e => { failure = e; });
140
- child.on('close', code => {
141
- clearTimeout(timer);clearTimeout(reapTimer); signal?.removeEventListener('abort', abort);
142
- if(settled)return;settled=true;
143
- if (failure || code !== 0) {
144
- const error = failure ?? new Error(`Process exited ${code}; no approval recorded.`);
145
- error.output = output.slice(-12000); error.exitCode = code; reject(error);
146
- }
147
- else resolve(output);
148
- });
149
- child.stdin.end(input);
150
- });
151
- }
152
-
153
- export async function runWorker(who, prompt, config, folder, signal, metadata = {}) {
154
- const lane=reviewer(config,who);
155
- if(lane.adapter==='opencode') {
156
- if(!config.nativeRunner)throw Error('OpenCode reviewer adapter requires the active OpenCode plugin host.');
157
- return config.nativeRunner(who,prompt,config,folder,signal,metadata);
158
- }
159
- const telemetry = new Telemetry(who, config, folder, metadata);
160
- let envelope;
161
- const stream = jsonLineReader(event => { telemetry.event(event); if (event.type === 'result') envelope = event; });
162
- const heartbeat = setInterval(() => telemetry.save(), 5000); heartbeat.unref();
163
- try {
164
- if (signal?.aborted) throw new Error('Cancelled before launch.');
165
- requireSubscription(who, config, folder);
166
- writeFileSync(path.join(folder, 'schema.json'), JSON.stringify(resultSchema));
167
- const browser=prepareBrowser(config,folder);
168
- const evidence=evidenceServer(folder);
169
- const args = workerArgs(who, config, folder, metadata.images ?? [],browser,evidence);
170
- if(evidence)prompt=queryInstructions+'\n\n'+prompt;
171
- if(browser){prompt=browserInstructions+'\n\n'+prompt;telemetry.value.browserAvailable=true;telemetry.save();}
172
- telemetry.prompt(prompt);
173
- const executable = lane.executable;
174
- const stdout = await runProcess(executable, args, folder, prompt, config.reviewTimeoutMs ?? 0, signal, {
175
- streamOnly: true, onStart: pid => telemetry.start(pid), onData: text => stream.push(text),
176
- });
177
- stream.finish();
178
- let result;
179
- if (lane.adapter === 'codex-cli') result = JSON.parse(readFileSync(path.join(folder, 'result.json'), 'utf8'));
180
- else {
181
- if (!envelope) { try { envelope = JSON.parse(stdout); telemetry.event(envelope); } catch {} }
182
- if (!envelope) throw new Error('Claude produced no final result event; checkpoint stays closed.');
183
- if (envelope.is_error || envelope.subtype !== 'success') throw new Error('Claude did not complete successfully; checkpoint stays closed.');
184
- result = envelope.structured_output;
185
- if (!result) {
186
- // Some CLI runs return the schema JSON as the final result string.
187
- // Accept only exact JSON validated below, never prose or inferred approval.
188
- try { result = JSON.parse(envelope.result); } catch {}
189
- if (!result) throw new Error(`Claude returned no schema result (fields: ${Object.keys(envelope).join(',')}; result type: ${typeof envelope.result}); checkpoint stays closed.`);
190
- }
191
- }
192
- try { validateResult(result); telemetry.end('completed', result.verdict); return result; }
193
- catch {
194
- throw new Error(`${who}: invalid reviewer schema (verdict type ${typeof result?.verdict}; report type ${typeof result?.report}; findings count ${Array.isArray(result?.findings) ? result.findings.length : 'not-array'}). Checkpoint stays closed.`);
195
- }
196
- } catch (error) {
197
- if(error.unreapedPid){telemetry.value.workerExitUnconfirmed=true;telemetry.value.descendantsUnconfirmed=!!error.descendantsUnconfirmed;}
198
- stream.finish(); telemetry.end(signal?.aborted ? 'cancelled' : 'failed');
199
- delete error.output; // Never expose raw verbose CLI event tails to OpenCode logs.
200
- throw error;
201
- } finally { clearInterval(heartbeat); }
202
- }
1
+ import {documentTool} from '../lib/public-document.mjs';
2
+ import {reviewer} from '../lib/reviewers.mjs';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { readFileSync, writeFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { Telemetry, jsonLineReader } from './telemetry.mjs';
7
+ import { findingSchema, isMajor } from './decision.mjs';
8
+ import {processIdentity} from './process-identity.mjs';
9
+ import {commandSpec} from '../lib/commands.mjs';
10
+ import {commandEnvironment} from '../lib/platform.mjs';
11
+ import {prepareBrowser,browserTools,browserInstructions} from '../lib/reviewer-browser.mjs';
12
+ import {evidenceServer,queryInstructions,queryTool} from '../lib/evidence-mcp.mjs';
13
+
14
+ export const resultSchema = {
15
+ type: 'object', additionalProperties: false,
16
+ properties: {
17
+ verdict: { type: 'string', enum: ['pass', 'revise', 'blocked'] },
18
+ report: { type: 'string' },
19
+ findings: { type: 'array', items: findingSchema },
20
+ verifiedResolutions: {type:'array',items:{type:'object',additionalProperties:false,properties:{id:{type:'string'},evidence:{type:'string'}},required:['id','evidence']}},
21
+ }, required: ['verdict', 'report', 'findings', 'verifiedResolutions'],
22
+ };
23
+
24
+ export function workerArgs(who, config, folder, images = [], browser = null, evidence = null) {
25
+ const lane=reviewer(config,who);
26
+ // Attachment count is a transport choice, never an evidence quota. Larger
27
+ // sets stay in the frozen images/ manifest and are opened with view_image.
28
+ const inlineImages = images.length <= 4 && images.join('').length < 2048 ? images : [];
29
+ if (lane.adapter === 'codex-cli') return ['exec', '--json', '--ignore-user-config', '--ignore-rules', '--ephemeral', '--skip-git-repo-check',
30
+ '--sandbox', 'read-only', '-C', folder, '-m', lane.model,
31
+ // User config is intentionally isolated, so Windows' required sandbox
32
+ // implementation must be explicit too. Without it shell reads are denied.
33
+ ...(process.platform === 'win32' ? ['-c', 'windows.sandbox="elevated"'] : []),
34
+ '-c', 'approval_policy="never"',
35
+ ...(lane.effort==='default'?[]:['-c', `model_reasoning_effort="${lane.effort??'high'}"`]), '-c', 'web_search="disabled"',
36
+ ...(browser ? ['-c',`mcp_servers.review_browser.command=${JSON.stringify(browser.command)}`,
37
+ '-c',`mcp_servers.review_browser.args=${JSON.stringify(browser.args)}`,
38
+ '-c','mcp_servers.review_browser.startup_timeout_sec=60',
39
+ '-c','mcp_servers.review_browser.tool_timeout_sec=120',
40
+ '-c','mcp_servers.review_browser.default_tools_approval_mode="approve"',
41
+ '-c',`mcp_servers.review_browser.enabled_tools=${JSON.stringify(browserTools)}`] : []),
42
+ ...(evidence?['-c',`mcp_servers.review_evidence.command=${JSON.stringify(evidence.command)}`,'-c',`mcp_servers.review_evidence.args=${JSON.stringify(evidence.args)}`,
43
+ '-c','mcp_servers.review_evidence.default_tools_approval_mode="approve"','-c',`mcp_servers.review_evidence.enabled_tools=${JSON.stringify([queryTool,documentTool])}`]:[]),
44
+ ...inlineImages.flatMap(image=>['--image',image]),
45
+ '--output-schema', path.join(folder, 'schema.json'), '-o', path.join(folder, 'result.json'), '-'];
46
+ return ['-p', ...(browser||evidence?['--setting-sources','','--disable-slash-commands','--settings','{"disableAllHooks":true,"enabledPlugins":{}}']:['--safe-mode']), '--restricted', '--strict-mcp-config', '--mcp-config', JSON.stringify({mcpServers:{...(browser?{review_browser:browser}:{}),...(evidence?{review_evidence:evidence}:{})}}),
47
+ '--no-chrome', '--no-session-persistence', '--permission-mode', 'dontAsk', '--permission-prompts', 'none',
48
+ '--tools', 'Read,Glob,Grep', '--allowedTools', ['Read','Glob','Grep',...(browser?browserTools.map(name=>'mcp__review_browser__'+name):[]),...(evidence?[queryTool,documentTool].map(name=>'mcp__review_evidence__'+name):[])].join(','),
49
+ '--model', lane.model, ...(lane.effort==='default'?[]:['--effort',lane.effort??'high']), '--output-format', 'stream-json', '--verbose',
50
+ '--json-schema', JSON.stringify(resultSchema)];
51
+ }
52
+
53
+ export function validateResult(value) {
54
+ if (!value || !['pass', 'revise', 'blocked'].includes(value.verdict) || typeof value.report !== 'string' || !value.report.trim() ||
55
+ !Array.isArray(value.findings)) throw new Error('Invalid reviewer result; checkpoint stays closed.');
56
+ if (Object.keys(value).some(key => !['verdict', 'report', 'findings','verifiedResolutions'].includes(key))) throw new Error('Invalid reviewer result fields.');
57
+ // Existing receipts remain readable. Omission cannot resolve tracked findings.
58
+ if(value.verifiedResolutions!==undefined&&(!Array.isArray(value.verifiedResolutions)||value.verifiedResolutions.some(r=>!r||Object.keys(r).some(k=>!['id','evidence'].includes(k))||typeof r.id!=='string'||!r.id.trim()||typeof r.evidence!=='string'||!r.evidence.trim())||new Set(value.verifiedResolutions.map(r=>r.id)).size!==value.verifiedResolutions.length))throw Error('Invalid verified resolutions.');
59
+ const ids = new Set();
60
+ for (const f of value.findings) {
61
+ if (!f || typeof f !== 'object' || Array.isArray(f) ||
62
+ Object.keys(f).some(key => !findingSchema.required.includes(key)) ||
63
+ findingSchema.required.some(key => typeof f[key] !== 'string' || !f[key].trim()) ||
64
+ !findingSchema.properties.severity.enum.includes(f.severity) ||
65
+ !findingSchema.properties.category.enum.includes(f.category) || ids.has(f.id) ||
66
+ (f.category === 'preference' && isMajor(f))) throw new Error('Invalid structured finding; checkpoint stays closed.');
67
+ ids.add(f.id);
68
+ }
69
+ if ((value.verdict === 'pass' && value.findings.some(isMajor)) ||
70
+ (value.verdict === 'revise' && !value.findings.length) ||
71
+ (value.verdict === 'blocked' && !value.findings.some(isMajor))) throw new Error('Inconsistent reviewer verdict; checkpoint stays closed.');
72
+ return value;
73
+ }
74
+
75
+ export function workerEnvironment(source = process.env) {
76
+ const env = {};
77
+ const allowed = /^(PATH|PATHEXT|SYSTEMROOT|WINDIR|COMSPEC|TEMP|TMP|TMPDIR|HOME|XDG_CONFIG_HOME|XDG_DATA_HOME|XDG_STATE_HOME|XDG_CACHE_HOME|USERPROFILE|APPDATA|LOCALAPPDATA|HOMEDRIVE|HOMEPATH|USERNAME|USERDOMAIN|PROGRAMFILES|PROGRAMFILES\(X86\)|PROGRAMW6432|OS|SYSTEMDRIVE|LANG|LC_ALL)$/i;
78
+ for (const [key, value] of Object.entries(source)) if (allowed.test(key)) env[key] = value;
79
+ // Official CLIs retain their normal per-user official login; never extract tokens.
80
+ return commandEnvironment(env);
81
+ }
82
+
83
+ export function requireCliAuthentication(who, config, folder, invoke = spawnSync) {
84
+ const lane=reviewer(config,who);
85
+ if(lane.adapter==='opencode')return;
86
+ const executable=lane.executable;
87
+ const args = lane.adapter === 'codex-cli' ? ['login', 'status'] : ['auth', 'status'];
88
+ const spec=invoke===spawnSync?commandSpec(executable):{executable,args:[]};
89
+ const result = invoke(spec.executable, [...spec.args,...args], { cwd: folder, env: workerEnvironment(), shell: false, windowsHide: true, encoding: 'utf8', timeout: 15000 });
90
+ let valid = false;
91
+ if (result.status === 0 && lane.adapter === 'codex-cli') valid = /^Logged in using (?:ChatGPT|(?:an? )?API key)(?:\s|$)/im.test((result.stdout ?? '') + '\n' + (result.stderr ?? ''));
92
+ if (result.status === 0 && lane.adapter === 'claude-cli') {
93
+ try { const auth = JSON.parse(result.stdout); valid = auth.loggedIn === true && ['claude.ai','oauth_token','api_key'].includes(auth.authMethod) && auth.apiProvider === 'firstParty'; } catch {}
94
+ }
95
+ if (!valid) throw new Error(`${who}: official CLI authentication could not be confirmed. Sign in through the selected CLI using its subscription or Console/API login. Unknown or unsupported authentication is refused.`);
96
+ }
97
+
98
+ export async function runProcess(executable, args, folder, input, timeoutMs, signal, observer = {}) {
99
+ if (signal?.aborted) throw new Error('Cancelled before launch.');
100
+ return await new Promise((resolve, reject) => {
101
+ const spec=commandSpec(executable);
102
+ const child = spawn(spec.executable, [...spec.args,...args], { cwd: folder, env: workerEnvironment(), shell: false, windowsHide: true, detached:process.platform!=='win32', stdio: ['pipe', 'pipe', 'pipe'] });
103
+ child.once('spawn', () => observer.onStart?.(child.pid));
104
+ let output = '', failure, size = 0, reapTimer, settled=false;
105
+ const stop = reason => {
106
+ if(failure)return;
107
+ failure ??= new Error(reason);
108
+ if (process.platform === 'win32' && child.pid && child.exitCode === null) {
109
+ spawnSync(path.join(process.env.SystemRoot ?? 'C:/Windows', 'System32/taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 5000 });
110
+ } else if(child.pid) { try {process.kill(-child.pid,'SIGTERM');} catch {child.kill();} }
111
+ // A failed OS termination must not hang the tool forever. Retain an explicit
112
+ // live-worker guard so returning an error never unlocks concurrent writers.
113
+ reapTimer=setTimeout(()=>{
114
+ if(settled)return;settled=true;clearTimeout(timer);signal?.removeEventListener('abort',abort);
115
+ failure.unreapedPid=child.pid;failure.workerIdentity=processIdentity(child.pid);
116
+ // An exited root does not prove its descendants exited: inherited pipes
117
+ // can keep this call open. Never clear this uncertainty by PID lookup.
118
+ failure.descendantsUnconfirmed=true;
119
+ child.stdout.destroy();child.stderr.destroy();child.stdin.destroy();child.unref();reject(failure);
120
+ },5000);
121
+ };
122
+ const abort = () => stop('Review cancelled; checkpoint remains closed.');
123
+ signal?.addEventListener('abort', abort, { once: true });
124
+ const timer = Number.isFinite(timeoutMs) && timeoutMs > 0 ? setTimeout(() => stop('Process timed out; checkpoint remains closed.'), timeoutMs) : undefined;
125
+ child.stdout.setEncoding('utf8');
126
+ child.stdout.on('data', data => {
127
+ if (observer.streamOnly) {
128
+ // Reviewer CLI tool reads can be much larger than the final report.
129
+ // Consume all events without retaining the full transcript in RAM.
130
+ output = (output + data).slice(-12000); observer.onData?.(data);
131
+ } else {
132
+ size += Buffer.byteLength(data);
133
+ output += data; observer.onData?.(data);
134
+ }
135
+ });
136
+ child.stderr.setEncoding('utf8');
137
+ child.stderr.on('data', data => { if(observer.captureStderr) output=(output+data).slice(-12000); }); // Premium CLI stderr remains private.
138
+ child.stdin.on('error', () => {});
139
+ child.on('error', e => { failure = e; });
140
+ child.on('close', code => {
141
+ clearTimeout(timer);clearTimeout(reapTimer); signal?.removeEventListener('abort', abort);
142
+ if(settled)return;settled=true;
143
+ if (failure || code !== 0) {
144
+ const error = failure ?? new Error(`Process exited ${code}; no approval recorded.`);
145
+ error.output = output.slice(-12000); error.exitCode = code; reject(error);
146
+ }
147
+ else resolve(output);
148
+ });
149
+ child.stdin.end(input);
150
+ });
151
+ }
152
+
153
+ export async function runWorker(who, prompt, config, folder, signal, metadata = {}) {
154
+ const lane=reviewer(config,who);
155
+ if(lane.adapter==='opencode') {
156
+ if(!config.nativeRunner)throw Error('OpenCode reviewer adapter requires the active OpenCode plugin host.');
157
+ return config.nativeRunner(who,prompt,config,folder,signal,metadata);
158
+ }
159
+ const telemetry = new Telemetry(who, config, folder, metadata);
160
+ let envelope;
161
+ const stream = jsonLineReader(event => { telemetry.event(event); if (event.type === 'result') envelope = event; });
162
+ const heartbeat = setInterval(() => telemetry.save(), 5000); heartbeat.unref();
163
+ try {
164
+ if (signal?.aborted) throw new Error('Cancelled before launch.');
165
+ requireCliAuthentication(who, config, folder);
166
+ writeFileSync(path.join(folder, 'schema.json'), JSON.stringify(resultSchema));
167
+ const browser=prepareBrowser(config,folder);
168
+ const evidence=evidenceServer(folder);
169
+ const args = workerArgs(who, config, folder, metadata.images ?? [],browser,evidence);
170
+ if(evidence)prompt=queryInstructions+'\n\n'+prompt;
171
+ if(browser){prompt=browserInstructions+'\n\n'+prompt;telemetry.value.browserAvailable=true;telemetry.save();}
172
+ telemetry.prompt(prompt);
173
+ const executable = lane.executable;
174
+ const stdout = await runProcess(executable, args, folder, prompt, config.reviewTimeoutMs ?? 0, signal, {
175
+ streamOnly: true, onStart: pid => telemetry.start(pid), onData: text => stream.push(text),
176
+ });
177
+ stream.finish();
178
+ let result;
179
+ if (lane.adapter === 'codex-cli') result = JSON.parse(readFileSync(path.join(folder, 'result.json'), 'utf8'));
180
+ else {
181
+ if (!envelope) { try { envelope = JSON.parse(stdout); telemetry.event(envelope); } catch {} }
182
+ if (!envelope) throw new Error('Claude produced no final result event; checkpoint stays closed.');
183
+ if (envelope.is_error || envelope.subtype !== 'success') throw new Error('Claude did not complete successfully; checkpoint stays closed.');
184
+ result = envelope.structured_output;
185
+ if (!result) {
186
+ // Some CLI runs return the schema JSON as the final result string.
187
+ // Accept only exact JSON validated below, never prose or inferred approval.
188
+ try { result = JSON.parse(envelope.result); } catch {}
189
+ if (!result) throw new Error(`Claude returned no schema result (fields: ${Object.keys(envelope).join(',')}; result type: ${typeof envelope.result}); checkpoint stays closed.`);
190
+ }
191
+ }
192
+ try { validateResult(result); telemetry.end('completed', result.verdict); return result; }
193
+ catch {
194
+ throw new Error(`${who}: invalid reviewer schema (verdict type ${typeof result?.verdict}; report type ${typeof result?.report}; findings count ${Array.isArray(result?.findings) ? result.findings.length : 'not-array'}). Checkpoint stays closed.`);
195
+ }
196
+ } catch (error) {
197
+ if(error.unreapedPid){telemetry.value.workerExitUnconfirmed=true;telemetry.value.descendantsUnconfirmed=!!error.descendantsUnconfirmed;}
198
+ stream.finish(); telemetry.end(signal?.aborted ? 'cancelled' : 'failed');
199
+ delete error.output; // Never expose raw verbose CLI event tails to OpenCode logs.
200
+ throw error;
201
+ } finally { clearInterval(heartbeat); }
202
+ }
@@ -34,6 +34,12 @@ redirect reviewer executable settings to its own file. The installer supports
34
34
  `--config-dir PATH` for a separate profile.
35
35
  Do not put credentials in this file; OpenCode or the selected official CLI owns authentication.
36
36
 
37
+ CLI reviewers use the account already signed in through that CLI, including
38
+ Claude Console/API billing or a subscription. The plugin does not switch accounts,
39
+ copy keys or inherit API-key environment overrides. Billing follows the CLI login
40
+ you selected. For environment-key or cloud-provider setups, connect the provider
41
+ through OpenCode and select that provider/model in Hybrid Workflow settings.
42
+
37
43
  `opencode-hybrid configure` opens an interactive selector: choose one or two
38
44
  reviewers, OpenCode provider or external CLI for each, model, supported variant/CLI effort, and the
39
45
  planning/design lead. Either slot can use any supported adapter, including two
@@ -0,0 +1,67 @@
1
+ # Engineering handoffs
2
+
3
+ Hybrid Workflow turns the premium models' selected architecture into a compact responsibility map inside the existing approved report. Implementation agents follow it; reviewers compare it to the affected code. There is no additional setup, phase, file-length limit or compulsory architecture for a small change.
4
+
5
+ For a substantial editor, the handoff names the view, draft/request owner, recovery adapter and authoritative validation boundary. It states what happens when a response arrives after navigation or a record change. A small extension can simply name the existing component and the behavior being added.
6
+
7
+ The checkpoint returns `engineeringHandoff`, and reviewers receive `handoffChecks`. These describe contract consistency and whether declared files exist in the captured source. They do **not** prove that code follows the contract or that an acceptance check ran. A future module missing from an intermediate milestone is not automatically a defect. Existing prose plans remain usable.
8
+
9
+ ## Example: a bounded save workflow
10
+
11
+ The premium plan may include this block alongside its explanation. Paths are exact project-relative paths, not commands or globs. State lists identify authoritative state, not every derived display value. The actual project determines the boundaries; these names are illustrative.
12
+
13
+ ```hybrid-handoff
14
+ {
15
+ "version": 1,
16
+ "scope": "Save and leave an existing editor",
17
+ "owners": [
18
+ {
19
+ "id": "view",
20
+ "responsibility": "Render the form and forward user events",
21
+ "paths": ["src/Editor.tsx"],
22
+ "boundary": "client",
23
+ "state": [],
24
+ "inputs": ["session state"],
25
+ "outputs": ["edit, save, leave"],
26
+ "dependsOn": ["session"]
27
+ },
28
+ {
29
+ "id": "session",
30
+ "responsibility": "Own draft transitions and ignore results after departure or record change",
31
+ "paths": ["src/editor-session.ts"],
32
+ "boundary": "client",
33
+ "state": ["draft", "recordId", "requestGeneration", "departed"],
34
+ "inputs": ["edit, save, leave", "save result"],
35
+ "outputs": ["render state", "recovery write/remove intent"],
36
+ "dependsOn": ["save"]
37
+ },
38
+ {
39
+ "id": "save",
40
+ "responsibility": "Authorize and validate before committing a record",
41
+ "paths": ["src/server/save.ts"],
42
+ "boundary": "server",
43
+ "state": [],
44
+ "inputs": ["authenticated request, recordId, draft"],
45
+ "outputs": ["committed result or validation/conflict error"],
46
+ "dependsOn": []
47
+ }
48
+ ],
49
+ "scenarios": [
50
+ {
51
+ "id": "leave-during-save",
52
+ "owners": ["view", "session", "save"],
53
+ "action": "Begin a save, confirm leaving, then deliver the response",
54
+ "expected": "No client redirect or recovered draft resurrection; do not claim the server commit was canceled",
55
+ "evidence": "Deferred-response test and representative browser navigation check"
56
+ }
57
+ ]
58
+ }
59
+ ```
60
+
61
+ Design documents add a map only when they change engineering ownership. An explicit approved amendment must reconcile conflicting contracts; a later design cannot silently erase the plan. Visual direction still belongs to the configured premium lead. References, typography, tokens and representative states stay in the design document.
62
+
63
+ ## Review and implementation
64
+
65
+ At the first meaningful milestone of a new pattern, inspect one implementation before replicating it. Trace view -> state -> domain rules -> persistence and lifecycle where applicable. Check actual owner paths, shared client/server contracts and stale-response handling. A giant hook or a JSX-only split is not proof of separation. Conversely, a cohesive component is acceptable regardless of file length.
66
+
67
+ Implementation handoffs distinguish intended scenarios from executed checks, failed checks and untested behavior. Parsing the map never executes its evidence text or follows its paths outside the frozen inventory. Reviewers assign findings only for concrete consequences. The existing rule remains: all configured current-artifact reviews, at least one pass and no unresolved medium/high/critical issues.