@givolt/i3-composer-agent 2.2.1 → 2.2.3

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.
@@ -6,7 +6,7 @@
6
6
  "plugins": [
7
7
  {
8
8
  "name": "i3-composer-agent",
9
- "version": "2.2.1",
9
+ "version": "2.2.3",
10
10
  "description": "覆盖需求、计划、开发、测试、审查与发布的软件工程技能,默认使用简体中文。",
11
11
  "source": {
12
12
  "source": "local",
@@ -8,7 +8,7 @@
8
8
  "plugins": [
9
9
  {
10
10
  "name": "i3-composer-agent",
11
- "version": "2.2.1",
11
+ "version": "2.2.3",
12
12
  "source": "./",
13
13
  "description": "覆盖需求、计划、开发、测试、审查与发布的软件工程技能,默认使用简体中文。",
14
14
  "homepage": "https://github.com/49780522/i3-composer-agent",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i3-composer-agent",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "覆盖需求、计划、开发、测试、审查与发布的软件工程技能,默认使用简体中文。",
5
5
  "author": {
6
6
  "name": "Givolt"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i3-composer-agent",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "覆盖需求、计划、开发、测试、审查与发布的软件工程技能,默认使用简体中文。",
5
5
  "author": {
6
6
  "name": "Givolt"
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # i3 Composer Agent v2.2.1
1
+ # i3 Composer Agent v2.2.3
2
2
 
3
3
  **Intelligence. Integration. Innovation.**
4
4
 
@@ -26,8 +26,9 @@ WorkBuddy 在第 2 项。上下键移动,空格勾选/取消,回车确认;
26
26
  确认工具后选择项目/全局范围并确认安装。
27
27
 
28
28
  全部目标均安装 25 个技能、共享参考资料,以及完整评测/Hook/脚本配套。
29
- CodeBuddy、WorkBuddy、Claude Code 另安装 9 个短命令和 4 个 Agent 角色。
30
- 其他工具通过其原生 Skills 机制使用;短命令支持取决于宿主。
29
+ 全部目标同时提供 9 个命令和 4 个 Agent 角色提示。
30
+ CodeBuddy、WorkBuddy、Claude Code 使用原生命令入口;其他工具通过 Skills 机制使用,
31
+ 命令和角色文件可作为资料读取,是否自动发现取决于宿主。
31
32
  目录预设来自固定的 [Skills CLI 工具登记](https://github.com/vercel-labs/skills/blob/48dc9e8eb8aa19040e91cb90ba0b642c9cbad57e/src/agents.ts)
32
33
  并增加 WorkBuddy。目录预设验证与每个宿主的实际执行验收分别记录。
33
34
 
@@ -53,44 +54,71 @@ CodeBuddy 和 WorkBuddy 的项目安装共用 `.codebuddy/`,相同目标只安
53
54
  同版本、同范围的清单所属未修改内容会复用,新增客户端合并到清单。
54
55
  升级、修改过的内容及未登记同名冲突需确认替换;非交互使用 `--force` 前可先看 dry-run。
55
56
  安装成功后清理替换掉的旧内容及本安装器所属历史备份,失败则恢复原文件。
56
- 迁移只清理旧清单所属的无前缀 Skill/命令路径,保留自定义内容。
57
- 标准 MIT 许可随共享参考资料安装。`npx -y` 确认 npm 下载;包的 `--yes` 确认安装计划。
57
+ 升级按文件归属清理旧副本,保留用户新增文件;2.2.2 的分散配套资料迁入工具根;2.2.1 的评测结果保留原位。
58
+ 同名结果内容不同时会在写入前停止;未归属本包的资料冲突也需先处理,`--force` 不会接管。
59
+ 成功后不保留临时备份。`npx -y` 确认 npm 下载;包的 `--yes` 确认安装计划。
58
60
 
59
61
  ## 安装内容与可选功能
60
62
 
61
63
  | 内容 | 默认安装 | 用途 |
62
64
  | --- | --- | --- |
63
65
  | `skills/`(含技能内部的 `scripts/` 等资源) | 是 | 工程工作流及运行辅助文件 |
64
- | `references/` | 是 | 技能引用的检查清单与参考资料 |
65
- | `commands/`、`agents/` | CodeBuddy、WorkBuddy、Claude Code | 短命令与专业角色 |
66
- | `hooks/` | 是,文件安装后按宿主方式启用 | 会话注入、文档缓存和代码块保护 |
67
- | `scripts/` | 是 | 评测运行器、五项校验脚本及其共享库 |
68
- | `evals/` | 是 | 中英文触发/行为用例、测试项目样例及插件评分规则 |
66
+ | `i3-composer-agent/references/` | 是 | 技能引用的检查清单与参考资料 |
67
+ | `commands/`、`agents/` | 是;发现与调用依宿主支持 | 短命令与专业角色提示 |
68
+ | `i3-composer-agent/hooks/` | 是,文件安装后按宿主方式启用 | 会话注入、文档缓存和代码块保护 |
69
+ | `i3-composer-agent/scripts/` | 是 | 评测运行器、五项校验、全部原版脚本回归测试及共享库 |
70
+ | `i3-composer-agent/evals/` | 是 | 中英文触发/行为用例、测试项目样例及插件评分规则 |
69
71
 
70
- 完整配套默认放在宿主目录下的 `i3-composer-agent/`,例如
71
- `.codebuddy/i3-composer-agent/` 或 `~/.workbuddy/i3-composer-agent/`。
72
- 其中保留对应的技能、命令、角色、参考资料与清单,供评测、校验和 Hook 的相对路径使用;
73
- 宿主调用入口继续安装在其原生目录。该配套与技能一起升级、复用和失败恢复。
72
+ 以项目安装的 `.codebuddy/` 为例,默认目录为:
74
73
 
75
- 从配套目录运行以下检查,不调用模型:
74
+ ```text
75
+ .codebuddy/
76
+ ├─ skills/ # 宿主和评测共用的实际技能
77
+ ├─ commands/ # 一份命令
78
+ ├─ agents/ # 一份角色提示
79
+ ├─ i3-composer-agent/ # 配套工具根,不是第二份插件
80
+ │ ├─ package.json # 独立 CommonJS 配置,提供 npm test
81
+ │ ├─ README.md
82
+ │ ├─ scripts/ # 全部脚本、原有回归测试及 lib/
83
+ │ ├─ evals/ # 中英文用例、样例、插件评测及结果
84
+ │ ├─ hooks/
85
+ │ ├─ references/
86
+ │ ├─ docs/
87
+ │ └─ adapters/ # 其他命令格式和插件清单的校验输入
88
+ └─ .i3-composer-agent.json # 版本、布局、能力与文件归属清单
89
+ ```
90
+
91
+ 每个宿主基址只有一份技能;公共资料集中在配套工具根目录。技能内部的 scripts/references 保留。
92
+ WorkBuddy 全局安装将基址换为 `~/.workbuddy/`,Codex 项目安装为 `.agents/`。
93
+ 评测、校验和适用的 Hook 读取宿主使用的实际内容,不生成第二份完整插件。
94
+
95
+ 进入配套目录后运行以下命令,不调用模型:
76
96
 
77
97
  ```bash
98
+ cd .codebuddy/i3-composer-agent
78
99
  npm test
79
100
  node scripts/run-evals.js --locale zh-CN --min-rank1 95
80
101
  ```
81
102
 
103
+ `npm test` 执行五项校验、中英文确定性评测和全部原版脚本回归测试。
104
+ 原版英文案例完整保留,中文案例作为增量,两种语言分别通过门槛。
105
+ 脚本也可使用绝对路径从任意目录运行;无需 `npm install`。
106
+
82
107
  模型行为评测需要主动运行并消耗模型额度,详见 [评测说明](evals/README.md)。
83
- 本包的 `npm test` 检查随包技能的结构和中英文触发,不是业务项目测试或宿主执行验收。
108
+ 原生脚本按安装清单检查本包内容,支持业务项目的 ESM/CommonJS 设置。
109
+ 配套根与独立插件导出目录均可执行 `npm test`;这些检查均不能代替业务项目测试或宿主执行验收。
84
110
  评测结果不进入安装替换清单。安装过程不执行评测,也不自动启用 Hook。
85
111
 
86
112
  可选 Hook 不影响普通技能和命令的使用,默认中文交互也不依赖 Hook。
87
113
  未配置 Hook 时,自动注入、缓存和代码块保护这些增强功能不会运行。
88
114
  会话注入仅适合缺少原生技能路由的宿主;已有原生路由时不应重复注入元技能。
89
- 缓存与代码块保护的配置说明分别在配套目录的 `hooks/SDD-CACHE.md`、
90
- `hooks/SIMPLIFY-IGNORE.md`,当前示例针对 Claude Code,其他宿主需适配自己的事件协议。
115
+ 缓存与代码块保护的配置说明位于 `i3-composer-agent/hooks/` 下的 `SDD-CACHE.md`、
116
+ `SIMPLIFY-IGNORE.md`,当前示例针对 Claude Code,其他宿主需适配自己的事件协议。
91
117
  脚本依赖 Bash、jq;缓存还需 curl 和哈希工具,代码块保护还需其文档列出的工具。
92
118
  Windows PowerShell 本身不会自动提供这些脚本依赖。
93
119
 
120
+ `i3-composer-agent/adapters/` 中的其他命令格式与插件清单用于完整回归校验,不自动注册插件。
121
+
94
122
  ## 通过宿主插件管理器安装
95
123
 
96
124
  默认配套已包含 Skills、Commands、Agents、references、evals、hooks 和 scripts。
@@ -3,7 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const crypto = require('node:crypto');
6
- const { nativeContent } = require('./native-content.cjs');
6
+ const { renderAsset } = require('./native-layout.cjs');
7
7
 
8
8
  // Compare the complete tree without following links. When asset is provided,
9
9
  // hash the source as it will be rendered by the native installer.
@@ -21,13 +21,11 @@ function fingerprint(file, asset) {
21
21
  if (stat.isDirectory()) {
22
22
  for (const entry of fs.readdirSync(current).sort()) {
23
23
  if (asset && entry === '.DS_Store') continue;
24
- if (asset?.kind === 'support' && ['results', 'node_modules', '.git'].includes(entry)) continue;
25
24
  if (!visit(path.join(current, entry), path.join(relative, entry))) return false;
26
25
  }
27
26
  } else {
28
- const content = asset && current.endsWith('.md')
29
- ? Buffer.from(nativeContent(fs.readFileSync(current, 'utf8'), asset))
30
- : fs.readFileSync(current);
27
+ const bytes = fs.readFileSync(current);
28
+ const content = asset ? renderAsset(bytes, asset) : bytes;
31
29
  hash.update(String(content.length) + '\n');
32
30
  hash.update(content);
33
31
  }
@@ -46,7 +46,8 @@ ${pkg.name}
46
46
  上下键移动/跨页,空格勾选/取消,回车确认;列表下实时显示已选,q/Esc 退出。
47
47
  CodeBuddy、WorkBuddy、Claude Code 同时安装短命令和 Agent 角色。
48
48
  其他工具通过 Skills 调用,短命令支持取决于宿主。
49
- 所有工具默认安装完整配套目录 i3-composer-agent/,含 evals、hooks、scripts 及所需资料。
49
+ 所有工具默认安装单份 skills、commands、agents;配套资料集中在 i3-composer-agent/。
50
+ 评测、脚本和 Hook 读取同一份已安装技能。--prepare 用于另行导出标准插件并按宿主方式注册。
50
51
  WorkBuddy 桌面版建议全局安装,便于技能列表和输入补全发现。
51
52
  评测需主动运行;Hook 文件已提供,需按宿主事件协议单独启用。
52
53
  --prepare 仅供插件管理器安装方式使用,不自动注册插件或启用 Hook。
@@ -201,12 +202,16 @@ async function main() {
201
202
  if (plan.reused.length) console.log(`已复用同版本的 ${plan.reused.length} 个未修改内容。`);
202
203
  for (const target of plan.targets) {
203
204
  console.log(`已为 ${target.agents.join(', ')} 安装技能:${target.skillsDirectory}`);
204
- if (target.commandsDirectory) console.log(`命令:/i3-spec、/i3-plan、/i3-build、/i3-ship(共 ${fs.readdirSync(target.commandsDirectory).filter(file => file.endsWith('.md')).length} 个工作流):${target.commandsDirectory}`);
205
- console.log(`配套目录(含 evals、hooks、scripts):${target.supportDirectory}`);
206
- console.log(`中文评测:node "${path.join(target.supportDirectory, 'scripts/run-evals.js')}" --locale zh-CN --min-rank1 95`);
205
+ if (target.nativeCommands) console.log(`命令:/i3-spec、/i3-plan、/i3-build、/i3-ship(共 ${target.catalog.commands.length} 个工作流):${target.commandsDirectory}`);
206
+ else console.log(`命令和角色资料已提供;是否自动发现取决于宿主:${target.commandsDirectory}、${target.personasDirectory}`);
207
+ console.log(`评测资料:${target.evalsDirectory}`);
208
+ console.log(`完整校验:进入 "${target.toolkitDirectory}" 后运行 npm test`);
209
+ console.log(`中文评测:node "${path.join(target.scriptsDirectory, 'run-evals.js')}" --locale zh-CN --min-rank1 95`);
210
+ console.log(`Hook:${target.hooksDirectory}(尚未启用)`);
207
211
  }
212
+ for (const file of plan.preserved || []) console.log(`保留了用户文件:${file}`);
208
213
  console.log('请重新加载 IDE 窗口或新建宿主会话以发现安装内容。');
209
214
  if (plan.agents.some(id => !['codebuddy', 'workbuddy', 'claude-code'].includes(id))) console.log('这些工具通过技能 ID 调用;生命周期短命令支持取决于宿主。');
210
- console.log('安装完成。评测运行方式见配套目录 evals/README.md;Hook 文件已安装,尚未启用。');
215
+ console.log('安装完成。评测说明见 i3-composer-agent/evals/README.md;Hook 需按宿主事件协议启用。');
211
216
  }
212
217
  main().catch(error => { console.error(`${name}: ${error.message}`); process.exitCode = 1; });
package/bin/install.cjs CHANGED
@@ -6,7 +6,8 @@ const os = require('node:os');
6
6
  const crypto = require('node:crypto');
7
7
  const registry = require('./agent-targets.json');
8
8
  const { cleanupBackups } = require('./cleanup-backups.cjs');
9
- const { nativeContent } = require('./native-content.cjs');
9
+ const { nativeAssets, renderAsset } = require('./native-layout.cjs');
10
+ const { migrationAssets, pruneRetiredDirectories, ownsAsset } = require('./native-migration.cjs');
10
11
  const { fingerprint } = require('./asset-fingerprint.cjs');
11
12
  const root = path.resolve(__dirname, '..');
12
13
  const name = 'i3-composer-agent';
@@ -49,8 +50,10 @@ function checkDestination(file) {
49
50
  }
50
51
  function assetPath(base, file) { return path.relative(base, file).split(path.sep).join('/'); }
51
52
  function manifestContent(plan, asset) {
52
- return JSON.stringify({ name, version, entryPrefix: 'i3-', scope: plan.scope, agents: asset.target.agents,
53
- assets: plan.assets.filter(item => item.base === asset.base && item.kind !== 'legacy').map(item => assetPath(asset.base, item.destination)) }, null, 2) + '\n';
53
+ return JSON.stringify({ name, version, layoutVersion: 3, entryPrefix: 'i3-', scope: plan.scope, agents: asset.target.agents,
54
+ capabilities: { nativeCommands: !!asset.target.nativeCommands, nativeAgents: !!asset.target.nativeCommands, hooks: 'manual' },
55
+ catalog: asset.target.catalog, resources: asset.target.resources,
56
+ assets: plan.assets.filter(item => item.base === asset.base && !['legacy', 'result'].includes(item.kind)).map(item => assetPath(asset.base, item.destination)) }, null, 2) + '\n';
54
57
  }
55
58
  function planInstallation({ agents, scope, projectDir = process.cwd(), home = os.homedir(), env = process.env }) {
56
59
  agents = selectAgents(agents);
@@ -61,24 +64,17 @@ function planInstallation({ agents, scope, projectDir = process.cwd(), home = os
61
64
  const agent = registry.agents.find(item => item.id === id);
62
65
  const skillsDirectory = destination(scope === 'project' ? path.resolve(projectDir, agent.projectSkillsDir) : globalSkillsDir(agent, home, env));
63
66
  const base = path.dirname(skillsDirectory);
64
- if (!targets.has(skillsDirectory)) targets.set(skillsDirectory, { agents: [], skillsDirectory, referencesDirectory: path.join(base, 'references'), supportDirectory: path.join(base, name), base });
67
+ if (!targets.has(skillsDirectory)) targets.set(skillsDirectory, { agents: [], skillsDirectory, toolkitDirectory: path.join(base, name), referencesDirectory: path.join(base, name, 'references'),
68
+ commandsDirectory: path.join(base, 'commands'), personasDirectory: path.join(base, 'agents'),
69
+ evalsDirectory: path.join(base, name, 'evals'), scriptsDirectory: path.join(base, name, 'scripts'),
70
+ hooksDirectory: path.join(base, name, 'hooks'), docsDirectory: path.join(base, name, 'docs'), base });
65
71
  const target = targets.get(skillsDirectory);
66
72
  target.agents.push(id);
67
73
  if (['codebuddy', 'workbuddy', 'claude-code'].includes(id)) {
68
- target.commandsDirectory = path.join(base, 'commands');
69
- target.personasDirectory = path.join(base, 'agents');
74
+ target.nativeCommands = true;
70
75
  }
71
76
  }
72
77
  const assets = [];
73
- function addDirectory(sourceDirectory, destinationDirectory, kind) {
74
- if (exists(destinationDirectory) && fs.lstatSync(destinationDirectory).isSymbolicLink()) {
75
- throw new Error(`安装目录是符号链接(symlink): ${destinationDirectory}. 请选择其真实目录。`);
76
- }
77
- for (const entry of fs.readdirSync(path.join(root, sourceDirectory)).sort()) {
78
- if (entry === '.DS_Store') continue;
79
- assets.push({ source: path.join(root, sourceDirectory, entry), destination: destination(path.join(destinationDirectory, entry)), kind });
80
- }
81
- }
82
78
  for (const target of targets.values()) {
83
79
  const manifestFile = path.join(target.base, `.${name}.json`);
84
80
  const previous = exists(manifestFile) && !fs.lstatSync(manifestFile).isSymbolicLink()
@@ -87,7 +83,7 @@ function planInstallation({ agents, scope, projectDir = process.cwd(), home = os
87
83
  // Earlier Windows installs persisted backslashes. Normalize before lookup
88
84
  // and containment checks so owned upgrades work across both manifest forms.
89
85
  if (owned) previous.assets = previous.assets.map(relative => typeof relative === 'string' ? relative.replaceAll('\\', '/') : relative);
90
- const sameVersion = owned && previous.version === version && previous.scope === scope && previous.entryPrefix === 'i3-';
86
+ const sameVersion = owned && previous.layoutVersion === 3 && previous.version === version && previous.scope === scope && previous.entryPrefix === 'i3-';
91
87
  if (owned && previous.scope === scope && Array.isArray(previous.agents)) {
92
88
  const applicable = previous.agents.filter(id => {
93
89
  const agent = registry.agents.find(item => item.id === id);
@@ -97,47 +93,23 @@ function planInstallation({ agents, scope, projectDir = process.cwd(), home = os
97
93
  });
98
94
  target.agents = [...new Set([...applicable, ...target.agents])];
99
95
  }
96
+ target.nativeCommands = target.agents.some(id => ['codebuddy', 'workbuddy', 'claude-code'].includes(id));
100
97
  const start = assets.length;
101
- addDirectory('skills', target.skillsDirectory, 'skill');
102
- addDirectory('references', target.referencesDirectory, 'reference');
103
- if (target.commandsDirectory) {
104
- addDirectory('.claude/commands', target.commandsDirectory, 'command');
105
- addDirectory('agents', target.personasDirectory, 'persona');
106
- const docs = path.join(target.base, 'docs');
107
- if (exists(docs) && fs.lstatSync(docs).isSymbolicLink()) throw new Error(`安装目录是符号链接(symlink): ${docs}.`);
108
- assets.push({ source: path.join(root, 'docs/agents.md'), destination: destination(path.join(target.base, 'docs/agents.md')), kind: 'documentation' });
109
- }
110
- const licenseDirectory = path.join(target.referencesDirectory, name);
111
- if (exists(licenseDirectory) && fs.lstatSync(licenseDirectory).isSymbolicLink()) throw new Error(`安装目录是符号链接(symlink): ${licenseDirectory}.`);
112
- assets.push({ source: path.join(root, 'LICENSE'), destination: destination(path.join(licenseDirectory, 'LICENSE')), kind: 'attribution' });
113
- // Keep the canonical plugin layout for eval fixtures, validators and Hook
114
- // relative paths. Native entry points above remain host-adapted copies.
115
- for (const relative of pluginFiles) {
116
- const file = path.join(target.supportDirectory, relative);
117
- if (destination(file) !== file) throw new Error(`配套目录包含符号链接(symlink): ${file}`);
118
- assets.push({ source: path.join(root, relative), destination: file, kind: 'support' });
119
- }
120
- if (owned) {
121
- const legacy = fs.readdirSync(path.join(root, 'skills')).filter(id => id.startsWith('i3-')).map(id => `skills/${id.slice(3)}`);
122
- if (target.commandsDirectory) legacy.push(...fs.readdirSync(path.join(root, '.claude/commands')).filter(file => file.startsWith('i3-') && file.endsWith('.md')).map(file => `commands/${file.slice(3)}`));
123
- legacy.push(`${name}-LICENSE`, `${name}-NOTICE.md`);
124
- const current = new Set(assets.slice(start).map(asset => assetPath(target.base, asset.destination)));
125
- for (const relative of previous.assets) {
126
- // Retire only owned native entries or entries inside this package's
127
- // companion directory. Never let a manifest extend the deletion scope.
128
- if (typeof relative !== 'string' || relative.includes('\0') || relative.includes(':')
129
- || relative.split('/').some(part => ['', '.', '..'].includes(part))) continue;
130
- if (!/^(skills|commands|agents|references)\/[^/]+$/.test(relative) && !relative.startsWith(`${name}/`)) continue;
131
- if (relative.startsWith(`${name}/`) && destination(path.join(target.base, relative)) !== path.join(target.base, relative)) continue;
132
- if (!current.has(relative)) legacy.push(relative);
133
- }
134
- for (const relative of new Set(legacy)) {
135
- const file = destination(path.join(target.base, relative));
136
- if (previous.assets.includes(relative) && exists(file)) assets.push({ destination: file, kind: 'legacy' });
98
+ const additions = nativeAssets(root, target, pluginFiles, version);
99
+ const current = new Set(additions.map(asset => asset.relative));
100
+ assets.push(...additions, ...migrationAssets(target, owned ? previous : undefined, current));
101
+ for (const asset of additions) {
102
+ // A new grouped resource must not take ownership of an unrelated file,
103
+ // even when --force confirms replacement of a previous installation.
104
+ const previousOwned = owned && ownsAsset(previous, asset.relative);
105
+ asset.protectedResource = /^(references|evals|hooks|scripts|docs|i3-composer-agent)\//.test(asset.relative);
106
+ if (exists(asset.destination) && !previousOwned && asset.protectedResource) {
107
+ throw new Error(`未归属本包的同名资源,不能覆盖,请先处理:${asset.destination}`);
137
108
  }
138
109
  }
139
110
  assets.push({ target, destination: destination(manifestFile), kind: 'manifest', mergeable: sameVersion });
140
111
  for (const asset of assets.slice(start)) {
112
+ if (destination(asset.destination) !== asset.destination) throw new Error(`安装目录包含符号链接(symlink):${asset.destination}`);
141
113
  asset.base = target.base;
142
114
  asset.buddyCommand = asset.kind === 'command' && target.agents.some(id => ['codebuddy', 'workbuddy'].includes(id));
143
115
  asset.scope = scope;
@@ -149,6 +121,9 @@ function planInstallation({ agents, scope, projectDir = process.cwd(), home = os
149
121
  }
150
122
  }
151
123
  for (const asset of assets) { checkDestination(asset.destination); asset.existing = exists(asset.destination); }
124
+ for (const asset of assets.filter(item => item.existing)) {
125
+ if (!fs.lstatSync(asset.destination).isFile()) throw new Error(`目标文件被目录或符号链接占用(symlink):${asset.destination}`);
126
+ }
152
127
  const plan = { mode: 'install', scope, agents, targets: [...targets.values()], assets };
153
128
  for (const asset of assets.filter(item => item.kind === 'manifest' && item.mergeable)) {
154
129
  asset.observedFingerprint = fingerprint(asset.destination);
@@ -172,13 +147,6 @@ function executeInstallation(plan, { force = false } = {}) {
172
147
  fs.mkdirSync(directory);
173
148
  createdDirectories.push(directory);
174
149
  }
175
- function portable(file, asset, markdown = false) {
176
- if (fs.statSync(file).isDirectory()) {
177
- for (const entry of fs.readdirSync(file)) portable(path.join(file, entry), asset);
178
- } else if (markdown || file.endsWith('.md')) {
179
- fs.writeFileSync(file, nativeContent(fs.readFileSync(file, 'utf8'), asset));
180
- }
181
- }
182
150
  try {
183
151
  // Stage every target before replacing the first installed asset.
184
152
  for (const [index, asset] of plan.assets.entries()) {
@@ -192,17 +160,22 @@ function executeInstallation(plan, { force = false } = {}) {
192
160
  if (asset.kind === 'manifest') {
193
161
  fs.writeFileSync(asset.staged, manifestContent(plan, asset));
194
162
  } else {
195
- fs.cpSync(asset.source, asset.staged, { recursive: true, filter: file => {
196
- if (fs.lstatSync(file).isSymbolicLink()) throw new Error(`包内容中包含符号链接(symlink): ${file}`);
197
- return path.basename(file) !== '.DS_Store' && (asset.kind !== 'support'
198
- || !/(?:^|[\\/])(?:results|node_modules|\.git)(?:[\\/]|$)/.test(path.relative(root, file)));
199
- } });
200
- portable(asset.staged, asset, asset.source.endsWith('.md'));
163
+ if (!fs.lstatSync(asset.source).isFile()) throw new Error(`包内容不是普通文件:${asset.source}`);
164
+ const bytes = fs.readFileSync(asset.source);
165
+ fs.writeFileSync(asset.staged, asset.kind === 'result' ? bytes : renderAsset(bytes, asset));
166
+ fs.chmodSync(asset.staged, fs.statSync(asset.source).mode & 0o777);
201
167
  }
202
168
  }
203
169
  for (const asset of plan.assets) {
204
170
  const context = contexts.get(asset.base);
171
+ if (destination(asset.destination) !== asset.destination) throw new Error(`安装期间目录变成符号链接(symlink):${asset.destination}`);
205
172
  mkdir(path.dirname(asset.destination));
173
+ if (exists(asset.destination) && !fs.lstatSync(asset.destination).isFile()) throw new Error(`安装期间目标不再是普通文件:${asset.destination}`);
174
+ if (!asset.existing && exists(asset.destination) && (asset.protectedResource || asset.kind === 'result')) {
175
+ throw new Error(`安装期间出现未归属文件:${asset.destination}`);
176
+ }
177
+ if (asset.kind === 'result' && fingerprint(asset.source) !== asset.expectedFingerprint) throw new Error(`安装期间评测结果已改变:${asset.source}`);
178
+ if (asset.kind === 'result' && asset.existing && fingerprint(asset.destination) !== asset.expectedFingerprint) throw new Error(`安装期间目标评测结果已改变:${asset.destination}`);
206
179
  if (asset.reusable) {
207
180
  const unchanged = fingerprint(asset.destination) === asset.expectedFingerprint
208
181
  && fingerprint(asset.staged) === asset.expectedFingerprint;
@@ -225,12 +198,27 @@ function executeInstallation(plan, { force = false } = {}) {
225
198
  change.written = true;
226
199
  }
227
200
  }
201
+ pruneRetiredDirectories(plan.assets);
228
202
  completed = true;
203
+ const ownedPaths = new Set(plan.assets.filter(asset => asset.kind !== 'legacy').map(asset => asset.destination));
204
+ plan.preserved = [];
205
+ function findUserFiles(directory) {
206
+ if (!exists(directory) || fs.lstatSync(directory).isSymbolicLink()) return;
207
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
208
+ const file = path.join(directory, entry.name);
209
+ if (entry.isDirectory()) findUserFiles(file);
210
+ else if (!ownedPaths.has(file) && !file.split(path.sep).includes('results')) plan.preserved.push(file);
211
+ }
212
+ }
213
+ for (const target of plan.targets) {
214
+ findUserFiles(path.join(target.base, name));
215
+ for (const category of ['references', 'evals', 'hooks', 'scripts', 'docs']) findUserFiles(path.join(target.base, category, name));
216
+ }
229
217
  return [];
230
218
  } catch (error) {
231
219
  for (const change of committed.reverse()) {
232
220
  if (change.written) fs.rmSync(change.destination, { recursive: true, force: true });
233
- if (change.backup) fs.renameSync(change.backup, change.destination);
221
+ if (change.backup) { mkdir(path.dirname(change.destination)); fs.renameSync(change.backup, change.destination); }
234
222
  }
235
223
  rolledBack = true;
236
224
  throw error;