@rockysoft/opencode-hybrid-workflow 1.2.0 → 1.3.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,205 @@
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
- }
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
+ import {recoverySchema} from './planning-escalation.mjs';
14
+
15
+ export const resultSchema = {
16
+ type: 'object', additionalProperties: false,
17
+ properties: {
18
+ verdict: { type: 'string', enum: ['pass', 'revise', 'blocked'] },
19
+ report: { type: 'string' },
20
+ findings: { type: 'array', items: findingSchema },
21
+ recovery: recoverySchema,
22
+ verifiedResolutions: {type:'array',items:{type:'object',additionalProperties:false,properties:{id:{type:'string'},evidence:{type:'string'}},required:['id','evidence']}},
23
+ }, required: ['verdict', 'report', 'findings', 'verifiedResolutions', 'recovery'],
24
+ };
25
+
26
+ export function workerArgs(who, config, folder, images = [], browser = null, evidence = null) {
27
+ const lane=reviewer(config,who);
28
+ // Attachment count is a transport choice, never an evidence quota. Larger
29
+ // sets stay in the frozen images/ manifest and are opened with view_image.
30
+ const inlineImages = images.length <= 4 && images.join('').length < 2048 ? images : [];
31
+ if (lane.adapter === 'codex-cli') return ['exec', '--json', '--ignore-user-config', '--ignore-rules', '--ephemeral', '--skip-git-repo-check',
32
+ '--sandbox', 'read-only', '-C', folder, '-m', lane.model,
33
+ // User config is intentionally isolated, so Windows' required sandbox
34
+ // implementation must be explicit too. Without it shell reads are denied.
35
+ ...(process.platform === 'win32' ? ['-c', 'windows.sandbox="elevated"'] : []),
36
+ '-c', 'approval_policy="never"',
37
+ ...(lane.effort==='default'?[]:['-c', `model_reasoning_effort="${lane.effort??'high'}"`]), '-c', 'web_search="disabled"',
38
+ ...(browser ? ['-c',`mcp_servers.review_browser.command=${JSON.stringify(browser.command)}`,
39
+ '-c',`mcp_servers.review_browser.args=${JSON.stringify(browser.args)}`,
40
+ '-c','mcp_servers.review_browser.startup_timeout_sec=60',
41
+ '-c','mcp_servers.review_browser.tool_timeout_sec=120',
42
+ '-c','mcp_servers.review_browser.default_tools_approval_mode="approve"',
43
+ '-c',`mcp_servers.review_browser.enabled_tools=${JSON.stringify(browserTools)}`] : []),
44
+ ...(evidence?['-c',`mcp_servers.review_evidence.command=${JSON.stringify(evidence.command)}`,'-c',`mcp_servers.review_evidence.args=${JSON.stringify(evidence.args)}`,
45
+ '-c','mcp_servers.review_evidence.default_tools_approval_mode="approve"','-c',`mcp_servers.review_evidence.enabled_tools=${JSON.stringify([queryTool,documentTool])}`]:[]),
46
+ ...inlineImages.flatMap(image=>['--image',image]),
47
+ '--output-schema', path.join(folder, 'schema.json'), '-o', path.join(folder, 'result.json'), '-'];
48
+ 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}:{})}}),
49
+ '--no-chrome', '--no-session-persistence', '--permission-mode', 'dontAsk', '--permission-prompts', 'none',
50
+ '--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(','),
51
+ '--model', lane.model, ...(lane.effort==='default'?[]:['--effort',lane.effort??'high']), '--output-format', 'stream-json', '--verbose',
52
+ '--json-schema', JSON.stringify(resultSchema)];
53
+ }
54
+
55
+ export function validateResult(value) {
56
+ if (!value || !['pass', 'revise', 'blocked'].includes(value.verdict) || typeof value.report !== 'string' || !value.report.trim() ||
57
+ !Array.isArray(value.findings)) throw new Error('Invalid reviewer result; checkpoint stays closed.');
58
+ if (Object.keys(value).some(key => !['verdict', 'report', 'findings','verifiedResolutions','recovery'].includes(key))) throw new Error('Invalid reviewer result fields.');
59
+ if(value.recovery!=null&&(!value.recovery||typeof value.recovery!=='object'||Array.isArray(value.recovery)||Object.keys(value.recovery).some(k=>!['kind','instructions'].includes(k))||!recoverySchema.properties.kind.enum.includes(value.recovery.kind)||typeof value.recovery.instructions!=='string'||!value.recovery.instructions.trim()))throw Error('Invalid planning recovery directive.');
60
+ // Existing receipts remain readable. Omission cannot resolve tracked findings.
61
+ 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.');
62
+ const ids = new Set();
63
+ for (const f of value.findings) {
64
+ if (!f || typeof f !== 'object' || Array.isArray(f) ||
65
+ Object.keys(f).some(key => !findingSchema.required.includes(key)) ||
66
+ findingSchema.required.some(key => typeof f[key] !== 'string' || !f[key].trim()) ||
67
+ !findingSchema.properties.severity.enum.includes(f.severity) ||
68
+ !findingSchema.properties.category.enum.includes(f.category) || ids.has(f.id) ||
69
+ (f.category === 'preference' && isMajor(f))) throw new Error('Invalid structured finding; checkpoint stays closed.');
70
+ ids.add(f.id);
71
+ }
72
+ if ((value.verdict === 'pass' && value.findings.some(isMajor)) ||
73
+ (value.verdict === 'revise' && !value.findings.length) ||
74
+ (value.verdict === 'blocked' && !value.findings.some(isMajor))) throw new Error('Inconsistent reviewer verdict; checkpoint stays closed.');
75
+ return value;
76
+ }
77
+
78
+ export function workerEnvironment(source = process.env) {
79
+ const env = {};
80
+ 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;
81
+ for (const [key, value] of Object.entries(source)) if (allowed.test(key)) env[key] = value;
82
+ // Official CLIs retain their normal per-user official login; never extract tokens.
83
+ return commandEnvironment(env);
84
+ }
85
+
86
+ export function requireCliAuthentication(who, config, folder, invoke = spawnSync) {
87
+ const lane=reviewer(config,who);
88
+ if(lane.adapter==='opencode')return;
89
+ const executable=lane.executable;
90
+ const args = lane.adapter === 'codex-cli' ? ['login', 'status'] : ['auth', 'status'];
91
+ const spec=invoke===spawnSync?commandSpec(executable):{executable,args:[]};
92
+ const result = invoke(spec.executable, [...spec.args,...args], { cwd: folder, env: workerEnvironment(), shell: false, windowsHide: true, encoding: 'utf8', timeout: 15000 });
93
+ let valid = false;
94
+ if (result.status === 0 && lane.adapter === 'codex-cli') valid = /^Logged in using (?:ChatGPT|(?:an? )?API key)(?:\s|$)/im.test((result.stdout ?? '') + '\n' + (result.stderr ?? ''));
95
+ if (result.status === 0 && lane.adapter === 'claude-cli') {
96
+ try { const auth = JSON.parse(result.stdout); valid = auth.loggedIn === true && ['claude.ai','oauth_token','api_key'].includes(auth.authMethod) && auth.apiProvider === 'firstParty'; } catch {}
97
+ }
98
+ 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.`);
99
+ }
100
+
101
+ export async function runProcess(executable, args, folder, input, timeoutMs, signal, observer = {}) {
102
+ if (signal?.aborted) throw new Error('Cancelled before launch.');
103
+ return await new Promise((resolve, reject) => {
104
+ const spec=commandSpec(executable);
105
+ const child = spawn(spec.executable, [...spec.args,...args], { cwd: folder, env: workerEnvironment(), shell: false, windowsHide: true, detached:process.platform!=='win32', stdio: ['pipe', 'pipe', 'pipe'] });
106
+ child.once('spawn', () => observer.onStart?.(child.pid));
107
+ let output = '', failure, size = 0, reapTimer, settled=false;
108
+ const stop = reason => {
109
+ if(failure)return;
110
+ failure ??= new Error(reason);
111
+ if (process.platform === 'win32' && child.pid && child.exitCode === null) {
112
+ spawnSync(path.join(process.env.SystemRoot ?? 'C:/Windows', 'System32/taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 5000 });
113
+ } else if(child.pid) { try {process.kill(-child.pid,'SIGTERM');} catch {child.kill();} }
114
+ // A failed OS termination must not hang the tool forever. Retain an explicit
115
+ // live-worker guard so returning an error never unlocks concurrent writers.
116
+ reapTimer=setTimeout(()=>{
117
+ if(settled)return;settled=true;clearTimeout(timer);signal?.removeEventListener('abort',abort);
118
+ failure.unreapedPid=child.pid;failure.workerIdentity=processIdentity(child.pid);
119
+ // An exited root does not prove its descendants exited: inherited pipes
120
+ // can keep this call open. Never clear this uncertainty by PID lookup.
121
+ failure.descendantsUnconfirmed=true;
122
+ child.stdout.destroy();child.stderr.destroy();child.stdin.destroy();child.unref();reject(failure);
123
+ },5000);
124
+ };
125
+ const abort = () => stop('Review cancelled; checkpoint remains closed.');
126
+ signal?.addEventListener('abort', abort, { once: true });
127
+ const timer = Number.isFinite(timeoutMs) && timeoutMs > 0 ? setTimeout(() => stop('Process timed out; checkpoint remains closed.'), timeoutMs) : undefined;
128
+ child.stdout.setEncoding('utf8');
129
+ child.stdout.on('data', data => {
130
+ if (observer.streamOnly) {
131
+ // Reviewer CLI tool reads can be much larger than the final report.
132
+ // Consume all events without retaining the full transcript in RAM.
133
+ output = (output + data).slice(-12000); observer.onData?.(data);
134
+ } else {
135
+ size += Buffer.byteLength(data);
136
+ output += data; observer.onData?.(data);
137
+ }
138
+ });
139
+ child.stderr.setEncoding('utf8');
140
+ child.stderr.on('data', data => { if(observer.captureStderr) output=(output+data).slice(-12000); }); // Premium CLI stderr remains private.
141
+ child.stdin.on('error', () => {});
142
+ child.on('error', e => { failure = e; });
143
+ child.on('close', code => {
144
+ clearTimeout(timer);clearTimeout(reapTimer); signal?.removeEventListener('abort', abort);
145
+ if(settled)return;settled=true;
146
+ if (failure || code !== 0) {
147
+ const error = failure ?? new Error(`Process exited ${code}; no approval recorded.`);
148
+ error.output = output.slice(-12000); error.exitCode = code; reject(error);
149
+ }
150
+ else resolve(output);
151
+ });
152
+ child.stdin.end(input);
153
+ });
154
+ }
155
+
156
+ export async function runWorker(who, prompt, config, folder, signal, metadata = {}) {
157
+ const lane=reviewer(config,who);
158
+ if(lane.adapter==='opencode') {
159
+ if(!config.nativeRunner)throw Error('OpenCode reviewer adapter requires the active OpenCode plugin host.');
160
+ return config.nativeRunner(who,prompt,config,folder,signal,metadata);
161
+ }
162
+ const telemetry = new Telemetry(who, config, folder, metadata);
163
+ let envelope;
164
+ const stream = jsonLineReader(event => { telemetry.event(event); if (event.type === 'result') envelope = event; });
165
+ const heartbeat = setInterval(() => telemetry.save(), 5000); heartbeat.unref();
166
+ try {
167
+ if (signal?.aborted) throw new Error('Cancelled before launch.');
168
+ requireCliAuthentication(who, config, folder);
169
+ writeFileSync(path.join(folder, 'schema.json'), JSON.stringify(resultSchema));
170
+ const browser=prepareBrowser(config,folder);
171
+ const evidence=evidenceServer(folder);
172
+ const args = workerArgs(who, config, folder, metadata.images ?? [],browser,evidence);
173
+ if(evidence)prompt=queryInstructions+'\n\n'+prompt;
174
+ if(browser){prompt=browserInstructions+'\n\n'+prompt;telemetry.value.browserAvailable=true;telemetry.save();}
175
+ telemetry.prompt(prompt);
176
+ const executable = lane.executable;
177
+ const stdout = await runProcess(executable, args, folder, prompt, config.reviewTimeoutMs ?? 0, signal, {
178
+ streamOnly: true, onStart: pid => telemetry.start(pid), onData: text => stream.push(text),
179
+ });
180
+ stream.finish();
181
+ let result;
182
+ if (lane.adapter === 'codex-cli') result = JSON.parse(readFileSync(path.join(folder, 'result.json'), 'utf8'));
183
+ else {
184
+ if (!envelope) { try { envelope = JSON.parse(stdout); telemetry.event(envelope); } catch {} }
185
+ if (!envelope) throw new Error('Claude produced no final result event; checkpoint stays closed.');
186
+ if (envelope.is_error || envelope.subtype !== 'success') throw new Error('Claude did not complete successfully; checkpoint stays closed.');
187
+ result = envelope.structured_output;
188
+ if (!result) {
189
+ // Some CLI runs return the schema JSON as the final result string.
190
+ // Accept only exact JSON validated below, never prose or inferred approval.
191
+ try { result = JSON.parse(envelope.result); } catch {}
192
+ if (!result) throw new Error(`Claude returned no schema result (fields: ${Object.keys(envelope).join(',')}; result type: ${typeof envelope.result}); checkpoint stays closed.`);
193
+ }
194
+ }
195
+ try { validateResult(result); telemetry.end('completed', result.verdict); return result; }
196
+ catch {
197
+ 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.`);
198
+ }
199
+ } catch (error) {
200
+ if(error.unreapedPid){telemetry.value.workerExitUnconfirmed=true;telemetry.value.descendantsUnconfirmed=!!error.descendantsUnconfirmed;}
201
+ stream.finish(); telemetry.end(signal?.aborted ? 'cancelled' : 'failed');
202
+ delete error.output; // Never expose raw verbose CLI event tails to OpenCode logs.
203
+ throw error;
204
+ } finally { clearInterval(heartbeat); }
205
+ }
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Release notes
2
2
 
3
+ ## 1.3.1
4
+
5
+ - Carry original OpenCode user messages to both reviewers so explicit scope amendments are not reduced to implementation-agent claims or overridden by stale briefs.
6
+ - Reopen planning, design and code-review evidence checks when the original user context changes. Preserve task history and reject approval against context changed during review.
7
+ - Keep synthetic recovery, attachments and agent output separate from user authority; screen common credential formats and verify session/project boundaries. Existing two-reviewer approval requirements are unchanged.
8
+
9
+ ## 1.3.0
10
+
11
+ - Route disputed or unclear planning findings to the configured premium lead through `escalate`, with concrete research, resubmission, developer or tooling directives.
12
+ - Recover visible premature planning stops inside the active execution while honoring interruptions, waiting instructions and approval boundaries.
13
+ - Preserve reviewer-owned finding resolution and detect unchanged recovery attempts without repeatedly spending model calls.
14
+ - Distinguish malformed reviewer JSON from schema/verdict conflicts in transport errors.
15
+
3
16
  ## 1.2.0
4
17
 
5
18
  - Keep global specialist roles independent of a preferred framework, database or cloud while preserving responsibility, correctness, security and testing standards.
@@ -1,22 +1,30 @@
1
- # Continue blocked planning
1
+ # Planning recovery
2
2
 
3
- Continue the existing OpenCode chat. A new chat in the same project shares the same checkpoint and does not reset it.
3
+ The implementation model collects facts and builds the approved design. Premium planners evaluate architectural/security choices and decide whether findings are valid. A closed planning gate is not a reason to reset a task or disable Hybrid.
4
4
 
5
- Ask normally: **“Collect the missing planning evidence and continue. Keep installation and integration checks at the implementation stages that can actually execute them.”**
5
+ When a finding or requested correction is unclear, disputed or blocked by a missing capability, call `hybrid_checkpoint` with `action: "escalate"` and the observations in `checks`. This sends the current plan, finding history and research evidence to the configured planning lead. It preserves the task, reviewer assignments and approvals.
6
6
 
7
- The agent can use these checkpoint actions before application edits are approved:
7
+ The lead returns a `planningNext` directive:
8
8
 
9
- | Action | What it does |
9
+ | Kind | Next step |
10
10
  | --- | --- |
11
- | `status` | Shows current-stage findings and concrete recovery actions. |
12
- | `inspect` | Reports installed tool versions and reviewer authentication diagnostics. |
13
- | `research`, `kind: packages` | Fetches public npm metadata for `{name, version}` entries. It does not install packages or read npm credentials. |
14
- | `research`, `kind: note` | Saves attributed evidence from `checks` in task runtime storage and includes it in subsequent planning. |
15
- | `research`, `kind: report` | Reads a saved report by its `reportCall` from `status.reports`. |
16
- | `plan` | Submits the evidence and proposed resolutions to the configured reviewers. |
11
+ | Research | Collect the specific public facts/excerpts, save attributed evidence through `research` and return it to `plan`. Do not invent architecture contracts. |
12
+ | Resubmit | Submit the lead's corrected contract or rebuttal through normal planning validation. |
13
+ | Developer | Present the specific question, options and consequences; wait for the developer. |
14
+ | Tooling | Explain the exact missing access/capability and repair required. Do not repeatedly retry an unchanged failure. |
17
15
 
18
- Notes are unverified claims. Registry receipts prove what the registry returned, not a successful install. Neither closes a finding or grants approval. Arbitrary shell commands and application edits stay gated. There is no package-count or research-call quota; network requests have transport timeouts and per-response memory protection.
16
+ An advisory escalation never grants implementation approval or closes findings. A reviewer can explicitly withdraw its own mistaken finding with a reason through `verifiedResolutions`; another model's pass or the implementer's assertion cannot erase dissent. Normal current-plan validation still requires the configured reports, at least one pass, and no unresolved medium/high/critical finding. Reserved developer decisions remain reserved.
19
17
 
20
- Planning must settle material architecture choices. Installation, deployed-service and live-account checks belong to the stages that can execute them. The plan must name their owner, dependent operation, required evidence and failure/replan path. A fundamental feasibility or security unknown cannot be deferred merely to obtain a pass. Only the originating reviewer can verify that a previous finding is resolved by a corrected stage boundary.
18
+ On supported OpenCode hosts, a visible premature stop after active planning work can receive a continuation inside the same execution. It routes through the next step above, respects interruptions and explicit waiting instructions, excludes children/reviewer sessions and pending question-tool interactions, and suppresses unchanged recovery loops. It does not start a background session or resume an interrupted one. An already-idle conversation still needs a normal user message to resume.
21
19
 
22
- Package/note receipts attach only to planning. For design or code review, use that checkpoint's existing `checks` and `images` inputs. Use `replan` for changed architectural requirements, preserving history and prior findings.
20
+ Native reviewers use captured evidence and approved documentation/browser tools. This feature does not add unrestricted web search. The implementation agent may gather the lead's requested documentation using its available tools; the premium lead evaluates it. External evidence remains untrusted and must contain no credentials or private source sent to public research services.
21
+
22
+ Older saved tasks remain readable. The report schema adds a nullable `recovery` object for new responses; existing receipts without it remain accepted. For a recovery response, verdicts retain their usual meaning: a missing essential fact uses `blocked` with an actionable research directive, not `pass` with a medium finding. JSON syntax and schema/verdict errors are reported separately.
23
+
24
+ ## Original user decisions
25
+
26
+ Checkpoints now attach the original user messages exposed by OpenCode, with session/message IDs and dates. Both reviewers receive the same captured evidence. An explicit scope change in chat can supersede an old brief; you do not need to edit a file manually to prove it. The plan should cite the message, state the effective scope and schedule stale documentation updates during implementation. A generic “continue” still does not approve an unspecified stack migration.
27
+
28
+ On OpenCode 2.2.5, the plugin reads original messages in active context. It does not mistake compaction summaries for user approval. Previously captured originals missing from the active window remain labelled historical evidence, because compaction and reversion cannot be distinguished through that API. They cannot authorize new reserved actions. Hosts exposing the projected-message listing API are read page by page. An unavailable decision may require a focused clarification; it is not a reason to reset the task or ask for manual source edits.
29
+
30
+ Visible assistant proposals are included only as context for replies. Synthetic messages, tool results and attachment bodies are not promoted to user instructions. Common credential formats are redacted before forwarding; this screening is best-effort, so keep credentials out of chat. Provider access remains the configured reviewers' access. User-role provenance is supplied by the OpenCode host, not cryptographic proof of human authorship.
@@ -3,6 +3,7 @@ import {isReviewerSession} from './opencode-reviewer.mjs';
3
3
  import {writeJSONAtomic,runtimePath} from '../core/state-store.mjs';
4
4
  import {operationBusy} from '../core/operation-lock.mjs';
5
5
  import {continuationFile} from './continuation-notice.mjs';
6
+ import {planningStopDecision} from './planning-continuation.mjs';
6
7
 
7
8
  const SOURCE='hybrid-silent-stop';
8
9
  const unwrap=value=>value.data??value;
@@ -63,7 +64,8 @@ export async function registerContinuation(ctx,forSession) {
63
64
  const controller=await forSession(event.sessionID);
64
65
  if(operationBusy(controller.lockFile))return;
65
66
  const messages=unwrap(await ctx.session.context({sessionID:event.sessionID}));
66
- const decision=silentStopDecision(messages,controller.read(),event.sessionID,event.background);
67
+ const state=controller.read();
68
+ const decision=planningStopDecision(messages,state,event.sessionID,event.background)??silentStopDecision(messages,state,event.sessionID,event.background);
67
69
  if(!decision)return;
68
70
  const current=unwrap(await ctx.session.get({sessionID:event.sessionID}));
69
71
  const busy=Number(new Date(current.time?.busy??0)),idle=Number(new Date(current.time?.idle??0));
@@ -26,6 +26,7 @@ export async function readNativeResult(ctx,sessionID,{isAborted=()=>false,onReco
26
26
  // Accept only an exact single JSON fence wrapper, not prose or guessed JSON
27
27
  // substrings. The complete enclosed object still passes the same strict schema.
28
28
  const fenced=/^```(?:json)?[ \t]*\r?\n([\s\S]*)\r?\n```$/.exec(text);
29
- let result;try{result=validateResult(JSON.parse(fenced?fenced[1]:text));}catch{throw Error('Reviewer returned invalid JSON; no approval recorded.');}
29
+ let parsed;try{parsed=JSON.parse(fenced?fenced[1]:text);}catch{throw Error('Reviewer returned invalid JSON; no approval recorded.');}
30
+ let result;try{result=validateResult(parsed);}catch(error){throw Error('Reviewer returned a schema-invalid result: '+error.message+' No approval recorded.');}
30
31
  return {result,session};
31
32
  }
@@ -0,0 +1,40 @@
1
+ import {planningNext} from '../core/planning-escalation.mjs';
2
+
3
+ const SOURCE='hybrid-planning-handoff';
4
+ const terminalText=m=>(m?.content??[]).filter(p=>p.type==='text').map(p=>p.text??'').join('').trim();
5
+ function unchangedEscalation(parts){
6
+ const last=parts.findLast(p=>p.name==='hybrid_checkpoint');
7
+ if(last?.state?.status!=='completed'||last.state.input?.action!=='escalate')return false;
8
+ const content=last.state.content;
9
+ const text=typeof content==='string'?content:Array.isArray(content)?content.filter(p=>p.type==='text').map(p=>p.text??'').join(''):'';
10
+ try{return JSON.parse(text).escalationUnchanged===true;}catch{return false;}
11
+ }
12
+ function explicitHandoff(request){
13
+ const text=request.replace(/\b(?:do not|don't|never)\s+(?:stop|pause|cancel)\b/gi,'continue');
14
+ if(/\b(stop|pause|cancel|do not continue|don't continue|status only|do not run|don't run)\b/i.test(text))return true;
15
+ // Be conservative about handoffs. The single supported deferred boundary is
16
+ // explicit completion of planning before waiting; it is still pending here.
17
+ const remaining=text.replace(/\b(finish|complete|finalize)\b[^.!?\n]{0,55}\bplan(?:ning)?\b[^.!?\n]{0,35}\bthen\s+wait\b/gi,'');
18
+ return /\b(wait|hold|await)\b/i.test(remaining);
19
+ }
20
+ export function planningStopDecision(messages,state,sessionID,background=[]){
21
+ const next=planningNext(state);
22
+ if(!next||!['escalate','research','resubmit'].includes(next.kind)||background.some(w=>w.sessionID===sessionID))return null;
23
+ const userIndex=messages.findLastIndex(m=>m.type==='user');if(userIndex<0)return null;
24
+ const user=messages[userIndex],request=user.text??terminalText(user);
25
+ // Explicit human stop/status-only requests do not authorize more work.
26
+ if(explicitHandoff(request))return null;
27
+ const turn=messages.slice(userIndex+1),last=turn.findLast(m=>m.type==='assistant');
28
+ if(!last||last.error||last.finish!=='stop'||!terminalText(last))return null;
29
+ if(turn.some(m=>m.type==='assistant'&&(m.error||['error','length','content-filter'].includes(m.finish))))return null;
30
+ const parts=turn.filter(m=>m.type==='assistant').flatMap(m=>m.content??[]).filter(p=>p.type==='tool');
31
+ if(parts.some(p=>p.state?.status==='error'||p.name==='question'))return null;
32
+ const participates=parts.some(p=>p.name==='hybrid_checkpoint'&&p.state?.status==='completed'&&
33
+ (['plan','escalate'].includes(p.state.input?.action)||p.state.input?.action==='research'&&['note','packages'].includes(p.state.input?.kind)));
34
+ if(!participates)return null;
35
+ if(unchangedEscalation(parts))return {notice:'The premium lead already evaluated the unchanged planning evidence. Follow its saved research/decision instructions; no approval was granted and no reset is needed.'};
36
+ const metadata={source:SOURCE,taskID:state.id,key:next.key};
37
+ if(turn.some(m=>m.type==='synthetic'&&m.metadata?.source===SOURCE&&m.metadata?.taskID===state.id&&m.metadata?.key===next.key))return {notice:'Planning recovery made no evidence progress. The saved premium directive remains available; report the exact unavailable capability or decision instead of resetting the task. Approval is unchanged.'};
38
+ return {resume:false,metadata,description:'Hybrid Workflow: returning unfinished planning to the premium lead’s recovery path.',
39
+ text:`The current planning checkpoint has an actionable next step, not a request to reset the workflow. Continue within this execution. Next kind: ${next.kind}; checkpoint action: ${next.action}. ${next.instructions}\nFor escalate, send the findings and concrete observations in checks to hybrid_checkpoint escalate now. For research, collect only the lead-requested facts, save attributed evidence with research kind=note and submit plan; do not invent security/architecture contracts. For resubmit, call plan with the premium directive and collected evidence. If the directive cannot be followed, escalate the exact missing tool/access or developer decision for premium evaluation. Never waive a finding, disable Hybrid or reset state. This message grants no developer approval; retain all explicit pauses and approval boundaries. New user input or interruption takes precedence.`};
40
+ }