@rockysoft/opencode-hybrid-workflow 0.9.0 → 1.0.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/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
+ }
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Release notes
2
2
 
3
+ ## 1.0.0
4
+
5
+ - Carry reviewed project ownership hints into subsequent tasks, with project/source bindings and automatic invalidation when files change. Hints never replace source inspection or approval.
6
+ - Show accurate resume context, pending findings and verification freshness.
7
+ - Order stable context consistently without removing evidence. No token-saving or speed improvement is claimed.
8
+ - Correct UI/UX Pro Max instructions for macOS/Linux and Windows.
9
+ - Exclude generated Python bytecode from package and managed skill installation.
10
+
11
+ ## 0.9.1
12
+
13
+ - Recognize Claude Console/API authentication when using the optional Claude CLI adapter.
14
+
15
+ ## 0.9.0
16
+
17
+ - Add structured engineering handoffs for responsibility boundaries, state ownership and behavioral acceptance checks.
18
+ - Keep material architecture and design choices with the configured planning/design reviewers while implementation specialists follow approved contracts.
19
+ - Preserve proportional review of small changes without file-length quotas.
20
+
3
21
  ## 0.8.0
4
22
 
5
23
  - Capture repeatable frontend scenarios, screenshots and assertions once for both reviewers.
@@ -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
@@ -126,3 +132,9 @@ machine rather than copying account credentials.
126
132
  ## Frozen source lookup
127
133
 
128
134
  Reviewers automatically receive `hybrid_evidence_query` (under `review_evidence` for CLI connections). It supports inventory, literal case-sensitive search and batch line reads within the captured source snapshot. Results include hashes, snapshot identity, UTF-16 offsets and continuation fields. Missing, withheld or changed captures are explicitly unavailable. Existing file/image readers remain available. No symbol analysis, regex execution or live-project reads are added. Query contents never enter usage logs; CLI query measurements contain only operation names, sizes, duration and status.
135
+
136
+ ## Project context and resume
137
+
138
+ New tasks keep stable contracts ahead of changing review details. Full skills, plans and frozen source remain accessible through the existing evidence transport. Cache behavior is managed by your provider, so stable prompts do not guarantee cache hits or lower charges.
139
+
140
+ After an accepted code review, structured engineering handoffs can supply project-specific ownership hints. Any captured source/configuration change makes these hints stale; conflicting plan/design owners are omitted. Hints cannot approve work or replace verification. The checkpoint status includes current contracts, pending findings, verification freshness and the next permitted step for resuming work. Existing tasks retain their earlier transport; no conversation is reset.
@@ -0,0 +1,20 @@
1
+ import {hash} from '../core/snapshot.mjs';
2
+
3
+ // Order object keys, never arrays: source order and reviewer precedence can matter.
4
+ export function canonical(value) {
5
+ if(Array.isArray(value))return value.map(canonical);
6
+ if(value&&typeof value==='object')return Object.fromEntries(Object.keys(value).sort().filter(key=>value[key]!==undefined).map(key=>[key,canonical(value[key])]));
7
+ return value;
8
+ }
9
+ export const stableJSON = value => JSON.stringify(canonical(value));
10
+ const stableKeys=['brief','plan','design','skills'];
11
+ export function orderContext(packet) {
12
+ const ordered=canonical(packet);
13
+ return Object.fromEntries([...stableKeys.filter(key=>Object.hasOwn(ordered,key)),...Object.keys(ordered).filter(key=>!stableKeys.includes(key))].map(key=>[key,ordered[key]]));
14
+ }
15
+ export function contextReceipt(packet) {
16
+ const stable=Object.fromEntries(stableKeys.filter(key=>Object.hasOwn(packet,key)).map(key=>[key,packet[key]]));
17
+ const text=stableJSON(stable);
18
+ return {version:1,contractsHash:hash(text),contractsChars:text.length,layout:'stable-fields-first',
19
+ cacheControl:'provider-managed',cacheHit:'unknown',note:'Canonical field ordering adds no prompt text. No explicit provider cache control, TTL, token saving or cache-hit guarantee.'};
20
+ }