@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/lib/install.mjs CHANGED
@@ -50,7 +50,7 @@ function configPath(root,name) {
50
50
  if(existsSync(plain)&&existsSync(comments))throw Error(`Both ${name}.json and ${name}.jsonc exist. Resolve ambiguity before installation.`);
51
51
  return existsSync(comments)?comments:plain;
52
52
  }
53
- export function install({root=configDirectory(),source=packageRoot,settingsPatch={},replaceLegacyPackage,apply=true,registerPlugin=true}={}) {
53
+ export function install({root=configDirectory(),source=packageRoot,settingsPatch={},replaceLegacyPackage,apply=true,registerPlugin=true,assetsOnly=false}={}) {
54
54
  root=path.resolve(root);source=realpathSync(source);
55
55
  const edits=[],conflicts=[];
56
56
  const read=(file,fallback={})=>existsSync(file)?parseJsonc(readFileSync(safeTarget(root,file),'utf8')):fallback;
@@ -66,8 +66,8 @@ export function install({root=configDirectory(),source=packageRoot,settingsPatch
66
66
  const before=existsSync(file)?readFileSync(file,'utf8'):null;
67
67
  if(before!==body)edits.push({file,before,body});
68
68
  }
69
- stage(settingsPath,json(settings));
70
- for(const name of ['opencode','cli']) {
69
+ if(!assetsOnly)stage(settingsPath,json(settings));
70
+ for(const name of assetsOnly?[]:['opencode','cli']) {
71
71
  const file=configPath(root,name),value=read(file);
72
72
  if(value.plugins!==undefined&&!Array.isArray(value.plugins))throw Error('plugins must be an array.');
73
73
  // Remove only entries owned by this package (and the superseded personal sidebar).
@@ -91,7 +91,7 @@ export function install({root=configDirectory(),source=packageRoot,settingsPatch
91
91
  if(before!==null&&before!==body&&files[relative]!==digest(before)){conflicts.push(relative);continue;}
92
92
  stage(file,body);files[relative]=digest(body);
93
93
  }
94
- const manifest={version:1,packageVersion:JSON.parse(readFileSync(path.join(source,'package.json'),'utf8')).version,files};
94
+ const manifest={version:1,packageVersion:JSON.parse(readFileSync(path.join(source,'package.json'),'utf8')).version,files,preservedCustomFiles:conflicts};
95
95
  stage(managedPath,json(manifest));
96
96
  const backup=path.join(root,'backups','hybrid-'+Date.now()+'-'+randomUUID());
97
97
  if(apply&&edits.length) {
@@ -110,3 +110,15 @@ export function install({root=configDirectory(),source=packageRoot,settingsPatch
110
110
  }
111
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
112
  }
113
+
114
+ // A new package version must refresh its managed instructions without requiring
115
+ // users to save reviewer settings again. Never rewrite config/auth/task files.
116
+ export function refreshManagedAssets({root=configDirectory(),source=packageRoot}={}) {
117
+ const settingsPath=safeTarget(root,path.join(root,'hybrid','config.json'));
118
+ if(!existsSync(settingsPath)||parseJsonc(readFileSync(settingsPath,'utf8')).setupComplete===false)return null;
119
+ const manifestPath=safeTarget(root,path.join(root,'hybrid','installed.json'));
120
+ const installed=existsSync(manifestPath)?parseJsonc(readFileSync(manifestPath,'utf8')):{};
121
+ const version=JSON.parse(readFileSync(path.join(source,'package.json'),'utf8')).version;
122
+ if(installed.packageVersion===version)return null;
123
+ return install({root,source,registerPlugin:false,assetsOnly:true});
124
+ }
@@ -26,6 +26,7 @@ export async function readNativeResult(ctx,sessionID,{isAborted=()=>false,onReco
26
26
  // Accept only an exact single JSON fence wrapper, not prose or guessed JSON
27
27
  // substrings. The complete enclosed object still passes the same strict schema.
28
28
  const fenced=/^```(?:json)?[ \t]*\r?\n([\s\S]*)\r?\n```$/.exec(text);
29
- let result;try{result=validateResult(JSON.parse(fenced?fenced[1]:text));}catch{throw Error('Reviewer returned invalid JSON; no approval recorded.');}
29
+ let parsed;try{parsed=JSON.parse(fenced?fenced[1]:text);}catch{throw Error('Reviewer returned invalid JSON; no approval recorded.');}
30
+ let result;try{result=validateResult(parsed);}catch(error){throw Error('Reviewer returned a schema-invalid result: '+error.message+' No approval recorded.');}
30
31
  return {result,session};
31
32
  }
@@ -0,0 +1,40 @@
1
+ import {planningNext} from '../core/planning-escalation.mjs';
2
+
3
+ const SOURCE='hybrid-planning-handoff';
4
+ const terminalText=m=>(m?.content??[]).filter(p=>p.type==='text').map(p=>p.text??'').join('').trim();
5
+ function unchangedEscalation(parts){
6
+ const last=parts.findLast(p=>p.name==='hybrid_checkpoint');
7
+ if(last?.state?.status!=='completed'||last.state.input?.action!=='escalate')return false;
8
+ const content=last.state.content;
9
+ const text=typeof content==='string'?content:Array.isArray(content)?content.filter(p=>p.type==='text').map(p=>p.text??'').join(''):'';
10
+ try{return JSON.parse(text).escalationUnchanged===true;}catch{return false;}
11
+ }
12
+ function explicitHandoff(request){
13
+ const text=request.replace(/\b(?:do not|don't|never)\s+(?:stop|pause|cancel)\b/gi,'continue');
14
+ if(/\b(stop|pause|cancel|do not continue|don't continue|status only|do not run|don't run)\b/i.test(text))return true;
15
+ // Be conservative about handoffs. The single supported deferred boundary is
16
+ // explicit completion of planning before waiting; it is still pending here.
17
+ const remaining=text.replace(/\b(finish|complete|finalize)\b[^.!?\n]{0,55}\bplan(?:ning)?\b[^.!?\n]{0,35}\bthen\s+wait\b/gi,'');
18
+ return /\b(wait|hold|await)\b/i.test(remaining);
19
+ }
20
+ export function planningStopDecision(messages,state,sessionID,background=[]){
21
+ const next=planningNext(state);
22
+ if(!next||!['escalate','research','resubmit'].includes(next.kind)||background.some(w=>w.sessionID===sessionID))return null;
23
+ const userIndex=messages.findLastIndex(m=>m.type==='user');if(userIndex<0)return null;
24
+ const user=messages[userIndex],request=user.text??terminalText(user);
25
+ // Explicit human stop/status-only requests do not authorize more work.
26
+ if(explicitHandoff(request))return null;
27
+ const turn=messages.slice(userIndex+1),last=turn.findLast(m=>m.type==='assistant');
28
+ if(!last||last.error||last.finish!=='stop'||!terminalText(last))return null;
29
+ if(turn.some(m=>m.type==='assistant'&&(m.error||['error','length','content-filter'].includes(m.finish))))return null;
30
+ const parts=turn.filter(m=>m.type==='assistant').flatMap(m=>m.content??[]).filter(p=>p.type==='tool');
31
+ if(parts.some(p=>p.state?.status==='error'||p.name==='question'))return null;
32
+ const participates=parts.some(p=>p.name==='hybrid_checkpoint'&&p.state?.status==='completed'&&
33
+ (['plan','escalate'].includes(p.state.input?.action)||p.state.input?.action==='research'&&['note','packages'].includes(p.state.input?.kind)));
34
+ if(!participates)return null;
35
+ if(unchangedEscalation(parts))return {notice:'The premium lead already evaluated the unchanged planning evidence. Follow its saved research/decision instructions; no approval was granted and no reset is needed.'};
36
+ const metadata={source:SOURCE,taskID:state.id,key:next.key};
37
+ if(turn.some(m=>m.type==='synthetic'&&m.metadata?.source===SOURCE&&m.metadata?.taskID===state.id&&m.metadata?.key===next.key))return {notice:'Planning recovery made no evidence progress. The saved premium directive remains available; report the exact unavailable capability or decision instead of resetting the task. Approval is unchanged.'};
38
+ return {resume:false,metadata,description:'Hybrid Workflow: returning unfinished planning to the premium lead’s recovery path.',
39
+ text:`The current planning checkpoint has an actionable next step, not a request to reset the workflow. Continue within this execution. Next kind: ${next.kind}; checkpoint action: ${next.action}. ${next.instructions}\nFor escalate, send the findings and concrete observations in checks to hybrid_checkpoint escalate now. For research, collect only the lead-requested facts, save attributed evidence with research kind=note and submit plan; do not invent security/architecture contracts. For resubmit, call plan with the premium directive and collected evidence. If the directive cannot be followed, escalate the exact missing tool/access or developer decision for premium evaluation. Never waive a finding, disable Hybrid or reset state. This message grants no developer approval; retain all explicit pauses and approval boundaries. New user input or interruption takes precedence.`};
40
+ }
@@ -2,7 +2,7 @@ import {existsSync,readFileSync,writeFileSync,mkdirSync,renameSync} from 'node:f
2
2
  import path from 'node:path';
3
3
  import {createHash,randomUUID} from 'node:crypto';
4
4
  import {configDirectory,readSettings,settingsFile,packageRoot} from './config.mjs';
5
- import {install,parseJsonc,safeTarget} from './install.mjs';
5
+ import {install,parseJsonc,safeTarget,refreshManagedAssets} from './install.mjs';
6
6
  import {selection} from './select-reviewers.mjs';
7
7
  import {commandSpec} from './commands.mjs';
8
8
  import {runProcess,requireCliAuthentication} from '../core/workers.mjs';
@@ -29,7 +29,7 @@ export function validateChoices(input,catalog,previous){
29
29
  if(!['astra','opus'].includes(input.designAuthor)||!reviewers[input.designAuthor])throw Error('Choose an enabled planning/design lead.');
30
30
  return {reviewers,designAuthor:input.designAuthor};
31
31
  }
32
- export function createSettingsService(ctx,options={}){
32
+ export function createSettingsService(ctx,options={},refreshState={}){
33
33
  const root=configDirectory(),file=settingsFile(options);
34
34
  const readOnly=path.resolve(file)!==path.join(root,'hybrid','config.json');
35
35
  const load=()=>readSettings(options);
@@ -43,18 +43,26 @@ export function createSettingsService(ctx,options={}){
43
43
  if(before!==null)writeFileSync(path.join(backup,path.basename(target)),before,{mode:0o600});
44
44
  const tmp=target+'.hybrid-'+randomUUID();writeFileSync(tmp,JSON.stringify(value,null,2)+'\n',{mode:0o600});renameSync(tmp,target);
45
45
  };
46
- const persist=patch=>install({root,settingsPatch:{...(!existsSync(file)?{setupComplete:false}:{}),...patch},registerPlugin:false});
46
+ const persist=patch=>{const result=install({root,settingsPatch:{...(!existsSync(file)?{setupComplete:false}:{}),...patch},registerPlugin:false});delete refreshState.error;return result;};
47
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
48
  async function get(){
49
49
  const config=load(),models=await catalog();
50
50
  return {configured:existsSync(file)&&config.setupComplete!==false,readOnly,revision:revision(file),version:JSON.parse(readFileSync(path.join(packageRoot,'package.json'),'utf8')).version,
51
51
  reviewers:config.reviewers,designAuthor:config.designAuthor??(config.reviewers.opus?'opus':'astra'),browserEnabled:!!config.reviewBrowser?.enabled,impeccableEnabled:config.design.impeccable===true,
52
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};
53
+ configFile:file,implementationModel:globalRead().model??'Selected in OpenCode',host:process.platform,instructionsWarning:refreshState.error??null};
54
54
  }
55
55
  async function diagnostics(){
56
56
  const config=load(),checks=[];
57
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
+ await check('Managed instructions',async()=>{
59
+ if(refreshState.error)throw Error(refreshState.error);
60
+ const manifest=path.join(root,'hybrid','installed.json');
61
+ if(!existsSync(manifest))return 'Not installed yet; complete Hybrid setup.';
62
+ const installed=parseJsonc(readFileSync(safeTarget(root,manifest),'utf8'));
63
+ if(installed.preservedCustomFiles?.length)throw Error('Kept customized files unchanged: '+installed.preservedCustomFiles.join(', ')+'. Compare them with the updated package instructions before opting into replacement.');
64
+ return 'Managed assets installed for '+installed.packageVersion;
65
+ });
58
66
  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
67
  const models=await catalog();
60
68
  for(const [who,lane] of Object.entries(config.reviewers))if(lane)await check(who==='astra'?'Reviewer 1':'Reviewer 2',async()=>{
@@ -137,7 +145,12 @@ export function createSettingsService(ctx,options={}){
137
145
  };
138
146
  }
139
147
  export async function registerSettings(ctx,options){
140
- const service=createSettingsService(ctx,options);
148
+ // Custom settings files retain their existing read-only behavior. Only update
149
+ // package-managed assets for the normal config root, before starting sessions.
150
+ const root=configDirectory();
151
+ const standard=path.resolve(settingsFile(options))===path.join(root,'hybrid','config.json'),refreshState={};
152
+ if(standard)try{refreshManagedAssets({root});}catch(error){refreshState.error='Managed instructions could not be refreshed and may be outdated: '+error.message+'. Resolve the file problem and restart OpenCode to retry.';}
153
+ const service=createSettingsService(ctx,options,refreshState);
141
154
  // Expected setup errors must reach the dialog, not become opaque HTTP defects.
142
155
  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}};}}]));
143
156
  await ctx.rpc.register(SettingsRPC,handlers);return service;
package/package.json CHANGED
@@ -1,76 +1,76 @@
1
- {
2
- "name": "@rockysoft/opencode-hybrid-workflow",
3
- "version": "1.1.2",
4
- "description": "Portable configurable-reviewer planning, design, implementation checkpoints and usage sidebar for OpenCode 2",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "RockySoft",
8
- "keywords": [
9
- "opencode",
10
- "opencode-plugin",
11
- "agents",
12
- "code-review",
13
- "workflow"
14
- ],
15
- "publishConfig": {
16
- "access": "public",
17
- "tag": "latest"
18
- },
19
- "exports": {
20
- ".": "./index.js",
21
- "./tui": "./tui.js"
22
- },
23
- "bin": {
24
- "opencode-hybrid": "./cli.mjs"
25
- },
26
- "files": [
27
- "index.js",
28
- "tui.js",
29
- "cli.mjs",
30
- "lib/",
31
- "core/",
32
- "sidebar/",
33
- "assets/",
34
- "README.md",
35
- "THIRD_PARTY_NOTICES.md",
36
- "LICENSE",
37
- "docs/"
38
- ],
39
- "engines": {
40
- "node": ">=22"
41
- },
42
- "scripts": {
43
- "build": "node scripts/build.mjs",
44
- "prepack": "npm run build",
45
- "test": "node --test test/*.test.mjs",
46
- "doctor": "node cli.mjs doctor"
47
- },
48
- "devDependencies": {
49
- "@babel/core": "7.29.7",
50
- "@babel/preset-typescript": "7.27.1",
51
- "babel-preset-solid": "1.9.12"
52
- },
53
- "peerDependencies": {
54
- "@opencode/plugin": ">=2.1.2 <3",
55
- "@opentui/core": ">=0.6.6",
56
- "@opentui/solid": ">=0.6.6",
57
- "solid-js": ">=1.9.0"
58
- },
59
- "peerDependenciesMeta": {
60
- "@opencode/plugin": {
61
- "optional": true
62
- },
63
- "@opentui/core": {
64
- "optional": true
65
- },
66
- "@opentui/solid": {
67
- "optional": true
68
- },
69
- "solid-js": {
70
- "optional": true
71
- }
72
- },
73
- "dependencies": {
74
- "diff": "9.0.0"
75
- }
76
- }
1
+ {
2
+ "name": "@rockysoft/opencode-hybrid-workflow",
3
+ "version": "1.3.0",
4
+ "description": "Portable configurable-reviewer planning, design, implementation checkpoints and usage sidebar for OpenCode 2",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "RockySoft",
8
+ "keywords": [
9
+ "opencode",
10
+ "opencode-plugin",
11
+ "agents",
12
+ "code-review",
13
+ "workflow"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public",
17
+ "tag": "latest"
18
+ },
19
+ "exports": {
20
+ ".": "./index.js",
21
+ "./tui": "./tui.js"
22
+ },
23
+ "bin": {
24
+ "opencode-hybrid": "./cli.mjs"
25
+ },
26
+ "files": [
27
+ "index.js",
28
+ "tui.js",
29
+ "cli.mjs",
30
+ "lib/",
31
+ "core/",
32
+ "sidebar/",
33
+ "assets/",
34
+ "README.md",
35
+ "THIRD_PARTY_NOTICES.md",
36
+ "LICENSE",
37
+ "docs/"
38
+ ],
39
+ "engines": {
40
+ "node": ">=22"
41
+ },
42
+ "scripts": {
43
+ "build": "node scripts/build.mjs",
44
+ "prepack": "npm run build",
45
+ "test": "node --test test/*.test.mjs",
46
+ "doctor": "node cli.mjs doctor"
47
+ },
48
+ "devDependencies": {
49
+ "@babel/core": "7.29.7",
50
+ "@babel/preset-typescript": "7.27.1",
51
+ "babel-preset-solid": "1.9.12"
52
+ },
53
+ "peerDependencies": {
54
+ "@opencode/plugin": ">=2.1.2 <3",
55
+ "@opentui/core": ">=0.6.6",
56
+ "@opentui/solid": ">=0.6.6",
57
+ "solid-js": ">=1.9.0"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "@opencode/plugin": {
61
+ "optional": true
62
+ },
63
+ "@opentui/core": {
64
+ "optional": true
65
+ },
66
+ "@opentui/solid": {
67
+ "optional": true
68
+ },
69
+ "solid-js": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "dependencies": {
74
+ "diff": "9.0.0"
75
+ }
76
+ }
@@ -79,6 +79,7 @@ export function createSettingsUI(context,onSaved=()=>{}){
79
79
  if(open||disposed)return;open=true;
80
80
  try{
81
81
  let state=await invoke('get');
82
+ if(state.instructionsWarning)await alert('Managed instructions need attention',state.instructionsWarning);
82
83
  if(firstRun&&state.configured)return;
83
84
  if(!state.configured){await wizard(state);state=await invoke('get');}
84
85
  while(!disposed){
@@ -89,7 +90,7 @@ export function createSettingsUI(context,onSaved=()=>{}){
89
90
  {title:'Reviewer browser',value:'browser',description:state.browserEnabled?'Enabled · isolated sessions':'Disabled'},
90
91
  {title:'Design guidance',value:'designGuidance',description:state.impeccableEnabled?'Impeccable · enabled':'Standard · Impeccable available per request'},
91
92
  {title:'Tools and dependencies',value:'dependencies',description:'Browser installation is in Reviewer browser'},
92
- {title:'Run diagnostics',value:'diagnostics',description:'Check setup and logins without model calls'},
93
+ {title:'Run diagnostics',value:'diagnostics',description:state.instructionsWarning?'Instructions update needs attention':'Check setup and logins without model calls'},
93
94
  {title:'About this setup',value:'about',description:'Global configuration and active-task behavior'},
94
95
  ]);
95
96
  if(!action)return;