@jeffusion/tianshu 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeffusion
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,76 @@
1
- # Temporary Holding Version
1
+ # 天枢 Tianshu
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/jeffusion/tianshu/actions/workflows/ci.yml/badge.svg)](https://github.com/jeffusion/tianshu/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+
6
+ 面向长期目标的智能协作工作台,以 Codex 本地插件交付。天枢将任务台账、执行聊天和验收证据连接起来,让跨聊天工作有持续的状态和明确的授权边界。
7
+
8
+ **当前阶段:0.1.0,早期开发。** 已实现任务中心、MCP 工具和协调 Skill;完整自主规划、后台常驻执行和多客户端协作尚未实现。
9
+
10
+ ## 已实现
11
+
12
+ - 按项目、状态、名称查看任务、授权范围与验收记录。
13
+ - “带到聊天”仅添加任务背景;通过 `@天枢` 的自然语言请求查询、协调、核验或删除已结束任务。
14
+ - SQLite 事务台账、revision 冲突保护、派发去重与不确定回执记录。
15
+ - 删除任务保留独立验收归档;聊天空闲、回合结束和业务验收分别记录。
16
+ - 明暗主题、只读浏览器预览、隔离测试与内容哈希构建标识。
17
+
18
+ ## npm 安装(正式渠道)
19
+
20
+ ```sh
21
+ npm install -g @jeffusion/tianshu
22
+ tianshu install
23
+ tianshu doctor
24
+ ```
25
+
26
+ 命令安装后即可使用,无需克隆和构建源码;仍需本机 Node.js 24–26、Python 3.12+ 与 Codex CLI。npm 安装没有 postinstall 副作用,显式运行 install 才配置 Codex。安装后重开客户端、启用天枢并发送“@天枢 打开任务中心”。
27
+
28
+ 已有台账使用 `tianshu install --config /absolute/path/to/config.json`,不会搬动或清空数据。升级命令、回滚与卸载见 [npm 分发说明](docs/npm-distribution.md)。安装标识 `tianshu@tianshu`,市场显示名“天枢”,Skill 为 `tianshu:coordinator`,MCP 服务名 `tianshu`。
29
+
30
+ 源码克隆、npm ci、npm run demo 仅供开发测试。构建和测试不会更新当前 Codex 安装,也不会发布 npm 版本。
31
+
32
+ ## 工作方式与边界
33
+
34
+ 天枢提供 `open_workspace`、`list_tasks`、`get_task` 和 `delete_task`。工具本身不向其他聊天发消息;协调 Skill 使用宿主提供的聊天工具,按用户授权跟进工作。台账记录授权但不是权限系统,不能替代用户指令。
35
+
36
+ 删除仅接受已验收或已停止的任务,要求最新 revision、授权来源和必要的执行停止证据。删除不等于停止进程或监控。交付报告先进入待验收状态,经独立核验才接受。
37
+
38
+ ## 开发测试体验
39
+
40
+ 需要 Node.js 24 或 26、npm、Python 3.12+。Codex 插件宿主目前主要在 macOS 桌面客户端验证;CI 覆盖 Linux/macOS 的构建与隔离测试。
41
+
42
+ ```sh
43
+ git clone https://github.com/jeffusion/tianshu.git
44
+ cd tianshu
45
+ npm ci
46
+ npm run demo
47
+ ```
48
+
49
+ 打开 <http://127.0.0.1:8975/preview.html?theme=light>。演示仅生成 `.local/demo/` 内的独立任务,不读取你的真实台账、不安装插件、不发送聊天消息。预览中的附件操作被隔离,需要实际宿主验证。
50
+
51
+ ## 检查与构建
52
+
53
+ ```sh
54
+ npm run check
55
+ npm run build
56
+ ```
57
+
58
+ `check` 包含 TypeScript、生产构建、Node 协议测试、Python 台账测试和真实 npm 包生命周期测试,无需 API key、模型服务或业务数据库。构建产物位于 `dist/plugin` 和 `dist/marketplace`,`dist/build-info.json` 包含内容指纹。版本保持 `0.1.0`;本地测试不递增版本,不自动生成 ZIP、创建 release 或发布 npm。
59
+
60
+ ## 工程结构
61
+
62
+ | 目录 | 内容 |
63
+ | --- | --- |
64
+ | `src/ui` | React 任务中心与宿主桥接 |
65
+ | `src/mcp` | MCP 工具与页面资源 |
66
+ | `src/contracts` | 产品元数据、任务和附件契约 |
67
+ | `ledger` | 配置解析、只读投影、事务台账 |
68
+ | `skills/coordinator` | 跨聊天协调规则与命令参考 |
69
+ | `scripts` | 演示、开发、构建、本地同步 |
70
+ | `tests` | 独立 fixture 和回归测试 |
71
+
72
+ 参见[开发与验证](docs/development.md)、[贡献指南](CONTRIBUTING.md)、[安全说明](SECURITY.md)。私人配置、任务数据库、历史验收材料和安装产物不进入仓库。
73
+
74
+ ## 许可证
75
+
76
+ [MIT](LICENSE)。第三方依赖保留各自许可证。本项目不隶属于 OpenAI;Codex、ChatGPT 等名称属于各自权利人。
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import {main} from '../cli/main.mjs';
3
+ main().catch(error=>{console.error(JSON.stringify({ok:false,error:error.message}));process.exitCode=1;});
package/cli/main.mjs ADDED
@@ -0,0 +1,193 @@
1
+ import {existsSync,readFileSync,writeFileSync,mkdirSync,cpSync,renameSync,rmSync,readdirSync,lstatSync,realpathSync} from 'node:fs';
2
+ import {resolve,join,dirname,relative,isAbsolute} from 'node:path';
3
+ import {homedir} from 'node:os';
4
+ import {fileURLToPath} from 'node:url';
5
+ import {execFileSync,spawn} from 'node:child_process';
6
+ import {createHash,randomUUID} from 'node:crypto';
7
+ import {probe} from './probe.mjs';
8
+ const packageRoot=fileURLToPath(new URL('../',import.meta.url));
9
+ const pkg=JSON.parse(readFileSync(join(packageRoot,'package.json'),'utf8'));
10
+ const read=p=>JSON.parse(readFileSync(p,'utf8'));
11
+ const print=value=>console.log(JSON.stringify(value,null,2));
12
+ const help=`天枢 Tianshu ${pkg.version}\n\n tianshu init 初始化独立台账(已有配置不覆盖)\n tianshu install 将已编译插件安装到 Codex\n tianshu doctor 检查依赖、配置和已安装 MCP\n tianshu upgrade 将当前 npm 包同步到插件,保留上一份\n tianshu rollback 恢复上一份插件/未完成安装,保留台账\n tianshu uninstall 移除 Codex 插件和市场,保留用户数据\n\n选项:--config <绝对路径>,--help,--version\n环境:TIANSHU_HOME、TIANSHU_PYTHON、TIANSHU_CODEX、CODEX_HOME\n安装 npm 包不会自动修改 Codex。需要 Node.js 24–26、Python 3.12+、Codex CLI。`;
13
+
14
+ function atomic(path,value){const temp=path+'.'+randomUUID()+'.tmp';writeFileSync(temp,JSON.stringify(value,null,2)+'\n',{mode:0o600,flag:'wx'});renameSync(temp,path);}
15
+ function run(command,args,options={}){return execFileSync(command,args,{encoding:'utf8',timeout:30000,maxBuffer:4*1024*1024,stdio:['pipe','pipe','pipe'],env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'},...options});}
16
+ function canonical(path){const value=resolve(path);return existsSync(value)?realpathSync(value):value;}
17
+ function defaultHome(){return process.platform==='darwin'?join(homedir(),'Library/Application Support/Tianshu'):join(process.env.XDG_DATA_HOME||join(homedir(),'.local/share'),'tianshu');}
18
+ function environment(options){
19
+ const requestedHome=resolve(process.env.TIANSHU_HOME||defaultHome());
20
+ if(existsSync(requestedHome)&&lstatSync(requestedHome).isSymbolicLink())throw new Error('TIANSHU_HOME must not be a symbolic link');
21
+ const home=canonical(requestedHome);
22
+ const codexHome=canonical(process.env.CODEX_HOME||join(homedir(),'.codex'));
23
+ const context={home,codexHome,codex:process.env.TIANSHU_CODEX||'codex',python:process.env.TIANSHU_PYTHON||'python3',statePath:join(home,'installation.json'),journal:join(home,'operation.json'),market:join(home,'marketplace'),active:join(home,'marketplace/plugins/tianshu')};
24
+ if(existsSync(home)&&lstatSync(home).isSymbolicLink())throw new Error('TIANSHU_HOME must not be a symbolic link');
25
+ const state=existsSync(context.statePath)?read(context.statePath):null;
26
+ if(state&&(state.schema!==1||state.codex_home!==codexHome))throw new Error('Installation state belongs to a different CODEX_HOME or unsupported schema');
27
+ context.state=state;
28
+ const selected=options.config||process.env.TIANSHU_CONFIG||state?.config||join(home,'config.json');
29
+ if(!isAbsolute(selected))throw new Error('Config path must be absolute');
30
+ context.config=selected;context.explicitConfig=Boolean(options.config||process.env.TIANSHU_CONFIG);
31
+ return context;
32
+ }
33
+ function owned(c,path){
34
+ const rel=relative(c.home,path);if(!rel||rel.startsWith('..')||isAbsolute(rel))throw new Error('Refusing operation outside managed home');
35
+ let current=c.home;
36
+ for(const part of rel.split(/[\\/]/)){current=join(current,part);if(existsSync(current)&&lstatSync(current).isSymbolicLink())throw new Error('Managed path must not contain symbolic links');}
37
+ return path;
38
+ }
39
+ function stateWrite(c,state){owned(c,c.statePath);if(state)atomic(c.statePath,state);else rmSync(c.statePath,{force:true});}
40
+ function preflight(c,needCodex=true){
41
+ if(process.platform==='win32')throw new Error('This release supports macOS/Linux; Windows installation is not validated');
42
+ const node=Number(process.versions.node.split('.')[0]);if(node<24||node>26)throw new Error('Node.js 24–26 is required');
43
+ const python=JSON.parse(run(c.python,['-c','import sys,json;print(json.dumps({"version":list(sys.version_info[:2]),"path":sys.executable}))']));
44
+ if(python.version[0]!==3||python.version[1]<12)throw new Error('Python 3.12+ is required');c.python=python.path;
45
+ if(needCodex)run(c.codex,['--version'],{env:{...process.env,CODEX_HOME:c.codexHome}});
46
+ }
47
+ function codex(c,args){return JSON.parse(run(c.codex,args,{env:{...process.env,CODEX_HOME:c.codexHome}}));}
48
+ function registeredMarket(c){const result=codex(c,['plugin','marketplace','list','--json']);const list=Array.isArray(result)?result:result.marketplaces;if(!Array.isArray(list))throw new Error('Unsupported Codex marketplace listing');return list.find(m=>m.name==='tianshu')||null;}
49
+ function register(c,path){
50
+ if(registeredMarket(c))codex(c,['plugin','marketplace','remove','tianshu','--json']);
51
+ codex(c,['plugin','marketplace','add',path,'--json']);
52
+ const result=codex(c,['plugin','add','tianshu@tianshu','--json']);
53
+ return result.installedPath;
54
+ }
55
+ function verifyBundle(plugin,manifest){
56
+ if(lstatSync(plugin).isSymbolicLink())throw new Error('Plugin root must not be a symlink');
57
+ const actual=[];
58
+ function walk(dir){for(const e of readdirSync(dir,{withFileTypes:true})){const p=join(dir,e.name);if(e.isSymbolicLink())throw new Error('Plugin payload contains symlinks');if(e.isDirectory())walk(p);else if(e.isFile())actual.push(relative(plugin,p).split('\\').join('/'));else throw new Error('Unsupported payload entry');}}
59
+ walk(plugin);const expected=Object.keys(manifest.files||{}).sort();
60
+ if(!expected.length||JSON.stringify(actual.sort())!==JSON.stringify(expected))throw new Error('Plugin payload inventory mismatch');
61
+ for(const path of expected){if(isAbsolute(path)||path.split(/[\\/]/).includes('..'))throw new Error('Invalid integrity path');const hash=createHash('sha256').update(readFileSync(join(plugin,path))).digest('hex');if(hash!==manifest.files[path])throw new Error('Plugin integrity check failed: '+path);}
62
+ const info=read(join(plugin,'build-info.json'));
63
+ if(info.name!=='tianshu'||!/^[0-9a-f]{20}$/.test(info.build_id)||!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(info.version))throw new Error('Invalid plugin identity');
64
+ return info;
65
+ }
66
+ function archive(c){
67
+ const source=join(packageRoot,'dist/plugin'),integrity=read(join(packageRoot,'dist/build-info.json'));
68
+ const info=verifyBundle(source,integrity),id=info.version+'-'+info.build_id;
69
+ const directory=owned(c,join(c.home,'releases',id));mkdirSync(owned(c,join(c.home,'releases')),{recursive:true,mode:0o700});
70
+ if(!existsSync(directory)){
71
+ const stage=owned(c,join(c.home,'releases','.stage-'+randomUUID()));mkdirSync(stage,{mode:0o700});
72
+ try{cpSync(source,join(stage,'plugin'),{recursive:true});atomic(join(stage,'integrity.json'),integrity);renameSync(stage,directory);}finally{rmSync(stage,{recursive:true,force:true});}
73
+ }
74
+ if(JSON.stringify(read(join(directory,'integrity.json')).files)!==JSON.stringify(integrity.files))throw new Error('Build identity collision; existing release was preserved');
75
+ verifyBundle(owned(c,join(directory,'plugin')),read(owned(c,join(directory,'integrity.json'))));
76
+ return {id,directory,info};
77
+ }
78
+ function previousRelease(c,id){
79
+ if(!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?-[0-9a-f]{20}$/.test(id||''))throw new Error('No previous managed plugin is available');
80
+ const directory=owned(c,join(c.home,'releases',id)),info=verifyBundle(owned(c,join(directory,'plugin')),read(owned(c,join(directory,'integrity.json'))));
81
+ return {id,directory,info};
82
+ }
83
+ function validateConfig(c){
84
+ const result=JSON.parse(run(c.python,[join(packageRoot,'dist/plugin/ledger/read_ledger.py'),'--config',c.config]));
85
+ if(!result.sources?.length||result.sources.some(s=>!s.ok))throw new Error('Configured ledger is missing or unhealthy; no replacement database was created');
86
+ return result;
87
+ }
88
+ function initialize(c){
89
+ if(existsSync(c.config)){validateConfig(c);return false;}
90
+ if(c.explicitConfig)throw new Error('Explicit config does not exist; configure an existing ledger or run init without --config');
91
+ const data=owned(c,join(c.home,'data'));mkdirSync(data,{recursive:true,mode:0o700});
92
+ const db=owned(c,join(data,'coordinator.db'));
93
+ if(!existsSync(db))run(c.python,[join(packageRoot,'dist/plugin/ledger/coordinator.py'),'--db',db,'init']);
94
+ const temp=owned(c,join(c.home,'.config-'+randomUUID()+'.json'));
95
+ const config={sources:[{id:'daily',label:'日常任务',path:db}]};
96
+ try{atomic(temp,config);const candidate={...c,config:temp};validateConfig(candidate);writeFileSync(owned(c,c.config),JSON.stringify(config,null,2)+'\n',{flag:'wx',mode:0o600});}finally{rmSync(temp,{force:true});}
97
+ return true;
98
+ }
99
+ function marketplaceFile(c){
100
+ mkdirSync(owned(c,join(c.market,'.agents/plugins')),{recursive:true,mode:0o700});
101
+ atomic(join(c.market,'.agents/plugins/marketplace.json'),{name:'tianshu',interface:{displayName:'天枢'},plugins:[{name:'tianshu',source:{source:'local',path:'./plugins/tianshu'},policy:{installation:'AVAILABLE',authentication:'ON_INSTALL'},category:'Productivity'}]});
102
+ }
103
+ async function recover(c){
104
+ const journal=read(owned(c,c.journal));
105
+ if(journal.codex_home!==c.codexHome||!/^\.backup-[0-9a-f-]+$/.test(journal.backup))throw new Error('Invalid recovery record');
106
+ const backup=owned(c,join(c.home,journal.backup));
107
+ if(existsSync(backup)){rmSync(owned(c,c.active),{recursive:true,force:true});mkdirSync(dirname(c.active),{recursive:true});renameSync(backup,c.active);}
108
+ else if(!journal.had_active)rmSync(owned(c,c.active),{recursive:true,force:true});
109
+ if(journal.before_market){register(c,journal.before_market);}
110
+ else{codex(c,['plugin','remove','tianshu@tianshu','--json']);if(registeredMarket(c))codex(c,['plugin','marketplace','remove','tianshu','--json']);}
111
+ stateWrite(c,journal.before_state);rmSync(c.journal);return {recovered:true,data_preserved:true};
112
+ }
113
+ async function install(c,release){
114
+ const beforeMarket=registeredMarket(c);
115
+ if(beforeMarket&&canonical(beforeMarket.root)!==canonical(c.market)&&!c.explicitConfig&&!c.state)throw new Error('An existing Tianshu installation was found. Use --config with its existing configuration to preserve tasks.');
116
+ initialize(c);validateConfig(c);
117
+ const stage=owned(c,join(c.home,'.stage-'+randomUUID())),backup='.backup-'+randomUUID(),backupPath=owned(c,join(c.home,backup));
118
+ mkdirSync(stage,{mode:0o700});
119
+ try{
120
+ cpSync(join(release.directory,'plugin'),stage,{recursive:true});
121
+ atomic(join(stage,'runtime-config.json'),{config_path:c.config});
122
+ const cache=join(c.codexHome,'plugins/cache/tianshu/tianshu',release.info.version);
123
+ atomic(join(stage,'.mcp.json'),{mcpServers:{tianshu:{command:process.execPath,args:[join(cache,'dist/server.mjs')],env:{TIANSHU_PYTHON:c.python,TIANSHU_CONFIG:c.config,PYTHONDONTWRITEBYTECODE:'1'}}}});
124
+ await probe(join(stage,'dist/server.mjs'),c.python,c.config);
125
+ const journal={codex_home:c.codexHome,backup,had_active:existsSync(c.active),before_state:c.state,before_market:beforeMarket?.root||null};
126
+ atomic(owned(c,c.journal),journal);
127
+ try{
128
+ mkdirSync(owned(c,dirname(c.active)),{recursive:true,mode:0o700});
129
+ if(journal.had_active)renameSync(c.active,backupPath);renameSync(stage,c.active);marketplaceFile(c);
130
+ const installed=register(c,c.market);if(!installed||canonical(installed)!==canonical(cache))throw new Error('Unexpected Codex installed path: '+installed);
131
+ const health=await probe(join(cache,'dist/server.mjs'),c.python,c.config);
132
+ if(health.build?.build_id!==release.info.build_id)throw new Error('Installed build does not match requested build');
133
+ const previous=c.state?.active===release.id?c.state.previous:c.state?.active||null;
134
+ stateWrite(c,{schema:1,codex_home:c.codexHome,active:release.id,previous,config:c.config,installed:true,installed_at:new Date().toISOString(),adopted_marketplace:c.state?.adopted_marketplace||(beforeMarket?.root!==c.market?beforeMarket?.root:null)});
135
+ rmSync(c.journal);rmSync(backupPath,{recursive:true,force:true});
136
+ return {installed:true,plugin:'tianshu@tianshu',...release.info,health,data_preserved:true,reload_client:true};
137
+ }catch(error){
138
+ try{await recover(c);}catch{throw new Error('Install failed and recovery is incomplete. Run tianshu rollback after checking Codex availability; user data was preserved.');}
139
+ throw new Error('Install failed; previous plugin restored: '+error.message);
140
+ }
141
+ }finally{rmSync(stage,{recursive:true,force:true});}
142
+ }
143
+ async function lock(c){
144
+ mkdirSync(c.home,{recursive:true,mode:0o700});const path=owned(c,join(c.home,'lifecycle.lock'));
145
+ // Kernel flock is released on exit/crash. Never unlink the stable lock inode.
146
+ const code='import os,sys,fcntl; fd=os.open(sys.argv[1],os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600); fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB); print("LOCKED",flush=True); sys.stdin.buffer.read()';
147
+ const child=spawn(c.python,['-c',code,path],{stdio:['pipe','pipe','pipe']});child.stderr.resume();
148
+ await new Promise((resolve,reject)=>{
149
+ const timer=setTimeout(()=>{child.kill();reject(new Error('Lifecycle lock timed out'));},5000);
150
+ child.once('error',error=>{clearTimeout(timer);reject(error);});
151
+ child.stdout.once('data',data=>{clearTimeout(timer);data.toString().trim()==='LOCKED'?resolve():reject(new Error('Lifecycle lock failed'));});
152
+ child.once('exit',()=>{clearTimeout(timer);reject(new Error('Another lifecycle operation is running or lock acquisition failed'));});
153
+ });
154
+ return ()=>new Promise(resolve=>{const timer=setTimeout(()=>child.kill('SIGKILL'),1000);child.once('close',()=>{clearTimeout(timer);resolve();});child.stdin.end();});
155
+ }
156
+
157
+ export async function main(argv=process.argv.slice(2)){
158
+ if(!argv.length||argv.includes('--help'))return console.log(help);
159
+ if(argv.length===1&&argv[0]==='--version')return console.log(pkg.version);
160
+ const command=argv.shift(),options={};
161
+ for(let i=0;i<argv.length;i++){if(argv[i]==='--config'&&argv[i+1])options.config=argv[++i];else throw new Error('Unknown option: '+argv[i]);}
162
+ if(!['init','install','upgrade','rollback','uninstall','doctor'].includes(command))throw new Error('Unknown command: '+command);
163
+ const c=environment(options);preflight(c,command!=='init');
164
+ if(command==='doctor'){
165
+ if(existsSync(c.journal))throw new Error('An interrupted lifecycle operation requires tianshu rollback');
166
+ if(!c.state?.installed)throw new Error('Plugin is not installed by this CLI; run tianshu install');
167
+ validateConfig(c);const release=previousRelease(c,c.state.active),cache=join(c.codexHome,'plugins/cache/tianshu/tianshu',release.info.version);
168
+ const market=registeredMarket(c);if(!market||canonical(market.root)!==canonical(c.market))throw new Error('Codex marketplace does not match this installation');
169
+ const health=await probe(join(cache,'dist/server.mjs'),c.python,c.config);if(health.build?.build_id!==release.info.build_id)throw new Error('Installed build drift detected');
170
+ return print({ok:true,home:c.home,config:c.config,health,host_ui_verified:false});
171
+ }
172
+ if(command==='install')mkdirSync(c.codexHome,{recursive:true,mode:0o700});
173
+ const unlock=await lock(c);
174
+ try{
175
+ // Re-read mutable installation state only after acquiring the lifecycle lock.
176
+ const python=c.python;Object.assign(c,environment(options),{python});
177
+ if(existsSync(c.journal)){if(command!=='rollback')throw new Error('An interrupted operation requires tianshu rollback');return print(await recover(c));}
178
+ if(command==='init'){initialize(c);return print({initialized:true,config:c.config});}
179
+ if(command==='uninstall'){
180
+ const market=registeredMarket(c);if(market&&canonical(market.root)!==canonical(c.market))throw new Error('Refusing to remove an installation managed elsewhere');
181
+ if(market&&!c.state?.installed)throw new Error('Managed installation state is missing or inconsistent; reinstall before uninstalling');
182
+ if(c.state?.installed){
183
+ atomic(owned(c,c.journal),{codex_home:c.codexHome,backup:'.backup-'+randomUUID(),had_active:existsSync(c.active),before_state:c.state,before_market:market?.root||null});
184
+ try{codex(c,['plugin','remove','tianshu@tianshu','--json']);if(market)codex(c,['plugin','marketplace','remove','tianshu','--json']);stateWrite(c,{...c.state,installed:false});rmSync(c.journal);}
185
+ catch(error){try{await recover(c);}catch{throw new Error('Uninstall recovery is incomplete; run tianshu rollback. User data was preserved.');}throw new Error('Uninstall failed; previous plugin restored: '+error.message);}
186
+ }
187
+ return print({uninstalled:true,data_preserved:true,home:c.home});
188
+ }
189
+ if(command==='upgrade'&&!c.state?.installed)throw new Error('No managed installation; run tianshu install first');
190
+ const release=command==='rollback'?previousRelease(c,c.state?.previous):archive(c);
191
+ return print(await install(c,release));
192
+ }finally{await unlock();}
193
+ }
package/cli/probe.mjs ADDED
@@ -0,0 +1,37 @@
1
+ import {spawn} from 'node:child_process';
2
+
3
+ // Exercise the shipped server over stdio without SDK dependencies or host UI actions.
4
+ export function probe(server,python,config){
5
+ return new Promise((resolve,reject)=>{
6
+ const child=spawn(process.execPath,[server],{env:{...process.env,TIANSHU_PYTHON:python,TIANSHU_CONFIG:config,PYTHONDONTWRITEBYTECODE:'1'},stdio:['pipe','pipe','pipe']});
7
+ let buffer='',bytes=0,finished=false;
8
+ const timer=setTimeout(()=>finish(new Error('MCP health check timed out')),12000);
9
+ function finish(error,value){if(finished)return;finished=true;clearTimeout(timer);child.kill();const force=setTimeout(()=>child.kill('SIGKILL'),1000);force.unref();child.once('close',()=>clearTimeout(force));error?reject(error):resolve(value);}
10
+ const send=value=>child.stdin.write(JSON.stringify(value)+'\n');
11
+ child.on('error',error=>finish(error));child.on('exit',()=>{if(!finished)finish(new Error('MCP server exited before health check completed'));});
12
+ child.stdin.on('error',error=>finish(error));child.stderr.resume();
13
+ child.stdout.on('data',chunk=>{
14
+ bytes+=chunk.length;if(bytes>4*1024*1024)return finish(new Error('MCP response exceeds health check limit'));
15
+ buffer+=chunk.toString();let newline;
16
+ while((newline=buffer.indexOf('\n'))>=0){
17
+ const line=buffer.slice(0,newline);buffer=buffer.slice(newline+1);if(!line.trim())continue;
18
+ try{
19
+ const response=JSON.parse(line);if(response.error)throw new Error('MCP health request rejected');
20
+ if(response.id===1){
21
+ if(response.result?.serverInfo?.name!=='tianshu')throw new Error('Unexpected MCP server identity');
22
+ send({jsonrpc:'2.0',method:'notifications/initialized'});send({jsonrpc:'2.0',id:2,method:'tools/list',params:{}});
23
+ }else if(response.id===2){
24
+ const tools=(response.result?.tools||[]).map(t=>t.name).sort();
25
+ if(JSON.stringify(tools)!==JSON.stringify(['delete_task','get_task','list_tasks','open_workspace']))throw new Error('Unexpected MCP tool inventory');
26
+ send({jsonrpc:'2.0',id:3,method:'tools/call',params:{name:'list_tasks',arguments:{}}});
27
+ }else if(response.id===3){
28
+ const data=response.result?.structuredContent;
29
+ if(response.result?.isError||!data?.readonly||!data.sources?.length||data.sources.some(s=>!s.ok))throw new Error('Configured ledger is not healthy/readable');
30
+ finish(null,{build:data.build,task_count:data.tasks.length,tools:4});
31
+ }
32
+ }catch(error){finish(error);}
33
+ }
34
+ });
35
+ send({jsonrpc:'2.0',id:1,method:'initialize',params:{protocolVersion:'2025-03-26',capabilities:{},clientInfo:{name:'tianshu-doctor',version:'0.1.0'}}});
36
+ });
37
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "tianshu",
3
+ "version": "0.1.0",
4
+ "build_id": "c1a359e812cd60471640",
5
+ "files": {
6
+ ".codex-plugin/plugin.json": "641407767454849c99ac71830985099eb953b2cc15441518aa53634506ae5595",
7
+ ".mcp.json": "3ce62a0d6713492af210b08d178631c2fdf7289da73f3bcc18afb309b68d438f",
8
+ "THIRD_PARTY_NOTICES.txt": "2b1d9634bbbb03744bb91b6d5bf9acab2fb292b5dd88bfbb21940b692dae2dfb",
9
+ "build-info.json": "675fb07b1fd26d69040ca1f90354f0c74d69d5c6dd423cd22ba26ee22b4a17a1",
10
+ "dist/panel.html": "e50380e8d6ad8d9ca86c66a89c6878c943d17a84a3d7f7fc28a269d2a0527e72",
11
+ "dist/server.mjs": "044fb54532de96bfeebeb321dd1ac175fcb9dd399d36b735da49f1648259baa5",
12
+ "ledger/cli.py": "af7d889029aae5ee5b764dd0914ec8e0ef4e8dcdf27ef0dd0c66db8f86a5089d",
13
+ "ledger/config.py": "b3ed0c975ca744f00db5a142bb7feacb7c203455ef45822a6d8d3fcc6c01c377",
14
+ "ledger/coordinator.py": "839d685862f8f2879169295d616e74bbd0bf10c272b1f703ee72e24e27e630cf",
15
+ "ledger/read_ledger.py": "56f9c9d3ec9decb421e0cb6e89861c69bce2379de848353ea1e3656389cac885",
16
+ "skills/coordinator/SKILL.md": "30fcf652170692ecba39c9b306231bd581c918f9b939621b4a2cae4ca3418af5",
17
+ "skills/coordinator/agents/openai.yaml": "75d31a3b984c373e87f9846ad76051daf0370ee3a9ab275b171ab7c32061b15a",
18
+ "skills/coordinator/references/commands.md": "68b47788204dc5bcd725d65922c8f66a14191240a6bdedd0ecc0b227a7c54e15",
19
+ "skills/coordinator/references/recovery.md": "ec905ed9f28c5cb9eddd5a212b5a37bd96694faa7b8495596cc9e633dd0097c9",
20
+ "skills/coordinator/references/task-center.md": "7b4a7b12a2a13ca5fbc3519cd5483b8cbcc8619642c92876d59fda25297420c5",
21
+ "skills/coordinator/scripts/coordinator.py": "876861791b88f4a2b51540a6999687abc722b981a9ee5533495b6057a2592191"
22
+ }
23
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "tianshu",
3
+ "version": "0.1.0",
4
+ "author": {
5
+ "name": "Jeffusion"
6
+ },
7
+ "description": "天枢:面向长期目标的智能协作工作台。",
8
+ "mcpServers": "./.mcp.json",
9
+ "interface": {
10
+ "displayName": "天枢",
11
+ "shortDescription": "面向目标的智能协作工作台",
12
+ "longDescription": "在天枢查看项目、任务与验收证据,将任务带到聊天;直接 @天枢 查询进度、推进、核验或删除已结束任务。",
13
+ "developerName": "Jeffusion",
14
+ "category": "Productivity",
15
+ "capabilities": [
16
+ "Read",
17
+ "Write"
18
+ ],
19
+ "defaultPrompt": "打开天枢任务中心。"
20
+ }
21
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "mcpServers": {
3
+ "tianshu": {
4
+ "command": "node",
5
+ "args": [
6
+ "${PLUGIN_ROOT}/dist/server.mjs"
7
+ ]
8
+ }
9
+ }
10
+ }