@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/lib/install.mjs CHANGED
@@ -1,111 +1,112 @@
1
- import {existsSync,readFileSync,writeFileSync,mkdirSync,readdirSync,lstatSync,realpathSync,renameSync,unlinkSync} from 'node:fs';
2
- import path from 'node:path';
3
- import {createHash,randomUUID} from 'node:crypto';
4
- import {packageRoot,configDirectory,defaultSettings} from './config.mjs';
5
- const digest=text=>createHash('sha256').update(text).digest('hex');
6
- const json=text=>JSON.stringify(text,null,2)+'\n';
7
- export function parseJsonc(text) {
8
- let stripped='',quoted=false,escape=false;
9
- for(let i=0;i<text.length;i++) {
10
- const c=text[i],next=text[i+1];
11
- if(quoted){stripped+=c;if(escape)escape=false;else if(c==='\\')escape=true;else if(c==='"')quoted=false;continue;}
12
- if(c==='"'){quoted=true;stripped+=c;continue;}
13
- if(c==='/'&&next==='/'){while(i<text.length&&text[i]!=='\n')i++;stripped+='\n';continue;}
14
- if(c==='/'&&next==='*'){i+=2;while(i<text.length&&!(text[i]==='*'&&text[i+1]==='/'))i++;if(i>=text.length)throw Error('Unterminated JSON comment');i++;stripped+=' ';continue;}
15
- stripped+=c;
16
- }
17
- let result='';quoted=false;escape=false;
18
- for(let i=0;i<stripped.length;i++) {
19
- const c=stripped[i];
20
- if(quoted){result+=c;if(escape)escape=false;else if(c==='\\')escape=true;else if(c==='"')quoted=false;continue;}
21
- if(c==='"')quoted=true;
22
- if(c===','&&/^[\s]*[}\]]/.test(stripped.slice(i+1)))continue;
23
- result+=c;
24
- }
25
- return JSON.parse(result.replace(/^\uFEFF/,''));
26
- }
27
- export function safeTarget(root,file) {
28
- const absolute=path.resolve(file),relative=path.relative(path.resolve(root),absolute);
29
- if(relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative))throw Error('Install target is outside config directory.');
30
- let current=path.resolve(root);
31
- if(existsSync(current)&&lstatSync(current).isSymbolicLink())throw Error('Config directory must not be a symlink.');
32
- for(const part of relative.split(path.sep)) {
33
- current=path.join(current,part);
34
- if(existsSync(current)&&lstatSync(current).isSymbolicLink())throw Error(`Refusing symlink install target: ${current}`);
35
- }
36
- return absolute;
37
- }
38
- const pluginName=value=>typeof value==='string'?value:value?.package;
39
- const ours=value=>/(?:^|[/\\])opencode-hybrid-workflow(?:@[^/\\]+)?$/.test(pluginName(value)??'');
40
- const legacySidebar=value=>/(?:^|[/\\])hybrid-usage$/.test(pluginName(value)??'');
41
- function assetFiles(root,prefix='') {
42
- return readdirSync(root,{withFileTypes:true}).flatMap(e=>{
43
- if(e.isSymbolicLink())throw Error('Bundled assets must not contain symlinks.');
44
- return e.isDirectory()?assetFiles(path.join(root,e.name),prefix+e.name+'/'):[prefix+e.name];
45
- });
46
- }
47
- function configPath(root,name) {
48
- const plain=path.join(root,name+'.json'),comments=path.join(root,name+'.jsonc');
49
- if(existsSync(plain)&&existsSync(comments))throw Error(`Both ${name}.json and ${name}.jsonc exist. Resolve ambiguity before installation.`);
50
- return existsSync(comments)?comments:plain;
51
- }
52
- export function install({root=configDirectory(),source=packageRoot,settingsPatch={},replaceLegacyPackage,apply=true,registerPlugin=true}={}) {
53
- root=path.resolve(root);source=realpathSync(source);
54
- const edits=[],conflicts=[];
55
- const read=(file,fallback={})=>existsSync(file)?parseJsonc(readFileSync(safeTarget(root,file),'utf8')):fallback;
56
- const settingsPath=path.join(root,'hybrid','config.json');
57
- const existing=read(settingsPath);
58
- const settings={...defaultSettings(),...existing,...settingsPatch};
59
- // Package-owned bundled skills resolve at runtime; only explicit user overrides persist.
60
- if(!existing.design&&!settingsPatch.design)delete settings.design;
61
- const managedPath=path.join(root,'hybrid','installed.json');
62
- const managed=read(managedPath,{files:{}}),files={...managed.files};
63
- function stage(file,body) {
64
- safeTarget(root,file);
65
- const before=existsSync(file)?readFileSync(file,'utf8'):null;
66
- if(before!==body)edits.push({file,before,body});
67
- }
68
- stage(settingsPath,json(settings));
69
- for(const name of ['opencode','cli']) {
70
- const file=configPath(root,name),value=read(file);
71
- if(value.plugins!==undefined&&!Array.isArray(value.plugins))throw Error('plugins must be an array.');
72
- // Remove only entries owned by this package (and the superseded personal sidebar).
73
- if(registerPlugin){
74
- const plugins=(value.plugins??[]).filter(p=>!ours(p)&&path.normalize(pluginName(p)??'')!==path.normalize(source)&&!(name==='opencode'&&replaceLegacyPackage&&path.normalize(pluginName(p)??'')===path.normalize(replaceLegacyPackage))&&!(name==='cli'&&legacySidebar(p)));
75
- const entry={package:source.replaceAll('\\','/'),options:{configFile:settingsPath}};
76
- value.plugins=[...plugins,entry];
77
- }
78
- if(name==='opencode') {
79
- const rules=path.join(root,'hybrid','rules.md');
80
- if(value.instructions!==undefined&&!Array.isArray(value.instructions))throw Error('instructions must be an array.');
81
- value.instructions=[...new Set([...(value.instructions??[]),rules])];
82
- }
83
- if(registerPlugin||name==='opencode')stage(file,json(value));
84
- }
85
- for(const relative of ['hybrid/rules.md',...assetFiles(path.join(source,'assets','agents')).map(n=>'agents/'+n),...assetFiles(path.join(source,'assets','skills')).map(n=>'skills/'+n)]) {
86
- const asset=relative==='hybrid/rules.md'?'rules.md':relative;
87
- const body=readFileSync(path.join(source,'assets',asset),'utf8'),file=path.join(root,relative);
88
- safeTarget(root,file);
89
- const before=existsSync(file)?readFileSync(file,'utf8'):null;
90
- if(before!==null&&before!==body&&files[relative]!==digest(before)){conflicts.push(relative);continue;}
91
- stage(file,body);files[relative]=digest(body);
92
- }
93
- const manifest={version:1,packageVersion:JSON.parse(readFileSync(path.join(source,'package.json'),'utf8')).version,files};
94
- stage(managedPath,json(manifest));
95
- const backup=path.join(root,'backups','hybrid-'+Date.now()+'-'+randomUUID());
96
- if(apply&&edits.length) {
97
- for(const edit of edits) {
98
- if(edit.before!==null){const copy=path.join(backup,path.relative(root,edit.file));safeTarget(root,copy);mkdirSync(path.dirname(copy),{recursive:true,mode:0o700});writeFileSync(copy,edit.before,{flag:'wx',mode:0o600});}
99
- }
100
- // No task/session/runtime files are touched by installation.
101
- const prepared=[],committed=[];
102
- try {
103
- for(const edit of edits){mkdirSync(path.dirname(edit.file),{recursive:true});const temporary=edit.file+'.hybrid-'+randomUUID();writeFileSync(temporary,edit.body,{flag:'wx',mode:0o600});prepared.push({...edit,temporary});}
104
- for(const edit of prepared){renameSync(edit.temporary,edit.file);committed.push(edit);}
105
- }catch(error){
106
- for(const edit of committed.reverse()){if(edit.before===null)unlinkSync(edit.file);else writeFileSync(edit.file,edit.before,{mode:0o600});}
107
- throw error;
108
- }finally{for(const edit of prepared)if(existsSync(edit.temporary))unlinkSync(edit.temporary);}
109
- }
110
- return {configDirectory:root,settingsPath,changed:edits.map(e=>path.relative(root,e.file)),preservedCustomFiles:conflicts,backup:apply&&edits.length?backup:null,applied:apply};
111
- }
1
+ import {existsSync,readFileSync,writeFileSync,mkdirSync,readdirSync,lstatSync,realpathSync,renameSync,unlinkSync} from 'node:fs';
2
+ import path from 'node:path';
3
+ import {createHash,randomUUID} from 'node:crypto';
4
+ import {packageRoot,configDirectory,defaultSettings} from './config.mjs';
5
+ const digest=text=>createHash('sha256').update(text).digest('hex');
6
+ const json=text=>JSON.stringify(text,null,2)+'\n';
7
+ export function parseJsonc(text) {
8
+ let stripped='',quoted=false,escape=false;
9
+ for(let i=0;i<text.length;i++) {
10
+ const c=text[i],next=text[i+1];
11
+ if(quoted){stripped+=c;if(escape)escape=false;else if(c==='\\')escape=true;else if(c==='"')quoted=false;continue;}
12
+ if(c==='"'){quoted=true;stripped+=c;continue;}
13
+ if(c==='/'&&next==='/'){while(i<text.length&&text[i]!=='\n')i++;stripped+='\n';continue;}
14
+ if(c==='/'&&next==='*'){i+=2;while(i<text.length&&!(text[i]==='*'&&text[i+1]==='/'))i++;if(i>=text.length)throw Error('Unterminated JSON comment');i++;stripped+=' ';continue;}
15
+ stripped+=c;
16
+ }
17
+ let result='';quoted=false;escape=false;
18
+ for(let i=0;i<stripped.length;i++) {
19
+ const c=stripped[i];
20
+ if(quoted){result+=c;if(escape)escape=false;else if(c==='\\')escape=true;else if(c==='"')quoted=false;continue;}
21
+ if(c==='"')quoted=true;
22
+ if(c===','&&/^[\s]*[}\]]/.test(stripped.slice(i+1)))continue;
23
+ result+=c;
24
+ }
25
+ return JSON.parse(result.replace(/^\uFEFF/,''));
26
+ }
27
+ export function safeTarget(root,file) {
28
+ const absolute=path.resolve(file),relative=path.relative(path.resolve(root),absolute);
29
+ if(relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative))throw Error('Install target is outside config directory.');
30
+ let current=path.resolve(root);
31
+ if(existsSync(current)&&lstatSync(current).isSymbolicLink())throw Error('Config directory must not be a symlink.');
32
+ for(const part of relative.split(path.sep)) {
33
+ current=path.join(current,part);
34
+ if(existsSync(current)&&lstatSync(current).isSymbolicLink())throw Error(`Refusing symlink install target: ${current}`);
35
+ }
36
+ return absolute;
37
+ }
38
+ const pluginName=value=>typeof value==='string'?value:value?.package;
39
+ const ours=value=>/(?:^|[/\\])opencode-hybrid-workflow(?:@[^/\\]+)?$/.test(pluginName(value)??'');
40
+ const legacySidebar=value=>/(?:^|[/\\])hybrid-usage$/.test(pluginName(value)??'');
41
+ function assetFiles(root,prefix='') {
42
+ return readdirSync(root,{withFileTypes:true}).flatMap(e=>{
43
+ if(e.isSymbolicLink())throw Error('Bundled assets must not contain symlinks.');
44
+ if(e.name==='__pycache__'||/\.py[co]$/i.test(e.name))return [];
45
+ return e.isDirectory()?assetFiles(path.join(root,e.name),prefix+e.name+'/'):[prefix+e.name];
46
+ });
47
+ }
48
+ function configPath(root,name) {
49
+ const plain=path.join(root,name+'.json'),comments=path.join(root,name+'.jsonc');
50
+ if(existsSync(plain)&&existsSync(comments))throw Error(`Both ${name}.json and ${name}.jsonc exist. Resolve ambiguity before installation.`);
51
+ return existsSync(comments)?comments:plain;
52
+ }
53
+ export function install({root=configDirectory(),source=packageRoot,settingsPatch={},replaceLegacyPackage,apply=true,registerPlugin=true}={}) {
54
+ root=path.resolve(root);source=realpathSync(source);
55
+ const edits=[],conflicts=[];
56
+ const read=(file,fallback={})=>existsSync(file)?parseJsonc(readFileSync(safeTarget(root,file),'utf8')):fallback;
57
+ const settingsPath=path.join(root,'hybrid','config.json');
58
+ const existing=read(settingsPath);
59
+ const settings={...defaultSettings(),...existing,...settingsPatch};
60
+ // Package-owned bundled skills resolve at runtime; only explicit user overrides persist.
61
+ if(!existing.design&&!settingsPatch.design)delete settings.design;
62
+ const managedPath=path.join(root,'hybrid','installed.json');
63
+ const managed=read(managedPath,{files:{}}),files={...managed.files};
64
+ function stage(file,body) {
65
+ safeTarget(root,file);
66
+ const before=existsSync(file)?readFileSync(file,'utf8'):null;
67
+ if(before!==body)edits.push({file,before,body});
68
+ }
69
+ stage(settingsPath,json(settings));
70
+ for(const name of ['opencode','cli']) {
71
+ const file=configPath(root,name),value=read(file);
72
+ if(value.plugins!==undefined&&!Array.isArray(value.plugins))throw Error('plugins must be an array.');
73
+ // Remove only entries owned by this package (and the superseded personal sidebar).
74
+ if(registerPlugin){
75
+ const plugins=(value.plugins??[]).filter(p=>!ours(p)&&path.normalize(pluginName(p)??'')!==path.normalize(source)&&!(name==='opencode'&&replaceLegacyPackage&&path.normalize(pluginName(p)??'')===path.normalize(replaceLegacyPackage))&&!(name==='cli'&&legacySidebar(p)));
76
+ const entry={package:source.replaceAll('\\','/'),options:{configFile:settingsPath}};
77
+ value.plugins=[...plugins,entry];
78
+ }
79
+ if(name==='opencode') {
80
+ const rules=path.join(root,'hybrid','rules.md');
81
+ if(value.instructions!==undefined&&!Array.isArray(value.instructions))throw Error('instructions must be an array.');
82
+ value.instructions=[...new Set([...(value.instructions??[]),rules])];
83
+ }
84
+ if(registerPlugin||name==='opencode')stage(file,json(value));
85
+ }
86
+ for(const relative of ['hybrid/rules.md',...assetFiles(path.join(source,'assets','agents')).map(n=>'agents/'+n),...assetFiles(path.join(source,'assets','skills')).map(n=>'skills/'+n)]) {
87
+ const asset=relative==='hybrid/rules.md'?'rules.md':relative;
88
+ const body=readFileSync(path.join(source,'assets',asset),'utf8'),file=path.join(root,relative);
89
+ safeTarget(root,file);
90
+ const before=existsSync(file)?readFileSync(file,'utf8'):null;
91
+ if(before!==null&&before!==body&&files[relative]!==digest(before)){conflicts.push(relative);continue;}
92
+ stage(file,body);files[relative]=digest(body);
93
+ }
94
+ const manifest={version:1,packageVersion:JSON.parse(readFileSync(path.join(source,'package.json'),'utf8')).version,files};
95
+ stage(managedPath,json(manifest));
96
+ const backup=path.join(root,'backups','hybrid-'+Date.now()+'-'+randomUUID());
97
+ if(apply&&edits.length) {
98
+ for(const edit of edits) {
99
+ if(edit.before!==null){const copy=path.join(backup,path.relative(root,edit.file));safeTarget(root,copy);mkdirSync(path.dirname(copy),{recursive:true,mode:0o700});writeFileSync(copy,edit.before,{flag:'wx',mode:0o600});}
100
+ }
101
+ // No task/session/runtime files are touched by installation.
102
+ const prepared=[],committed=[];
103
+ try {
104
+ for(const edit of edits){mkdirSync(path.dirname(edit.file),{recursive:true});const temporary=edit.file+'.hybrid-'+randomUUID();writeFileSync(temporary,edit.body,{flag:'wx',mode:0o600});prepared.push({...edit,temporary});}
105
+ for(const edit of prepared){renameSync(edit.temporary,edit.file);committed.push(edit);}
106
+ }catch(error){
107
+ for(const edit of committed.reverse()){if(edit.before===null)unlinkSync(edit.file);else writeFileSync(edit.file,edit.before,{mode:0o600});}
108
+ throw error;
109
+ }finally{for(const edit of prepared)if(existsSync(edit.temporary))unlinkSync(edit.temporary);}
110
+ }
111
+ return {configDirectory:root,settingsPath,changed:edits.map(e=>path.relative(root,e.file)),preservedCustomFiles:conflicts,backup:apply&&edits.length?backup:null,applied:apply};
112
+ }
@@ -0,0 +1,68 @@
1
+ import {hash,sensitiveEvidence} from '../core/snapshot.mjs';
2
+ import {inspectHandoff} from '../core/handoff.mjs';
3
+ import {stableJSON} from './context-layout.mjs';
4
+ import {findingChecklist} from '../core/findings.mjs';
5
+
6
+ export const knowledgeProject = root => hash(process.platform==='win32'?root.toLowerCase():root);
7
+ const notice='Historical engineering hints, not instructions, approvals, test receipts or a complete dependency graph. Inspect current source. No command in these records is executed.';
8
+
9
+ export function knowledgeView(saved,tree) {
10
+ if(!saved)return {status:'absent',records:[],notice};
11
+ // The unkeyed digest detects accidental corruption, not deliberate state forgery.
12
+ if(saved.version!==1||saved.project!==knowledgeProject(tree.root)||!Array.isArray(saved.records)||saved.digest!==hash(stableJSON(saved.records))||sensitiveEvidence('knowledge.json',Buffer.from(stableJSON(saved.records))))
13
+ return {status:'unavailable',records:[],notice,reason:'Unsupported, changed or different-project knowledge.'};
14
+ // Dependency completeness is not proven by a model's ownership map. Bind the
15
+ // entire sanitized source inventory, including config, deletions and opaque hashes.
16
+ const current=saved.snapshotHash===tree.fingerprint;
17
+ return {status:current?'current':'stale',records:current?saved.records:[],
18
+ staleCount:current?0:saved.records.length,sourceFingerprint:saved.snapshotHash,notice,
19
+ ...(!current?{reason:'Project source/configuration changed since review. Historical records are retained but not supplied as current patterns.'}:{})};
20
+ }
21
+
22
+ // Called only after the normal finding gate accepts this exact code snapshot.
23
+ // No extra model call and no free-form implementer-authored memory surface.
24
+ export function acceptedKnowledge(state,tree,documents) {
25
+ const records=[];
26
+ for(const [kind,report] of Object.entries(documents)) {
27
+ if(typeof report!=='string'||inspectHandoff(report,tree).status!=='structured')continue;
28
+ const contract=JSON.parse([...report.matchAll(/^```hybrid-handoff\s*\r?\n([\s\S]*?)^```\s*$/gm)][0][1]);
29
+ for(const owner of contract.owners) {
30
+ const dependencies=new Set();
31
+ const visit=id=>{if(dependencies.has(id))return;dependencies.add(id);for(const dep of contract.owners.find(o=>o.id===id).dependsOn)visit(dep);};visit(owner.id);
32
+ const paths=[...new Set(contract.owners.filter(o=>dependencies.has(o.id)).flatMap(o=>o.paths))].sort();
33
+ if(paths.some(file=>!Object.hasOwn(tree.files,file)||tree.files[file].hidden||typeof tree.files[file].text!=='string'))continue;
34
+ const record={id:kind+':'+owner.id,kind,scope:contract.scope,
35
+ pattern:{id:owner.id,responsibility:owner.responsibility,boundary:owner.boundary,state:owner.state,inputs:owner.inputs,outputs:owner.outputs,dependsOn:owner.dependsOn},
36
+ sources:paths.map(file=>({path:file,sha256:tree.files[file].hash})),
37
+ provenance:{taskID:state.id,documentHash:hash(report),artifactHash:tree.fingerprint,stage:'review'}};
38
+ if(!sensitiveEvidence('knowledge.json',Buffer.from(stableJSON(record))))records.push(record);
39
+ }
40
+ }
41
+ // Replace rather than merge: an older accepted decision must not survive a new
42
+ // contract that removes it. Prose-only contracts simply use the ordinary plan.
43
+ // A design may amend architecture. Without a machine-readable supersession
44
+ // contract, do not silently choose either interpretation or teach both as current.
45
+ const unambiguous=records.filter(record=>!records.some(other=>other.kind!==record.kind&&
46
+ (other.pattern.id===record.pattern.id||other.sources.some(a=>record.sources.some(b=>a.path===b.path))||other.pattern.state.some(name=>record.pattern.state.includes(name)))));
47
+ return {version:1,project:knowledgeProject(tree.root),snapshotHash:tree.fingerprint,records:unambiguous,digest:hash(stableJSON(unambiguous))};
48
+ }
49
+
50
+ export function resumeContext(state,tree,changes,prerequisites={}) {
51
+ const designPending=prerequisites.designPending??(state.designMode==='new'&&!state.designApproval);
52
+ const next=state.phase==='complete'?'Task complete. Begin a new bounded task.':
53
+ ['plan','planning'].includes(state.phase)?'Complete planning before dependent implementation.':
54
+ prerequisites.planCurrent===false?'Approved plan is missing or changed. Revalidate planning before dependent implementation.':
55
+ designPending||state.phase==='design'||prerequisites.designCurrent===false?'Complete or revalidate design before dependent implementation.':
56
+ state.phase==='fix'?'Resolve pending findings, verify affected behavior, then submit current code for review.':
57
+ state.phase==='review'?'Recover or finish pending review; retain every unresolved finding.':'Continue the approved scope; submit changed code for review before finishing.';
58
+ return {version:1,taskID:state.id,phase:state.phase,brief:state.brief,currentArtifactHash:tree.fingerprint,
59
+ changedFiles:changes.changed.map(c=>c.file),
60
+ contracts:{plan:state.approval?{path:state.planPath,hash:state.approval.artifactHash}:null,
61
+ design:state.designApproval?{path:state.designPath,hash:state.designApproval.hash}:null},
62
+ pendingFindings:['plan','design','review'].flatMap(scope=>findingChecklist(state,scope).map(f=>({scope,...f}))),
63
+ pendingReports:state.pendingPair?.applied?[]:Object.entries(state.pendingPair?.results??{}).map(([reviewer,result])=>({reviewer,verdict:result.verdict,findings:result.findings})),
64
+ verification:[...(state.lastAcceptedVerification??[]),...(state.checks??[])].map(check=>({script:check.script,success:check.success,execution:check.execution,
65
+ current:check.success===true&&check.before===tree.fingerprint&&check.after===tree.fingerprint,artifactHash:check.after??null})),
66
+ prerequisites:{...prerequisites,designPending,pauseReason:state.pauseReason??null,evidenceRequest:state.evidenceRequest??null,designPause:state.designPause??null},next,
67
+ notice:'Derived from checkpoint state, not a new approval. Read referenced contracts and current source; preserve pending findings. No host conversation reset.'};
68
+ }
@@ -1,137 +1,137 @@
1
- import {existsSync,readFileSync,writeFileSync,mkdirSync,renameSync} from 'node:fs';
2
- import path from 'node:path';
3
- import {createHash,randomUUID} from 'node:crypto';
4
- import {configDirectory,readSettings,settingsFile,packageRoot} from './config.mjs';
5
- import {install,parseJsonc,safeTarget} from './install.mjs';
6
- import {selection} from './select-reviewers.mjs';
7
- import {commandSpec} from './commands.mjs';
8
- import {runProcess,requireSubscription} from '../core/workers.mjs';
9
- import {NativeBrowser} from './native-browser.mjs';
10
- import {SettingsRPC} from './settings-rpc.mjs';
11
- import {discoverCLIModels} from './cli-models.mjs';
12
- import {projectMeasurements,formatMeasurements} from './trace-summary.mjs';
13
- const writes=new Set();
14
- const unwrap=x=>x?.data??x;
15
- export function revision(file){return createHash('sha256').update(existsSync(file)?readFileSync(file):'missing').digest('hex');}
16
- export function validateChoices(input,catalog,previous){
17
- if(!input.reviewers||Object.keys(input.reviewers).some(k=>!['astra','opus'].includes(k)))throw Error('Choose one or two reviewer slots.');
18
- const reviewers={};
19
- for(const who of ['astra','opus']){
20
- const item=input.reviewers[who];
21
- if(item===null&&who==='opus'){reviewers[who]=null;continue;}
22
- if(!item||typeof item.model!=='string')throw Error('Reviewer model is required.');
23
- if(item.adapter==='opencode')reviewers[who]=selection(`opencode:${item.providerID}/${item.model}`,{catalog,variant:item.variant});
24
- else if(['codex-cli','claude-cli'].includes(item.adapter)){
25
- if(item.effort&&!/^[a-zA-Z0-9_-]+$/.test(item.effort))throw Error('Invalid reasoning effort.');
26
- reviewers[who]=selection(`${item.adapter}:${item.model}`,{previous:previous.reviewers?.[who]??{},variant:item.effort});
27
- }else throw Error('Unknown reviewer connection.');
28
- }
29
- if(!['astra','opus'].includes(input.designAuthor)||!reviewers[input.designAuthor])throw Error('Choose an enabled planning/design lead.');
30
- return {reviewers,designAuthor:input.designAuthor};
31
- }
32
- export function createSettingsService(ctx,options={}){
33
- const root=configDirectory(),file=settingsFile(options);
34
- const readOnly=path.resolve(file)!==path.join(root,'hybrid','config.json');
35
- const load=()=>readSettings(options);
36
- const catalog=async()=>{const values=unwrap(await ctx.model.list({location:ctx.location}));return values.filter(m=>m.enabled&&m.capabilities?.tools&&(!m.type||m.type==='chat'));};
37
- const globalFile=()=>{const json=path.join(root,'opencode.json'),jsonc=path.join(root,'opencode.jsonc');if(existsSync(json)&&existsSync(jsonc))throw Error('Both opencode.json and opencode.jsonc exist; resolve the duplicate configuration.');return existsSync(jsonc)?jsonc:json;};
38
- const globalRead=()=>existsSync(globalFile())?parseJsonc(readFileSync(safeTarget(root,globalFile()),'utf8')):{};
39
- const patchGlobal=update=>{
40
- const target=globalFile(),before=existsSync(target)?readFileSync(safeTarget(root,target),'utf8'):null;
41
- const value=before?parseJsonc(before):{};update(value);
42
- const backup=path.join(root,'backups','hybrid-ui-'+randomUUID());mkdirSync(backup,{recursive:true,mode:0o700});
43
- if(before!==null)writeFileSync(path.join(backup,path.basename(target)),before,{mode:0o600});
44
- const tmp=target+'.hybrid-'+randomUUID();writeFileSync(tmp,JSON.stringify(value,null,2)+'\n',{mode:0o600});renameSync(tmp,target);
45
- };
46
- const persist=patch=>install({root,settingsPatch:{...(!existsSync(file)?{setupComplete:false}:{}),...patch},registerPlugin:false});
47
- async function exclusive(fn){if(readOnly)throw Error('This installation uses a custom OPENCODE_HYBRID_CONFIG override. It remains active; interactive changes require the standard global configuration location.');if(writes.has(file))throw Error('Another settings change is in progress. Please retry when it finishes.');writes.add(file);try{return await fn();}finally{writes.delete(file);}}
48
- async function get(){
49
- const config=load(),models=await catalog();
50
- return {configured:existsSync(file)&&config.setupComplete!==false,readOnly,revision:revision(file),version:JSON.parse(readFileSync(path.join(packageRoot,'package.json'),'utf8')).version,
51
- reviewers:config.reviewers,designAuthor:config.designAuthor??(config.reviewers.opus?'opus':'astra'),browserEnabled:!!config.reviewBrowser?.enabled,
52
- catalog:models.map(m=>({providerID:m.providerID,id:m.id,name:m.name,variants:(m.variants??[]).map(v=>v.id),vision:m.capabilities.input?.includes('image')===true})),
53
- configFile:file,implementationModel:globalRead().model??'Selected in OpenCode',host:process.platform};
54
- }
55
- async function diagnostics(){
56
- const config=load(),checks=[];
57
- const check=async(name,fn)=>{try{checks.push({name,ok:true,detail:await fn()});}catch(e){checks.push({name,ok:false,detail:e.message});}};
58
- for(const [name,exe] of [['Node','node'],['Git','git'],['pnpm',config.pnpmExecutable],['Design research Python',config.design.pythonExecutable]])await check(name,async()=>{const spec=commandSpec(exe);return (await runProcess(spec.executable,[...spec.args,'--version'],root,'',15000)).trim().split('\n')[0];});
59
- const models=await catalog();
60
- for(const [who,lane] of Object.entries(config.reviewers))if(lane)await check(who==='astra'?'Reviewer 1':'Reviewer 2',async()=>{
61
- if(lane.adapter==='opencode'){selection(`opencode:${lane.providerID}/${lane.model}`,{catalog:models,variant:lane.variant});return `${lane.providerID} / ${lane.model} / ${lane.variant??'default'}`;}
62
- requireSubscription(who,config,root);return `${lane.adapter} signed in`;
63
- });
64
- if(config.reviewBrowser?.enabled)await check('Reviewer browser',async()=>{
65
- const folder=path.join(root,'hybrid','checks',randomUUID());safeTarget(root,folder);mkdirSync(folder,{recursive:true});
66
- const browser=new NativeBrowser(config,folder);
67
- try{await browser.start();const result=await browser.request('tools/call',{name:'browser_snapshot',arguments:{}});if(result.isError)throw Error((result.content??[]).filter(c=>c.type==='text').map(c=>c.text).join('\n')||'Browser check failed');return 'Isolated browser launched and responded';}finally{await browser.close();}
68
- });
69
- for(const name of ['frontend-design','ui-ux-pro-max','web-design-guidelines'])await check(name,async()=>{if(!existsSync(path.join(config.design.skillsDirectory,name,'SKILL.md')))throw Error('Bundled skill missing');return 'Available';});
70
- if(ctx.location?.directory)await check('Workflow measurements',async()=>formatMeasurements(projectMeasurements(ctx.location.directory,config)));
71
- return {checks,modelCalls:0};
72
- }
73
- async function installTool(spec,signal){
74
- const folder=path.join(root,'hybrid','tools');safeTarget(root,folder);mkdirSync(folder,{recursive:true,mode:0o700});
75
- await runProcess('npm',['install','--prefix',folder,'--ignore-scripts','--no-audit','--no-fund',spec],folder,'',0,signal);
76
- return folder;
77
- }
78
- return {get,diagnostics,
79
- cliModels:(input,context={})=>{
80
- if(!['astra','opus'].includes(input.who)||!['codex-cli','claude-cli'].includes(input.adapter))throw Error('Unknown reviewer connection.');
81
- const previous=load().reviewers?.[input.who];
82
- return discoverCLIModels(input.adapter,{executable:previous?.adapter===input.adapter?previous.executable:undefined,signal:context.signal});
83
- },
84
- save:input=>exclusive(async()=>{
85
- if(input.revision!==revision(file))throw Error('Settings changed in another window. Reopen settings and try again.');
86
- const patch=validateChoices(input,await catalog(),load());
87
- // Recheck after catalog I/O; never overwrite another UI's saved defaults.
88
- if(input.revision!==revision(file))throw Error('Settings changed in another window. Reopen settings and try again.');
89
- const installed=persist({...patch,setupComplete:true});return {saved:true,preservedCustomFiles:installed.preservedCustomFiles,message:'Saved globally. New tasks use these reviewers; active tasks keep their assignments.'};
90
- }),
91
- browser:(input,context={})=>exclusive(async()=>{
92
- if(input.action==='disable'){persist({reviewBrowser:{...load().reviewBrowser,enabled:false}});return {message:'Reviewer browser disabled for future calls.'};}
93
- if(input.action==='enable'){
94
- let setting=load().reviewBrowser;
95
- if(!setting?.command){
96
- const server=globalRead().mcp?.servers?.playwright;
97
- if(server?.type!=='local'||!Array.isArray(server.command))throw Error('No installed browser found. Choose Install browser support in this menu.');
98
- const stop=server.command.findIndex((x,i)=>i>0&&x.startsWith('--')),command=server.command.slice(0,stop<0?undefined:stop);
99
- if(command.some(x=>/^(-y|install|add)$/.test(x)))throw Error('Browser uses on-demand downloads. Choose Install browser support for a managed installation.');
100
- const pos=server.command.indexOf('--browser');setting={command,browser:pos>=0?server.command[pos+1]:'chrome',headless:true};
101
- }
102
- persist({reviewBrowser:{...setting,enabled:true}});return {message:'Browser support enabled.'};
103
- }
104
- if(input.action!=='install')throw Error('Unknown browser action.');
105
- const folder=await installTool('@playwright/mcp@0.0.83',context.signal),node=commandSpec('node');
106
- await runProcess(node.executable,[...node.args,path.join(folder,'node_modules','playwright','cli.js'),'install','firefox'],folder,'',0,context.signal);
107
- const command=[node.executable,...node.args,path.join(folder,'node_modules','@playwright','mcp','cli.js')];
108
- persist({reviewBrowser:{enabled:true,command,browser:'firefox',headless:true}});
109
- patchGlobal(value=>{value.mcp??={};value.mcp.servers??={};value.mcp.servers.playwright??={type:'local',command:[...command,'--browser','firefox','--isolated','--headless'],enabled:true};});
110
- return {message:'Playwright and Firefox installed. Reviewer browser enabled; implementation MCP configured unless one already existed.'};
111
- }),
112
- dependencies:(input,context={})=>exclusive(async()=>{
113
- if(input.action==='pnpm'){
114
- const folder=await installTool('pnpm@12.14.0',context.signal);
115
- // pnpm 12 ships a native binary; only run this explicitly selected package's
116
- // installer, then verify it before saving a working executable.
117
- const node=commandSpec('node'),packageFolder=path.join(folder,'node_modules','pnpm');
118
- await runProcess(node.executable,[...node.args,path.join(packageFolder,'install.js')],folder,'',0,context.signal);
119
- const executable=path.join(packageFolder,process.platform==='win32'?'pnpm.exe':'pnpm');
120
- await runProcess(executable,['--version'],folder,'',15000,context.signal);
121
- persist({verification:{...load().verification,executable}});
122
- return {message:'Managed pnpm installed for hybrid verification. Project-required versions still take precedence.'};
123
- }
124
- if(input.action==='context7'){
125
- patchGlobal(value=>{value.mcp??={};value.mcp.servers??={};value.mcp.servers.context7??={type:'remote',url:'https://mcp.context7.com/mcp',enabled:true};});
126
- return {message:'Context7 documentation MCP configured. Any existing configuration was preserved.'};
127
- }
128
- throw Error('Unknown dependency.');
129
- }),
130
- };
131
- }
132
- export async function registerSettings(ctx,options){
133
- const service=createSettingsService(ctx,options);
134
- // Expected setup errors must reach the dialog, not become opaque HTTP defects.
135
- const handlers=Object.fromEntries(Object.entries(service).map(([name,fn])=>[name,async(...args)=>{try{return await fn(...args);}catch(error){return {error:{message:error.message}};}}]));
136
- await ctx.rpc.register(SettingsRPC,handlers);return service;
137
- }
1
+ import {existsSync,readFileSync,writeFileSync,mkdirSync,renameSync} from 'node:fs';
2
+ import path from 'node:path';
3
+ import {createHash,randomUUID} from 'node:crypto';
4
+ import {configDirectory,readSettings,settingsFile,packageRoot} from './config.mjs';
5
+ import {install,parseJsonc,safeTarget} from './install.mjs';
6
+ import {selection} from './select-reviewers.mjs';
7
+ import {commandSpec} from './commands.mjs';
8
+ import {runProcess,requireCliAuthentication} from '../core/workers.mjs';
9
+ import {NativeBrowser} from './native-browser.mjs';
10
+ import {SettingsRPC} from './settings-rpc.mjs';
11
+ import {discoverCLIModels} from './cli-models.mjs';
12
+ import {projectMeasurements,formatMeasurements} from './trace-summary.mjs';
13
+ const writes=new Set();
14
+ const unwrap=x=>x?.data??x;
15
+ export function revision(file){return createHash('sha256').update(existsSync(file)?readFileSync(file):'missing').digest('hex');}
16
+ export function validateChoices(input,catalog,previous){
17
+ if(!input.reviewers||Object.keys(input.reviewers).some(k=>!['astra','opus'].includes(k)))throw Error('Choose one or two reviewer slots.');
18
+ const reviewers={};
19
+ for(const who of ['astra','opus']){
20
+ const item=input.reviewers[who];
21
+ if(item===null&&who==='opus'){reviewers[who]=null;continue;}
22
+ if(!item||typeof item.model!=='string')throw Error('Reviewer model is required.');
23
+ if(item.adapter==='opencode')reviewers[who]=selection(`opencode:${item.providerID}/${item.model}`,{catalog,variant:item.variant});
24
+ else if(['codex-cli','claude-cli'].includes(item.adapter)){
25
+ if(item.effort&&!/^[a-zA-Z0-9_-]+$/.test(item.effort))throw Error('Invalid reasoning effort.');
26
+ reviewers[who]=selection(`${item.adapter}:${item.model}`,{previous:previous.reviewers?.[who]??{},variant:item.effort});
27
+ }else throw Error('Unknown reviewer connection.');
28
+ }
29
+ if(!['astra','opus'].includes(input.designAuthor)||!reviewers[input.designAuthor])throw Error('Choose an enabled planning/design lead.');
30
+ return {reviewers,designAuthor:input.designAuthor};
31
+ }
32
+ export function createSettingsService(ctx,options={}){
33
+ const root=configDirectory(),file=settingsFile(options);
34
+ const readOnly=path.resolve(file)!==path.join(root,'hybrid','config.json');
35
+ const load=()=>readSettings(options);
36
+ const catalog=async()=>{const values=unwrap(await ctx.model.list({location:ctx.location}));return values.filter(m=>m.enabled&&m.capabilities?.tools&&(!m.type||m.type==='chat'));};
37
+ const globalFile=()=>{const json=path.join(root,'opencode.json'),jsonc=path.join(root,'opencode.jsonc');if(existsSync(json)&&existsSync(jsonc))throw Error('Both opencode.json and opencode.jsonc exist; resolve the duplicate configuration.');return existsSync(jsonc)?jsonc:json;};
38
+ const globalRead=()=>existsSync(globalFile())?parseJsonc(readFileSync(safeTarget(root,globalFile()),'utf8')):{};
39
+ const patchGlobal=update=>{
40
+ const target=globalFile(),before=existsSync(target)?readFileSync(safeTarget(root,target),'utf8'):null;
41
+ const value=before?parseJsonc(before):{};update(value);
42
+ const backup=path.join(root,'backups','hybrid-ui-'+randomUUID());mkdirSync(backup,{recursive:true,mode:0o700});
43
+ if(before!==null)writeFileSync(path.join(backup,path.basename(target)),before,{mode:0o600});
44
+ const tmp=target+'.hybrid-'+randomUUID();writeFileSync(tmp,JSON.stringify(value,null,2)+'\n',{mode:0o600});renameSync(tmp,target);
45
+ };
46
+ const persist=patch=>install({root,settingsPatch:{...(!existsSync(file)?{setupComplete:false}:{}),...patch},registerPlugin:false});
47
+ async function exclusive(fn){if(readOnly)throw Error('This installation uses a custom OPENCODE_HYBRID_CONFIG override. It remains active; interactive changes require the standard global configuration location.');if(writes.has(file))throw Error('Another settings change is in progress. Please retry when it finishes.');writes.add(file);try{return await fn();}finally{writes.delete(file);}}
48
+ async function get(){
49
+ const config=load(),models=await catalog();
50
+ return {configured:existsSync(file)&&config.setupComplete!==false,readOnly,revision:revision(file),version:JSON.parse(readFileSync(path.join(packageRoot,'package.json'),'utf8')).version,
51
+ reviewers:config.reviewers,designAuthor:config.designAuthor??(config.reviewers.opus?'opus':'astra'),browserEnabled:!!config.reviewBrowser?.enabled,
52
+ catalog:models.map(m=>({providerID:m.providerID,id:m.id,name:m.name,variants:(m.variants??[]).map(v=>v.id),vision:m.capabilities.input?.includes('image')===true})),
53
+ configFile:file,implementationModel:globalRead().model??'Selected in OpenCode',host:process.platform};
54
+ }
55
+ async function diagnostics(){
56
+ const config=load(),checks=[];
57
+ const check=async(name,fn)=>{try{checks.push({name,ok:true,detail:await fn()});}catch(e){checks.push({name,ok:false,detail:e.message});}};
58
+ for(const [name,exe] of [['Node','node'],['Git','git'],['pnpm',config.pnpmExecutable],['Design research Python',config.design.pythonExecutable]])await check(name,async()=>{const spec=commandSpec(exe);return (await runProcess(spec.executable,[...spec.args,'--version'],root,'',15000)).trim().split('\n')[0];});
59
+ const models=await catalog();
60
+ for(const [who,lane] of Object.entries(config.reviewers))if(lane)await check(who==='astra'?'Reviewer 1':'Reviewer 2',async()=>{
61
+ if(lane.adapter==='opencode'){selection(`opencode:${lane.providerID}/${lane.model}`,{catalog:models,variant:lane.variant});return `${lane.providerID} / ${lane.model} / ${lane.variant??'default'}`;}
62
+ requireCliAuthentication(who,config,root);return `${lane.adapter} signed in`;
63
+ });
64
+ if(config.reviewBrowser?.enabled)await check('Reviewer browser',async()=>{
65
+ const folder=path.join(root,'hybrid','checks',randomUUID());safeTarget(root,folder);mkdirSync(folder,{recursive:true});
66
+ const browser=new NativeBrowser(config,folder);
67
+ try{await browser.start();const result=await browser.request('tools/call',{name:'browser_snapshot',arguments:{}});if(result.isError)throw Error((result.content??[]).filter(c=>c.type==='text').map(c=>c.text).join('\n')||'Browser check failed');return 'Isolated browser launched and responded';}finally{await browser.close();}
68
+ });
69
+ for(const name of ['frontend-design','ui-ux-pro-max','web-design-guidelines'])await check(name,async()=>{if(!existsSync(path.join(config.design.skillsDirectory,name,'SKILL.md')))throw Error('Bundled skill missing');return 'Available';});
70
+ if(ctx.location?.directory)await check('Workflow measurements',async()=>formatMeasurements(projectMeasurements(ctx.location.directory,config)));
71
+ return {checks,modelCalls:0};
72
+ }
73
+ async function installTool(spec,signal){
74
+ const folder=path.join(root,'hybrid','tools');safeTarget(root,folder);mkdirSync(folder,{recursive:true,mode:0o700});
75
+ await runProcess('npm',['install','--prefix',folder,'--ignore-scripts','--no-audit','--no-fund',spec],folder,'',0,signal);
76
+ return folder;
77
+ }
78
+ return {get,diagnostics,
79
+ cliModels:(input,context={})=>{
80
+ if(!['astra','opus'].includes(input.who)||!['codex-cli','claude-cli'].includes(input.adapter))throw Error('Unknown reviewer connection.');
81
+ const previous=load().reviewers?.[input.who];
82
+ return discoverCLIModels(input.adapter,{executable:previous?.adapter===input.adapter?previous.executable:undefined,signal:context.signal});
83
+ },
84
+ save:input=>exclusive(async()=>{
85
+ if(input.revision!==revision(file))throw Error('Settings changed in another window. Reopen settings and try again.');
86
+ const patch=validateChoices(input,await catalog(),load());
87
+ // Recheck after catalog I/O; never overwrite another UI's saved defaults.
88
+ if(input.revision!==revision(file))throw Error('Settings changed in another window. Reopen settings and try again.');
89
+ const installed=persist({...patch,setupComplete:true});return {saved:true,preservedCustomFiles:installed.preservedCustomFiles,message:'Saved globally. New tasks use these reviewers; active tasks keep their assignments.'};
90
+ }),
91
+ browser:(input,context={})=>exclusive(async()=>{
92
+ if(input.action==='disable'){persist({reviewBrowser:{...load().reviewBrowser,enabled:false}});return {message:'Reviewer browser disabled for future calls.'};}
93
+ if(input.action==='enable'){
94
+ let setting=load().reviewBrowser;
95
+ if(!setting?.command){
96
+ const server=globalRead().mcp?.servers?.playwright;
97
+ if(server?.type!=='local'||!Array.isArray(server.command))throw Error('No installed browser found. Choose Install browser support in this menu.');
98
+ const stop=server.command.findIndex((x,i)=>i>0&&x.startsWith('--')),command=server.command.slice(0,stop<0?undefined:stop);
99
+ if(command.some(x=>/^(-y|install|add)$/.test(x)))throw Error('Browser uses on-demand downloads. Choose Install browser support for a managed installation.');
100
+ const pos=server.command.indexOf('--browser');setting={command,browser:pos>=0?server.command[pos+1]:'chrome',headless:true};
101
+ }
102
+ persist({reviewBrowser:{...setting,enabled:true}});return {message:'Browser support enabled.'};
103
+ }
104
+ if(input.action!=='install')throw Error('Unknown browser action.');
105
+ const folder=await installTool('@playwright/mcp@0.0.83',context.signal),node=commandSpec('node');
106
+ await runProcess(node.executable,[...node.args,path.join(folder,'node_modules','playwright','cli.js'),'install','firefox'],folder,'',0,context.signal);
107
+ const command=[node.executable,...node.args,path.join(folder,'node_modules','@playwright','mcp','cli.js')];
108
+ persist({reviewBrowser:{enabled:true,command,browser:'firefox',headless:true}});
109
+ patchGlobal(value=>{value.mcp??={};value.mcp.servers??={};value.mcp.servers.playwright??={type:'local',command:[...command,'--browser','firefox','--isolated','--headless'],enabled:true};});
110
+ return {message:'Playwright and Firefox installed. Reviewer browser enabled; implementation MCP configured unless one already existed.'};
111
+ }),
112
+ dependencies:(input,context={})=>exclusive(async()=>{
113
+ if(input.action==='pnpm'){
114
+ const folder=await installTool('pnpm@12.14.0',context.signal);
115
+ // pnpm 12 ships a native binary; only run this explicitly selected package's
116
+ // installer, then verify it before saving a working executable.
117
+ const node=commandSpec('node'),packageFolder=path.join(folder,'node_modules','pnpm');
118
+ await runProcess(node.executable,[...node.args,path.join(packageFolder,'install.js')],folder,'',0,context.signal);
119
+ const executable=path.join(packageFolder,process.platform==='win32'?'pnpm.exe':'pnpm');
120
+ await runProcess(executable,['--version'],folder,'',15000,context.signal);
121
+ persist({verification:{...load().verification,executable}});
122
+ return {message:'Managed pnpm installed for hybrid verification. Project-required versions still take precedence.'};
123
+ }
124
+ if(input.action==='context7'){
125
+ patchGlobal(value=>{value.mcp??={};value.mcp.servers??={};value.mcp.servers.context7??={type:'remote',url:'https://mcp.context7.com/mcp',enabled:true};});
126
+ return {message:'Context7 documentation MCP configured. Any existing configuration was preserved.'};
127
+ }
128
+ throw Error('Unknown dependency.');
129
+ }),
130
+ };
131
+ }
132
+ export async function registerSettings(ctx,options){
133
+ const service=createSettingsService(ctx,options);
134
+ // Expected setup errors must reach the dialog, not become opaque HTTP defects.
135
+ const handlers=Object.fromEntries(Object.entries(service).map(([name,fn])=>[name,async(...args)=>{try{return await fn(...args);}catch(error){return {error:{message:error.message}};}}]));
136
+ await ctx.rpc.register(SettingsRPC,handlers);return service;
137
+ }