@rockysoft/opencode-hybrid-workflow 1.1.2 → 1.3.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,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
+ }
@@ -1,7 +1,7 @@
1
1
  // Shared workflow guidance for the host agents and isolated reviewer processes.
2
2
  export const WORKFLOW_POLICY = `Proportionate hybrid workflow:
3
3
  Classify by actual change, not page count or code length. Use mode=ui for a visual redesign of an existing app that preserves architecture, data contracts and behavior: go directly to configured design-lead authoring and reviewer validation, then implement and review. Use mode=feature for new applications, substantive new behavior or architecture changes; investigate and plan those before design. Do not label a redesign feature merely because it is substantial visually. Exact cosmetic changes may use cosmetic; faithful bounded extensions use small with existing/supplied design evidence.
4
- Decision ownership: the configured premium reviewers own material architecture and structural decisions, including new frameworks/dependencies, data models, shared API contracts, server/client boundaries and substantial changes to the approved plan. The implementation model and its native subagents gather facts, present alternatives and implement the approved decisions; they do not independently approve or replace them. The native fullstack-architect is a read-only researcher supporting premium planning. Route a newly discovered material choice through hybrid_checkpoint replan/plan (or design for UI/UX) before dependent implementation. Mechanical implementation details and faithful small/medium extensions within established patterns do not need a new planning ceremony.
4
+ Decision ownership: the configured premium reviewers own material architecture and structural decisions, including new frameworks/dependencies, data models, shared API contracts, server/client boundaries and substantial changes to the approved plan. The implementation model and its native subagents gather facts, present alternatives and implement the approved decisions; they do not independently approve or replace them. The native fullstack-architect is a read-only researcher supporting premium planning. Route a newly discovered material choice through hybrid_checkpoint replan/plan (or design for UI/UX) before dependent implementation. Framework/library replacements or deviations additionally require a proposal with benefits, risks and alternatives followed by explicit developer approval before dependent edits/installs; a premium pass is not that approval. Mechanical implementation details and faithful small/medium extensions within established patterns do not need a new planning ceremony.
5
5
  Design acceptance must be risk-based: list changed component families, affected behavior, representative viewports/states and the concrete risk each check covers. Exercise genuinely distinct responsive layouts, error/interaction paths and print behavior that changed. Reuse shared-component coverage; do not demand the Cartesian product of every state and every width by default. Expand coverage when an observed defect or distinct implementation warrants it. An exhaustive matrix invented by an agent is not an explicit user requirement. If an already-approved contract is excessive, obtain a focused design amendment; do not silently ignore it or falsify evidence.
6
6
  Separate product defects, missing evidence and preferences. Medium or higher requires a concrete material consequence or an essential unknown about the affected work. A missing screenshot alone is not medium unless that screenshot is necessary to resolve such a risk. Minor documentation wording, optional polish or preferred aesthetics are low/info. Never lower a real defect to force approval.
7
7
  Before re-review, address the entire consolidated blocking checklist. Supply checks linking each finding ID to the actual fix and verification; use resolutions [{id,evidence}] for an explicit checklist. Do not send partial submissions hoping another full review will discover what is still missing. If evidence is unavailable, collect it or state the genuine external blocker. No numeric code-line, screenshot-count, correction-round or lifetime-call quota applies.
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Release notes
2
2
 
3
+ ## 1.3.0
4
+
5
+ - Route disputed or unclear planning findings to the configured premium lead through `escalate`, with concrete research, resubmission, developer or tooling directives.
6
+ - Recover visible premature planning stops inside the active execution while honoring interruptions, waiting instructions and approval boundaries.
7
+ - Preserve reviewer-owned finding resolution and detect unchanged recovery attempts without repeatedly spending model calls.
8
+ - Distinguish malformed reviewer JSON from schema/verdict conflicts in transport errors.
9
+
10
+ ## 1.2.0
11
+
12
+ - Keep global specialist roles independent of a preferred framework, database or cloud while preserving responsibility, correctness, security and testing standards.
13
+ - Require a researched Stack implementation contract in new architecture plans, using Context7 and official web documentation matched to selected versions.
14
+ - Require a concrete benefits/risks proposal and explicit developer approval for framework/library replacements or deviations from the existing or approved stack.
15
+ - Refresh managed agent/rule assets when a new plugin version starts. Preserve customized files and report them in diagnostics without changing settings or task history.
16
+
3
17
  ## 1.1.2
4
18
 
5
19
  - Let blocked planning collect public package metadata, save attributed research notes and read its prior reports without opening application writes or running arbitrary shell commands.
@@ -1,22 +1,22 @@
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.
@@ -0,0 +1,36 @@
1
+ # Stack decisions belong in the project plan
2
+
3
+ Hybrid's global agents define engineering responsibilities and quality requirements. They do not select a default application framework, database, ORM or cloud.
4
+
5
+ For a new application, the premium planners recommend a suitable stack and explain why. Before implementation, the approved plan contains the practical rules for using that stack. Research uses **both Context7 and web search with actual official documentation reads**, matched to the selected versions.
6
+
7
+ For an existing application, keep its stack. Replacing a framework/library or deviating from the existing or approved stack requires a concrete proposal and **explicit developer approval** before dependent edits or installs. Explain benefits/gains, alternatives, migration effort, compatibility/security risks and rollback. A reviewer pass, generic "continue" or permission to build a feature is not that approval. An explicit request for the exact change already supplies it.
8
+
9
+ ## What the plan contains
10
+
11
+ Use a clearly identified **Stack implementation contract** section in the existing plan; equivalent prose is fine.
12
+
13
+ | Item | Required content |
14
+ | --- | --- |
15
+ | Decision | Selected technologies/versions, current installed versions, fit, alternatives and compatibility/deployment constraints. Distinguish latest stable from the selected version. |
16
+ | Research | Lookup date, Context7 library ID and actual returned version, official page URLs and the specific facts established. Record unavailable tools or version mismatches and authoritative fallback evidence honestly. |
17
+ | Implementation guidance | Relevant APIs and module ownership, supported patterns, version pitfalls, source citations and meaningful acceptance checks. Separate framework requirements from project choices. |
18
+ | Coverage | Rendering, routing, mutations, state/forms, validation, persistence, identity, failure/concurrency, runtime and tests as applicable. Do not invent services to fill headings. |
19
+ | Approval | For replacements/deviations, the specific developer-approved change and scope. Preserve existing choices while approval is pending. |
20
+ | Deferred checks | Owner, dependent operation, required evidence and failure/replan path. Installation/build checks follow scaffolding; foundational unsafe decisions cannot be deferred. |
21
+
22
+ Build saves the approved plan and supplies its path and relevant rules to each implementation specialist. Reviewers check evidence and code against that contract. A heading or collection of links does not prove research. Unchanged decisions reuse verified evidence; ordinary existing-pattern edits do not trigger another full-stack study.
23
+
24
+ ## Documentation accuracy
25
+
26
+ Context7's documented workflow resolves a library before querying its documentation and supports version-qualified IDs. Verify the returned source/version rather than assuming the requested version is indexed. Cross-check material decisions with official version-specific documentation, release notes and compatibility information. See [Context7 MCP tools](https://context7.com/docs/resources/all-clients) and [Context7 API version guidance](https://context7.com/docs/api-guide).
27
+
28
+ If Context7 lacks a library or a tool is unavailable, record the attempted lookup and use authoritative documentation or bundled source/help as an explicit fallback. Do not fabricate a successful lookup. Resolve essential unknowns before dependent implementation, while continuing independent work. Research sends public technology names and generic API questions only; never private code, credentials or internal URLs.
29
+
30
+ ## Enforcement boundary
31
+
32
+ This is an agent/reviewer instruction contract, delivered to the host, installed specialists and isolated reviewers. The existing checkpoint still enforces reviewer verdicts and unresolved findings. There is no new heading-count parser, automated claim of documentation verification, or retroactive approval reset. The harness does not independently prove that an agent read a source or that a quoted approval came from the developer; agents and reviewers must verify the actual evidence and authorization.
33
+
34
+ Framework-specific skill examples remain reference material for the chosen stack. The plugin's own Node/OpenTUI implementation and optional package-script verification adapters are implementation constraints of Hybrid, not instructions to build every application with those technologies.
35
+
36
+ On startup after a package update, configured standard profiles refresh package-managed agents, rules and skills with backups. Reviewer settings, plugin registration and task history are unchanged. User-customized files are preserved; **Run diagnostics → Managed instructions** lists files that need comparison with the new package. Custom settings-file profiles remain read-only and need their existing installation workflow to update assets.
@@ -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));