flower-trellis 0.5.2 → 0.5.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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "syncedAt": "2026-07-29T11:34:14.834Z",
2
+ "syncedAt": "2026-07-29T12:22:03.171Z",
3
3
  "syncedFrom": "vendor/skill-garden",
4
- "sourceCommit": "9c35c52409d53198738405861b730d942de7efb0",
4
+ "sourceCommit": "1916ef11ba3cb40bead9ad827957224af850bd4f",
5
5
  "common": {
6
6
  "codexSkills": [
7
7
  "craft-rpa",
@@ -70,7 +70,7 @@ bash "$SKILL_DIR/scripts/run.sh" craft [--session <ts>] [OUT]
70
70
  # 转 jsonl → trace.md;--session 默认 = 当前/最新;OUT 默认 ./trace.md
71
71
  ```
72
72
 
73
- 运行时状态 / sessions / profile 全部在 `$(pwd)/.craft-rpa/`(可用 `CRAFT_RPA_HOME` env 覆盖到任意路径)。首次 `start` 自动 `npm install`(跳浏览器下载,~10s)。
73
+ 运行时状态 / sessions / profile / 依赖全部在 `$(pwd)/.craft-rpa/`(可用 `CRAFT_RPA_HOME` env 覆盖到任意路径)。首次 `start` 自动安装依赖(跳浏览器下载,~10s),受管 skill 目录只保留静态代码资产。
74
74
 
75
75
  **AI 行为约定**(看到下列触发就跑对应子命令,不要手敲底层 cd / nohup):
76
76
 
@@ -106,24 +106,23 @@ bash "$SKILL_DIR/scripts/run.sh" craft [--session <ts>] [OUT]
106
106
  │ ├── 2026-05-18_14-22-15/
107
107
  │ └── legacy-2026-05-17_... ← 老版本遗留 / 升级时自动归档
108
108
  ├── profile/ ← Chrome 持久 profile(登录态,项目独立)
109
+ ├── runtime/recorder/ ← package manifest + node_modules,可重建
109
110
  ├── .launch.pid / .launch.log ← 进程管理 + 日志
110
111
  └── .current-session ← 最近一次 start 的会话 ts
111
112
 
112
- .claude/skills/craft-rpa/recorder/ ← skill 内仅代码资产;start 时建两个软链 → 数据根:
113
- ├── session.jsonl → <repo>/.craft-rpa/sessions/<latest>/session.jsonl
114
- └── profile → <repo>/.craft-rpa/profile
113
+ .claude/skills/craft-rpa/recorder/ ← skill 内仅静态代码资产,不写 profile/session/node_modules
115
114
  ```
116
115
 
117
116
  **为什么这样**:
118
117
  - 录制 jsonl 是**项目业务数据**,跟着仓库走(每个仓库独立 session 池,不串)
119
118
  - skill 代码可装 `~/.claude/skills/craft-rpa/` 全局,所有仓库共用同一份代码
120
- - launch.js / logger.js 用 `__dirname` 解析 session.jsonl / profile,通过 recorder/ 内软链自动落到项目根 —— **录制器代码不用改**
119
+ - run.sh 通过显式环境变量把 session/profile/Playwright 模块路径传给 launch.js,避免运行时产物进入 Plugin 管理树
121
120
 
122
121
  **关键性质**:
123
122
 
124
123
  - 每次 `run.sh start` 创建新时间戳目录,**不覆盖**历史
125
- - `recorder/session.jsonl` / `recorder/profile` 始终是软链,随当前会话切换目标
126
- - 老版本遗留的 `recorder/session.jsonl`(普通文件)在新版第一次 start 时自动归档到 `sessions/legacy-<ts>/`;`recorder/profile/`(普通目录)归档到 `.craft-rpa/profile-legacy-<ts>/`
124
+ - 新版不会在 `recorder/` 创建软链或 `node_modules`;依赖位于 `.craft-rpa/runtime/recorder/`
125
+ - 老版本遗留的 `recorder/session.jsonl` / `recorder/profile` 软链只删除链接本身,真实目标数据保留;普通文件/目录仍自动归档
127
126
  - `run.sh craft` 默认转最新;`--session <ts>` 可转任意历史
128
127
  - 删历史:手动 `rm -rf .craft-rpa/sessions/<ts>/`,run.sh 不管删
129
128
 
@@ -18,19 +18,21 @@
18
18
  * 没有系统 Chrome 时改成 false,并执行 npx playwright install chromium
19
19
  *
20
20
  * 数据存放:
21
- * - profile/ : 用户数据目录(cookie / localStorage / 登录态等),下次启动自动复用
22
- * - session.jsonl : 事件日志(由内置 logger 写入)
21
+ * - CRAFT_RPA_PROFILE_DIR : 用户数据目录(cookie / localStorage / 登录态等)
22
+ * - CRAFT_RPA_SESSION_FILE : 事件日志(由内置 logger 写入)
23
23
  */
24
- const { chromium } = require('playwright');
25
24
  const path = require('path');
26
25
  const fs = require('fs');
26
+ const PLAYWRIGHT_MODULE = process.env.CRAFT_RPA_PLAYWRIGHT_MODULE || 'playwright';
27
+ const { chromium } = require(PLAYWRIGHT_MODULE);
27
28
  const { startLogger } = require('./logger');
28
29
 
29
30
  // 启动参数:第一个 CLI 参数作为起始 URL,没传就开空白页
30
31
  const START_URL = process.argv[2] || 'about:blank';
31
32
 
32
- // profile 目录放在脚本同级 ./profile/,与日常 Chrome 完全隔离
33
- const PROFILE_DIR = path.join(__dirname, 'profile');
33
+ // run.sh 显式传入运行时目录;直接执行时保留原有默认值。
34
+ const PROFILE_DIR = path.resolve(process.env.CRAFT_RPA_PROFILE_DIR || path.join(__dirname, 'profile'));
35
+ const SESSION_LOG = path.resolve(process.env.CRAFT_RPA_SESSION_FILE || path.join(__dirname, 'session.jsonl'));
34
36
 
35
37
  // 注入脚本路径
36
38
  const INJECT_SCRIPT = path.join(__dirname, 'inject.js');
@@ -164,7 +166,7 @@ if (!fs.existsSync(INJECT_SCRIPT)) {
164
166
 
165
167
  // 先启动日志 + 控制服务,再启动浏览器
166
168
  // 浏览器一打开页面,注入脚本立刻就能 POST 成功,不会丢首批事件
167
- const loggerServer = startLogger({ browserController });
169
+ const loggerServer = startLogger({ browserController, logFile: SESSION_LOG });
168
170
 
169
171
  console.log(`[launch] profile dir : ${PROFILE_DIR}`);
170
172
  console.log(`[launch] start url : ${START_URL}`);
@@ -16,7 +16,9 @@ const fs = require('fs');
16
16
  const path = require('path');
17
17
 
18
18
  const DEFAULT_PORT = 7777;
19
- const DEFAULT_LOG_FILE = path.join(__dirname, 'session.jsonl');
19
+ const DEFAULT_LOG_FILE = path.resolve(
20
+ process.env.CRAFT_RPA_SESSION_FILE || path.join(__dirname, 'session.jsonl'),
21
+ );
20
22
 
21
23
  /**
22
24
  * 启动本地日志收集服务
@@ -0,0 +1,96 @@
1
+ const assert = require('assert');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const test = require('node:test');
7
+
8
+ /**
9
+ * 写入可执行的测试替身。
10
+ *
11
+ * @param {string} target 文件路径。
12
+ * @param {string} content 脚本内容。
13
+ * @return {void}
14
+ */
15
+ function writeExecutable(target, content) {
16
+ fs.writeFileSync(target, content, { mode: 0o755 });
17
+ }
18
+
19
+ test('run.sh 将旧运行时产物迁出 Plugin 管理树', { skip: process.platform === 'win32' }, t => {
20
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-rpa-runtime-layout-'));
21
+ const sourceSkill = path.resolve(__dirname, '..', '..');
22
+ const skillRoot = path.join(tempRoot, 'skill');
23
+ const recorderRoot = path.join(skillRoot, 'recorder');
24
+ const projectRoot = path.join(tempRoot, 'project');
25
+ const dataRoot = path.join(projectRoot, '.craft-rpa');
26
+ const fakeBin = path.join(tempRoot, 'bin');
27
+ let launchPid = null;
28
+
29
+ t.after(() => {
30
+ if (launchPid !== null) {
31
+ try { process.kill(launchPid, 'SIGTERM'); } catch (error) { /* 测试进程可能已结束。 */ }
32
+ }
33
+ fs.rmSync(tempRoot, { recursive: true, force: true });
34
+ });
35
+
36
+ fs.cpSync(sourceSkill, skillRoot, { recursive: true });
37
+ fs.mkdirSync(projectRoot, { recursive: true });
38
+ fs.mkdirSync(fakeBin, { recursive: true });
39
+
40
+ const profileTarget = path.join(dataRoot, 'profile');
41
+ const sessionTarget = path.join(dataRoot, 'sessions', 'old', 'session.jsonl');
42
+ fs.mkdirSync(profileTarget, { recursive: true });
43
+ fs.mkdirSync(path.dirname(sessionTarget), { recursive: true });
44
+ fs.writeFileSync(path.join(profileTarget, 'preserved.txt'), 'profile\n');
45
+ fs.writeFileSync(sessionTarget, '{"kind":"legacy"}\n');
46
+ fs.symlinkSync(profileTarget, path.join(recorderRoot, 'profile'), 'dir');
47
+ fs.symlinkSync(sessionTarget, path.join(recorderRoot, 'session.jsonl'));
48
+ fs.mkdirSync(path.join(recorderRoot, 'node_modules', 'playwright'), { recursive: true });
49
+ fs.writeFileSync(path.join(recorderRoot, 'node_modules', 'playwright', 'package.json'), '{}\n');
50
+
51
+ writeExecutable(path.join(fakeBin, 'npm'), `#!/usr/bin/env bash
52
+ mkdir -p "$PWD/node_modules/playwright"
53
+ printf '{}\\n' > "$PWD/node_modules/playwright/package.json"
54
+ `);
55
+ writeExecutable(path.join(fakeBin, 'node'), `#!/usr/bin/env bash
56
+ printf '%s\\n' "$CRAFT_RPA_SESSION_FILE" > "$CRAFT_RPA_HOME/fake-session-file"
57
+ printf '%s\\n' "$CRAFT_RPA_PROFILE_DIR" > "$CRAFT_RPA_HOME/fake-profile-dir"
58
+ printf '%s\\n' "$CRAFT_RPA_PLAYWRIGHT_MODULE" > "$CRAFT_RPA_HOME/fake-playwright-module"
59
+ while true; do /bin/sleep 1; done
60
+ `);
61
+
62
+ const result = spawnSync('bash', [path.join(skillRoot, 'scripts', 'run.sh'), 'start', 'about:blank'], {
63
+ cwd: projectRoot,
64
+ env: {
65
+ ...process.env,
66
+ CRAFT_RPA_HOME: dataRoot,
67
+ PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`,
68
+ },
69
+ encoding: 'utf8',
70
+ timeout: 10000,
71
+ });
72
+
73
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
74
+ launchPid = Number.parseInt(fs.readFileSync(path.join(dataRoot, '.launch.pid'), 'utf8'), 10);
75
+ assert.equal(Number.isInteger(launchPid), true);
76
+ assert.equal(fs.existsSync(path.join(recorderRoot, 'profile')), false);
77
+ assert.equal(fs.existsSync(path.join(recorderRoot, 'session.jsonl')), false);
78
+ assert.equal(fs.existsSync(path.join(recorderRoot, 'node_modules')), false);
79
+ assert.equal(fs.readFileSync(path.join(profileTarget, 'preserved.txt'), 'utf8'), 'profile\n');
80
+ assert.equal(fs.readFileSync(sessionTarget, 'utf8'), '{"kind":"legacy"}\n');
81
+
82
+ const runtimeRoot = path.join(dataRoot, 'runtime', 'recorder');
83
+ assert.equal(fs.existsSync(path.join(runtimeRoot, 'node_modules', 'playwright', 'package.json')), true);
84
+ assert.equal(
85
+ fs.readFileSync(path.join(dataRoot, 'fake-profile-dir'), 'utf8').trim(),
86
+ profileTarget,
87
+ );
88
+ assert.equal(
89
+ fs.readFileSync(path.join(dataRoot, 'fake-playwright-module'), 'utf8').trim(),
90
+ path.join(runtimeRoot, 'node_modules', 'playwright'),
91
+ );
92
+ const sessionFile = fs.readFileSync(path.join(dataRoot, 'fake-session-file'), 'utf8').trim();
93
+ assert.equal(sessionFile.startsWith(path.join(dataRoot, 'sessions')), true);
94
+ assert.equal(sessionFile.endsWith(path.join('', 'session.jsonl')), true);
95
+ assert.equal(fs.existsSync(sessionFile), true);
96
+ });
@@ -5,13 +5,11 @@
5
5
  # ${CRAFT_RPA_HOME:-<cwd>/.craft-rpa}/
6
6
  # ├── sessions/<YYYY-MM-DD_HH-MM-SS>/session.jsonl 每次 start 新建,不覆盖
7
7
  # ├── profile/ Chrome 持久 profile(登录态)
8
+ # ├── runtime/recorder/node_modules/ 可重建运行时依赖
8
9
  # ├── .launch.{pid,log} 进程管理 + 日志
9
10
  # └── .current-session 最近一次 start 的 ts
10
11
  #
11
- # skill 内 .claude/skills/craft-rpa/recorder/ 启动时建两个软链 → 数据根:
12
- # ├── session.jsonl → $CRAFT_RPA_HOME/sessions/<ts>/session.jsonl
13
- # └── profile → $CRAFT_RPA_HOME/profile
14
- # launch.js 用 __dirname 解析 session.jsonl / profile,通过软链落到项目根 .craft-rpa/
12
+ # skill 内 recorder/ 只保留静态代码资产;运行时路径通过环境变量显式传给 launch.js。
15
13
  #
16
14
  # 用法:
17
15
  # bash run.sh start [URL]
@@ -34,13 +32,73 @@ PROFILE_DIR="$CRAFT_RPA_HOME/profile"
34
32
  PID_FILE="$CRAFT_RPA_HOME/.launch.pid"
35
33
  LOG_FILE="$CRAFT_RPA_HOME/.launch.log"
36
34
  CURRENT_FILE="$CRAFT_RPA_HOME/.current-session"
37
-
38
- # skill 内 launch.js 通过 __dirname 看到的入口(软链 → 数据根)
39
- SESSION_LINK="$RECORDER_DIR/session.jsonl"
40
- PROFILE_LINK="$RECORDER_DIR/profile"
35
+ RUNTIME_DIR="$CRAFT_RPA_HOME/runtime/recorder"
36
+ RUNTIME_MODULES_DIR="$RUNTIME_DIR/node_modules"
37
+ LEGACY_SESSION_PATH="$RECORDER_DIR/session.jsonl"
38
+ LEGACY_PROFILE_PATH="$RECORDER_DIR/profile"
39
+ LEGACY_MODULES_DIR="$RECORDER_DIR/node_modules"
41
40
 
42
41
  DASHBOARD_URL="http://localhost:7777/dashboard"
43
42
 
43
+ prepare_runtime_dependencies() {
44
+ mkdir -p "$RUNTIME_DIR"
45
+ RUNTIME_CHANGED=false
46
+ for MANIFEST in package.json package-lock.json; do
47
+ if [ ! -f "$RECORDER_DIR/$MANIFEST" ]; then
48
+ continue
49
+ fi
50
+ if [ ! -f "$RUNTIME_DIR/$MANIFEST" ] || ! cmp -s "$RECORDER_DIR/$MANIFEST" "$RUNTIME_DIR/$MANIFEST"; then
51
+ cp "$RECORDER_DIR/$MANIFEST" "$RUNTIME_DIR/$MANIFEST"
52
+ RUNTIME_CHANGED=true
53
+ fi
54
+ done
55
+ if [ "$RUNTIME_CHANGED" = true ] || [ ! -d "$RUNTIME_MODULES_DIR/playwright" ]; then
56
+ echo "[craft-rpa] installing deps under $RUNTIME_DIR (one-time, ~30-60s) ..."
57
+ if [ -f "$RUNTIME_DIR/package-lock.json" ]; then
58
+ (cd "$RUNTIME_DIR" && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --no-audit --no-fund)
59
+ else
60
+ (cd "$RUNTIME_DIR" && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install --no-audit --no-fund)
61
+ fi
62
+ fi
63
+ }
64
+
65
+ cleanup_legacy_recorder_runtime() {
66
+ if [ -L "$LEGACY_SESSION_PATH" ]; then
67
+ # 只删除旧软链本身,真实 session 目标继续留在数据根。
68
+ rm -f "$LEGACY_SESSION_PATH"
69
+ echo "[craft-rpa] removed legacy recorder/session.jsonl symlink"
70
+ elif [ -e "$LEGACY_SESSION_PATH" ]; then
71
+ if [ -s "$LEGACY_SESSION_PATH" ]; then
72
+ LEGACY_TS=$(date +"%Y-%m-%d_%H-%M-%S")
73
+ LEGACY_DIR="$SESSIONS_DIR/legacy-$LEGACY_TS"
74
+ mkdir -p "$LEGACY_DIR"
75
+ mv "$LEGACY_SESSION_PATH" "$LEGACY_DIR/session.jsonl"
76
+ echo "[craft-rpa] migrated legacy recorder/session.jsonl → sessions/legacy-$LEGACY_TS/"
77
+ else
78
+ rm -f "$LEGACY_SESSION_PATH"
79
+ fi
80
+ fi
81
+
82
+ if [ -L "$LEGACY_PROFILE_PATH" ]; then
83
+ # 只删除旧软链本身,真实 profile 目标继续留在数据根。
84
+ rm -f "$LEGACY_PROFILE_PATH"
85
+ echo "[craft-rpa] removed legacy recorder/profile symlink"
86
+ elif [ -e "$LEGACY_PROFILE_PATH" ]; then
87
+ LEGACY_TS=$(date +"%Y-%m-%d_%H-%M-%S")
88
+ mv "$LEGACY_PROFILE_PATH" "$CRAFT_RPA_HOME/profile-legacy-$LEGACY_TS"
89
+ echo "[craft-rpa] migrated legacy recorder/profile → .craft-rpa/profile-legacy-$LEGACY_TS"
90
+ fi
91
+
92
+ if [ -L "$LEGACY_MODULES_DIR" ]; then
93
+ rm -f "$LEGACY_MODULES_DIR"
94
+ echo "[craft-rpa] removed legacy recorder/node_modules symlink"
95
+ elif [ -e "$LEGACY_MODULES_DIR" ]; then
96
+ # node_modules 只是可重建缓存,精确删除受管目录中的旧副本,避免 Plugin 重放再次扫描它。
97
+ rm -rf "$LEGACY_MODULES_DIR"
98
+ echo "[craft-rpa] removed legacy recorder/node_modules cache"
99
+ fi
100
+ }
101
+
44
102
  cmd="${1:-status}"
45
103
 
46
104
  case "$cmd" in
@@ -60,44 +118,22 @@ case "$cmd" in
60
118
  exit 0
61
119
  fi
62
120
 
63
- if [ ! -d "$RECORDER_DIR/node_modules" ]; then
64
- echo "[craft-rpa] installing deps (one-time, ~30-60s) ..."
65
- (cd "$RECORDER_DIR" && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install --no-audit --no-fund)
66
- fi
67
-
68
121
  mkdir -p "$CRAFT_RPA_HOME" "$SESSIONS_DIR" "$PROFILE_DIR"
69
-
70
- # 旧 session.jsonl 是普通文件(老版本遗留)→ 归档,避免 ln 覆盖丢数据
71
- if [ -e "$SESSION_LINK" ] && [ ! -L "$SESSION_LINK" ]; then
72
- if [ -s "$SESSION_LINK" ]; then
73
- LEGACY_TS=$(date +"%Y-%m-%d_%H-%M-%S")
74
- LEGACY_DIR="$SESSIONS_DIR/legacy-$LEGACY_TS"
75
- mkdir -p "$LEGACY_DIR"
76
- mv "$SESSION_LINK" "$LEGACY_DIR/session.jsonl"
77
- echo "[craft-rpa] migrated legacy recorder/session.jsonl → sessions/legacy-$LEGACY_TS/"
78
- else
79
- rm -f "$SESSION_LINK"
80
- fi
81
- fi
82
- # 旧 profile 是普通目录(老版本遗留)→ 归档到数据根
83
- if [ -e "$PROFILE_LINK" ] && [ ! -L "$PROFILE_LINK" ]; then
84
- LEGACY_TS=$(date +"%Y-%m-%d_%H-%M-%S")
85
- mv "$PROFILE_LINK" "$CRAFT_RPA_HOME/profile-legacy-$LEGACY_TS"
86
- echo "[craft-rpa] migrated legacy recorder/profile → .craft-rpa/profile-legacy-$LEGACY_TS"
87
- fi
122
+ cleanup_legacy_recorder_runtime
123
+ prepare_runtime_dependencies
88
124
 
89
125
  TS=$(date +"%Y-%m-%d_%H-%M-%S")
90
126
  NEW_SESSION_DIR="$SESSIONS_DIR/$TS"
91
127
  mkdir -p "$NEW_SESSION_DIR"
92
128
  : > "$NEW_SESSION_DIR/session.jsonl"
93
129
 
94
- # 建两个软链:launch.js / logger.js 通过 __dirname 读到的就是项目根的数据
95
- ln -sfn "$NEW_SESSION_DIR/session.jsonl" "$SESSION_LINK"
96
- ln -sfn "$PROFILE_DIR" "$PROFILE_LINK"
97
130
  echo "$TS" > "$CURRENT_FILE"
98
131
 
99
132
  cd "$RECORDER_DIR"
100
- nohup node launch.js "$URL" > "$LOG_FILE" 2>&1 &
133
+ CRAFT_RPA_SESSION_FILE="$NEW_SESSION_DIR/session.jsonl" \
134
+ CRAFT_RPA_PROFILE_DIR="$PROFILE_DIR" \
135
+ CRAFT_RPA_PLAYWRIGHT_MODULE="$RUNTIME_MODULES_DIR/playwright" \
136
+ nohup node launch.js "$URL" > "$LOG_FILE" 2>&1 &
101
137
  PID=$!
102
138
  echo "$PID" > "$PID_FILE"
103
139
  sleep 1
@@ -70,7 +70,7 @@ bash "$SKILL_DIR/scripts/run.sh" craft [--session <ts>] [OUT]
70
70
  # 转 jsonl → trace.md;--session 默认 = 当前/最新;OUT 默认 ./trace.md
71
71
  ```
72
72
 
73
- 运行时状态 / sessions / profile 全部在 `$(pwd)/.craft-rpa/`(可用 `CRAFT_RPA_HOME` env 覆盖到任意路径)。首次 `start` 自动 `npm install`(跳浏览器下载,~10s)。
73
+ 运行时状态 / sessions / profile / 依赖全部在 `$(pwd)/.craft-rpa/`(可用 `CRAFT_RPA_HOME` env 覆盖到任意路径)。首次 `start` 自动安装依赖(跳浏览器下载,~10s),受管 skill 目录只保留静态代码资产。
74
74
 
75
75
  **AI 行为约定**(看到下列触发就跑对应子命令,不要手敲底层 cd / nohup):
76
76
 
@@ -106,24 +106,23 @@ bash "$SKILL_DIR/scripts/run.sh" craft [--session <ts>] [OUT]
106
106
  │ ├── 2026-05-18_14-22-15/
107
107
  │ └── legacy-2026-05-17_... ← 老版本遗留 / 升级时自动归档
108
108
  ├── profile/ ← Chrome 持久 profile(登录态,项目独立)
109
+ ├── runtime/recorder/ ← package manifest + node_modules,可重建
109
110
  ├── .launch.pid / .launch.log ← 进程管理 + 日志
110
111
  └── .current-session ← 最近一次 start 的会话 ts
111
112
 
112
- .claude/skills/craft-rpa/recorder/ ← skill 内仅代码资产;start 时建两个软链 → 数据根:
113
- ├── session.jsonl → <repo>/.craft-rpa/sessions/<latest>/session.jsonl
114
- └── profile → <repo>/.craft-rpa/profile
113
+ .claude/skills/craft-rpa/recorder/ ← skill 内仅静态代码资产,不写 profile/session/node_modules
115
114
  ```
116
115
 
117
116
  **为什么这样**:
118
117
  - 录制 jsonl 是**项目业务数据**,跟着仓库走(每个仓库独立 session 池,不串)
119
118
  - skill 代码可装 `~/.claude/skills/craft-rpa/` 全局,所有仓库共用同一份代码
120
- - launch.js / logger.js 用 `__dirname` 解析 session.jsonl / profile,通过 recorder/ 内软链自动落到项目根 —— **录制器代码不用改**
119
+ - run.sh 通过显式环境变量把 session/profile/Playwright 模块路径传给 launch.js,避免运行时产物进入 Plugin 管理树
121
120
 
122
121
  **关键性质**:
123
122
 
124
123
  - 每次 `run.sh start` 创建新时间戳目录,**不覆盖**历史
125
- - `recorder/session.jsonl` / `recorder/profile` 始终是软链,随当前会话切换目标
126
- - 老版本遗留的 `recorder/session.jsonl`(普通文件)在新版第一次 start 时自动归档到 `sessions/legacy-<ts>/`;`recorder/profile/`(普通目录)归档到 `.craft-rpa/profile-legacy-<ts>/`
124
+ - 新版不会在 `recorder/` 创建软链或 `node_modules`;依赖位于 `.craft-rpa/runtime/recorder/`
125
+ - 老版本遗留的 `recorder/session.jsonl` / `recorder/profile` 软链只删除链接本身,真实目标数据保留;普通文件/目录仍自动归档
127
126
  - `run.sh craft` 默认转最新;`--session <ts>` 可转任意历史
128
127
  - 删历史:手动 `rm -rf .craft-rpa/sessions/<ts>/`,run.sh 不管删
129
128
 
@@ -18,19 +18,21 @@
18
18
  * 没有系统 Chrome 时改成 false,并执行 npx playwright install chromium
19
19
  *
20
20
  * 数据存放:
21
- * - profile/ : 用户数据目录(cookie / localStorage / 登录态等),下次启动自动复用
22
- * - session.jsonl : 事件日志(由内置 logger 写入)
21
+ * - CRAFT_RPA_PROFILE_DIR : 用户数据目录(cookie / localStorage / 登录态等)
22
+ * - CRAFT_RPA_SESSION_FILE : 事件日志(由内置 logger 写入)
23
23
  */
24
- const { chromium } = require('playwright');
25
24
  const path = require('path');
26
25
  const fs = require('fs');
26
+ const PLAYWRIGHT_MODULE = process.env.CRAFT_RPA_PLAYWRIGHT_MODULE || 'playwright';
27
+ const { chromium } = require(PLAYWRIGHT_MODULE);
27
28
  const { startLogger } = require('./logger');
28
29
 
29
30
  // 启动参数:第一个 CLI 参数作为起始 URL,没传就开空白页
30
31
  const START_URL = process.argv[2] || 'about:blank';
31
32
 
32
- // profile 目录放在脚本同级 ./profile/,与日常 Chrome 完全隔离
33
- const PROFILE_DIR = path.join(__dirname, 'profile');
33
+ // run.sh 显式传入运行时目录;直接执行时保留原有默认值。
34
+ const PROFILE_DIR = path.resolve(process.env.CRAFT_RPA_PROFILE_DIR || path.join(__dirname, 'profile'));
35
+ const SESSION_LOG = path.resolve(process.env.CRAFT_RPA_SESSION_FILE || path.join(__dirname, 'session.jsonl'));
34
36
 
35
37
  // 注入脚本路径
36
38
  const INJECT_SCRIPT = path.join(__dirname, 'inject.js');
@@ -164,7 +166,7 @@ if (!fs.existsSync(INJECT_SCRIPT)) {
164
166
 
165
167
  // 先启动日志 + 控制服务,再启动浏览器
166
168
  // 浏览器一打开页面,注入脚本立刻就能 POST 成功,不会丢首批事件
167
- const loggerServer = startLogger({ browserController });
169
+ const loggerServer = startLogger({ browserController, logFile: SESSION_LOG });
168
170
 
169
171
  console.log(`[launch] profile dir : ${PROFILE_DIR}`);
170
172
  console.log(`[launch] start url : ${START_URL}`);
@@ -16,7 +16,9 @@ const fs = require('fs');
16
16
  const path = require('path');
17
17
 
18
18
  const DEFAULT_PORT = 7777;
19
- const DEFAULT_LOG_FILE = path.join(__dirname, 'session.jsonl');
19
+ const DEFAULT_LOG_FILE = path.resolve(
20
+ process.env.CRAFT_RPA_SESSION_FILE || path.join(__dirname, 'session.jsonl'),
21
+ );
20
22
 
21
23
  /**
22
24
  * 启动本地日志收集服务
@@ -0,0 +1,96 @@
1
+ const assert = require('assert');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const test = require('node:test');
7
+
8
+ /**
9
+ * 写入可执行的测试替身。
10
+ *
11
+ * @param {string} target 文件路径。
12
+ * @param {string} content 脚本内容。
13
+ * @return {void}
14
+ */
15
+ function writeExecutable(target, content) {
16
+ fs.writeFileSync(target, content, { mode: 0o755 });
17
+ }
18
+
19
+ test('run.sh 将旧运行时产物迁出 Plugin 管理树', { skip: process.platform === 'win32' }, t => {
20
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-rpa-runtime-layout-'));
21
+ const sourceSkill = path.resolve(__dirname, '..', '..');
22
+ const skillRoot = path.join(tempRoot, 'skill');
23
+ const recorderRoot = path.join(skillRoot, 'recorder');
24
+ const projectRoot = path.join(tempRoot, 'project');
25
+ const dataRoot = path.join(projectRoot, '.craft-rpa');
26
+ const fakeBin = path.join(tempRoot, 'bin');
27
+ let launchPid = null;
28
+
29
+ t.after(() => {
30
+ if (launchPid !== null) {
31
+ try { process.kill(launchPid, 'SIGTERM'); } catch (error) { /* 测试进程可能已结束。 */ }
32
+ }
33
+ fs.rmSync(tempRoot, { recursive: true, force: true });
34
+ });
35
+
36
+ fs.cpSync(sourceSkill, skillRoot, { recursive: true });
37
+ fs.mkdirSync(projectRoot, { recursive: true });
38
+ fs.mkdirSync(fakeBin, { recursive: true });
39
+
40
+ const profileTarget = path.join(dataRoot, 'profile');
41
+ const sessionTarget = path.join(dataRoot, 'sessions', 'old', 'session.jsonl');
42
+ fs.mkdirSync(profileTarget, { recursive: true });
43
+ fs.mkdirSync(path.dirname(sessionTarget), { recursive: true });
44
+ fs.writeFileSync(path.join(profileTarget, 'preserved.txt'), 'profile\n');
45
+ fs.writeFileSync(sessionTarget, '{"kind":"legacy"}\n');
46
+ fs.symlinkSync(profileTarget, path.join(recorderRoot, 'profile'), 'dir');
47
+ fs.symlinkSync(sessionTarget, path.join(recorderRoot, 'session.jsonl'));
48
+ fs.mkdirSync(path.join(recorderRoot, 'node_modules', 'playwright'), { recursive: true });
49
+ fs.writeFileSync(path.join(recorderRoot, 'node_modules', 'playwright', 'package.json'), '{}\n');
50
+
51
+ writeExecutable(path.join(fakeBin, 'npm'), `#!/usr/bin/env bash
52
+ mkdir -p "$PWD/node_modules/playwright"
53
+ printf '{}\\n' > "$PWD/node_modules/playwright/package.json"
54
+ `);
55
+ writeExecutable(path.join(fakeBin, 'node'), `#!/usr/bin/env bash
56
+ printf '%s\\n' "$CRAFT_RPA_SESSION_FILE" > "$CRAFT_RPA_HOME/fake-session-file"
57
+ printf '%s\\n' "$CRAFT_RPA_PROFILE_DIR" > "$CRAFT_RPA_HOME/fake-profile-dir"
58
+ printf '%s\\n' "$CRAFT_RPA_PLAYWRIGHT_MODULE" > "$CRAFT_RPA_HOME/fake-playwright-module"
59
+ while true; do /bin/sleep 1; done
60
+ `);
61
+
62
+ const result = spawnSync('bash', [path.join(skillRoot, 'scripts', 'run.sh'), 'start', 'about:blank'], {
63
+ cwd: projectRoot,
64
+ env: {
65
+ ...process.env,
66
+ CRAFT_RPA_HOME: dataRoot,
67
+ PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`,
68
+ },
69
+ encoding: 'utf8',
70
+ timeout: 10000,
71
+ });
72
+
73
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
74
+ launchPid = Number.parseInt(fs.readFileSync(path.join(dataRoot, '.launch.pid'), 'utf8'), 10);
75
+ assert.equal(Number.isInteger(launchPid), true);
76
+ assert.equal(fs.existsSync(path.join(recorderRoot, 'profile')), false);
77
+ assert.equal(fs.existsSync(path.join(recorderRoot, 'session.jsonl')), false);
78
+ assert.equal(fs.existsSync(path.join(recorderRoot, 'node_modules')), false);
79
+ assert.equal(fs.readFileSync(path.join(profileTarget, 'preserved.txt'), 'utf8'), 'profile\n');
80
+ assert.equal(fs.readFileSync(sessionTarget, 'utf8'), '{"kind":"legacy"}\n');
81
+
82
+ const runtimeRoot = path.join(dataRoot, 'runtime', 'recorder');
83
+ assert.equal(fs.existsSync(path.join(runtimeRoot, 'node_modules', 'playwright', 'package.json')), true);
84
+ assert.equal(
85
+ fs.readFileSync(path.join(dataRoot, 'fake-profile-dir'), 'utf8').trim(),
86
+ profileTarget,
87
+ );
88
+ assert.equal(
89
+ fs.readFileSync(path.join(dataRoot, 'fake-playwright-module'), 'utf8').trim(),
90
+ path.join(runtimeRoot, 'node_modules', 'playwright'),
91
+ );
92
+ const sessionFile = fs.readFileSync(path.join(dataRoot, 'fake-session-file'), 'utf8').trim();
93
+ assert.equal(sessionFile.startsWith(path.join(dataRoot, 'sessions')), true);
94
+ assert.equal(sessionFile.endsWith(path.join('', 'session.jsonl')), true);
95
+ assert.equal(fs.existsSync(sessionFile), true);
96
+ });
@@ -5,13 +5,11 @@
5
5
  # ${CRAFT_RPA_HOME:-<cwd>/.craft-rpa}/
6
6
  # ├── sessions/<YYYY-MM-DD_HH-MM-SS>/session.jsonl 每次 start 新建,不覆盖
7
7
  # ├── profile/ Chrome 持久 profile(登录态)
8
+ # ├── runtime/recorder/node_modules/ 可重建运行时依赖
8
9
  # ├── .launch.{pid,log} 进程管理 + 日志
9
10
  # └── .current-session 最近一次 start 的 ts
10
11
  #
11
- # skill 内 .claude/skills/craft-rpa/recorder/ 启动时建两个软链 → 数据根:
12
- # ├── session.jsonl → $CRAFT_RPA_HOME/sessions/<ts>/session.jsonl
13
- # └── profile → $CRAFT_RPA_HOME/profile
14
- # launch.js 用 __dirname 解析 session.jsonl / profile,通过软链落到项目根 .craft-rpa/
12
+ # skill 内 recorder/ 只保留静态代码资产;运行时路径通过环境变量显式传给 launch.js。
15
13
  #
16
14
  # 用法:
17
15
  # bash run.sh start [URL]
@@ -34,13 +32,73 @@ PROFILE_DIR="$CRAFT_RPA_HOME/profile"
34
32
  PID_FILE="$CRAFT_RPA_HOME/.launch.pid"
35
33
  LOG_FILE="$CRAFT_RPA_HOME/.launch.log"
36
34
  CURRENT_FILE="$CRAFT_RPA_HOME/.current-session"
37
-
38
- # skill 内 launch.js 通过 __dirname 看到的入口(软链 → 数据根)
39
- SESSION_LINK="$RECORDER_DIR/session.jsonl"
40
- PROFILE_LINK="$RECORDER_DIR/profile"
35
+ RUNTIME_DIR="$CRAFT_RPA_HOME/runtime/recorder"
36
+ RUNTIME_MODULES_DIR="$RUNTIME_DIR/node_modules"
37
+ LEGACY_SESSION_PATH="$RECORDER_DIR/session.jsonl"
38
+ LEGACY_PROFILE_PATH="$RECORDER_DIR/profile"
39
+ LEGACY_MODULES_DIR="$RECORDER_DIR/node_modules"
41
40
 
42
41
  DASHBOARD_URL="http://localhost:7777/dashboard"
43
42
 
43
+ prepare_runtime_dependencies() {
44
+ mkdir -p "$RUNTIME_DIR"
45
+ RUNTIME_CHANGED=false
46
+ for MANIFEST in package.json package-lock.json; do
47
+ if [ ! -f "$RECORDER_DIR/$MANIFEST" ]; then
48
+ continue
49
+ fi
50
+ if [ ! -f "$RUNTIME_DIR/$MANIFEST" ] || ! cmp -s "$RECORDER_DIR/$MANIFEST" "$RUNTIME_DIR/$MANIFEST"; then
51
+ cp "$RECORDER_DIR/$MANIFEST" "$RUNTIME_DIR/$MANIFEST"
52
+ RUNTIME_CHANGED=true
53
+ fi
54
+ done
55
+ if [ "$RUNTIME_CHANGED" = true ] || [ ! -d "$RUNTIME_MODULES_DIR/playwright" ]; then
56
+ echo "[craft-rpa] installing deps under $RUNTIME_DIR (one-time, ~30-60s) ..."
57
+ if [ -f "$RUNTIME_DIR/package-lock.json" ]; then
58
+ (cd "$RUNTIME_DIR" && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --no-audit --no-fund)
59
+ else
60
+ (cd "$RUNTIME_DIR" && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install --no-audit --no-fund)
61
+ fi
62
+ fi
63
+ }
64
+
65
+ cleanup_legacy_recorder_runtime() {
66
+ if [ -L "$LEGACY_SESSION_PATH" ]; then
67
+ # 只删除旧软链本身,真实 session 目标继续留在数据根。
68
+ rm -f "$LEGACY_SESSION_PATH"
69
+ echo "[craft-rpa] removed legacy recorder/session.jsonl symlink"
70
+ elif [ -e "$LEGACY_SESSION_PATH" ]; then
71
+ if [ -s "$LEGACY_SESSION_PATH" ]; then
72
+ LEGACY_TS=$(date +"%Y-%m-%d_%H-%M-%S")
73
+ LEGACY_DIR="$SESSIONS_DIR/legacy-$LEGACY_TS"
74
+ mkdir -p "$LEGACY_DIR"
75
+ mv "$LEGACY_SESSION_PATH" "$LEGACY_DIR/session.jsonl"
76
+ echo "[craft-rpa] migrated legacy recorder/session.jsonl → sessions/legacy-$LEGACY_TS/"
77
+ else
78
+ rm -f "$LEGACY_SESSION_PATH"
79
+ fi
80
+ fi
81
+
82
+ if [ -L "$LEGACY_PROFILE_PATH" ]; then
83
+ # 只删除旧软链本身,真实 profile 目标继续留在数据根。
84
+ rm -f "$LEGACY_PROFILE_PATH"
85
+ echo "[craft-rpa] removed legacy recorder/profile symlink"
86
+ elif [ -e "$LEGACY_PROFILE_PATH" ]; then
87
+ LEGACY_TS=$(date +"%Y-%m-%d_%H-%M-%S")
88
+ mv "$LEGACY_PROFILE_PATH" "$CRAFT_RPA_HOME/profile-legacy-$LEGACY_TS"
89
+ echo "[craft-rpa] migrated legacy recorder/profile → .craft-rpa/profile-legacy-$LEGACY_TS"
90
+ fi
91
+
92
+ if [ -L "$LEGACY_MODULES_DIR" ]; then
93
+ rm -f "$LEGACY_MODULES_DIR"
94
+ echo "[craft-rpa] removed legacy recorder/node_modules symlink"
95
+ elif [ -e "$LEGACY_MODULES_DIR" ]; then
96
+ # node_modules 只是可重建缓存,精确删除受管目录中的旧副本,避免 Plugin 重放再次扫描它。
97
+ rm -rf "$LEGACY_MODULES_DIR"
98
+ echo "[craft-rpa] removed legacy recorder/node_modules cache"
99
+ fi
100
+ }
101
+
44
102
  cmd="${1:-status}"
45
103
 
46
104
  case "$cmd" in
@@ -60,44 +118,22 @@ case "$cmd" in
60
118
  exit 0
61
119
  fi
62
120
 
63
- if [ ! -d "$RECORDER_DIR/node_modules" ]; then
64
- echo "[craft-rpa] installing deps (one-time, ~30-60s) ..."
65
- (cd "$RECORDER_DIR" && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install --no-audit --no-fund)
66
- fi
67
-
68
121
  mkdir -p "$CRAFT_RPA_HOME" "$SESSIONS_DIR" "$PROFILE_DIR"
69
-
70
- # 旧 session.jsonl 是普通文件(老版本遗留)→ 归档,避免 ln 覆盖丢数据
71
- if [ -e "$SESSION_LINK" ] && [ ! -L "$SESSION_LINK" ]; then
72
- if [ -s "$SESSION_LINK" ]; then
73
- LEGACY_TS=$(date +"%Y-%m-%d_%H-%M-%S")
74
- LEGACY_DIR="$SESSIONS_DIR/legacy-$LEGACY_TS"
75
- mkdir -p "$LEGACY_DIR"
76
- mv "$SESSION_LINK" "$LEGACY_DIR/session.jsonl"
77
- echo "[craft-rpa] migrated legacy recorder/session.jsonl → sessions/legacy-$LEGACY_TS/"
78
- else
79
- rm -f "$SESSION_LINK"
80
- fi
81
- fi
82
- # 旧 profile 是普通目录(老版本遗留)→ 归档到数据根
83
- if [ -e "$PROFILE_LINK" ] && [ ! -L "$PROFILE_LINK" ]; then
84
- LEGACY_TS=$(date +"%Y-%m-%d_%H-%M-%S")
85
- mv "$PROFILE_LINK" "$CRAFT_RPA_HOME/profile-legacy-$LEGACY_TS"
86
- echo "[craft-rpa] migrated legacy recorder/profile → .craft-rpa/profile-legacy-$LEGACY_TS"
87
- fi
122
+ cleanup_legacy_recorder_runtime
123
+ prepare_runtime_dependencies
88
124
 
89
125
  TS=$(date +"%Y-%m-%d_%H-%M-%S")
90
126
  NEW_SESSION_DIR="$SESSIONS_DIR/$TS"
91
127
  mkdir -p "$NEW_SESSION_DIR"
92
128
  : > "$NEW_SESSION_DIR/session.jsonl"
93
129
 
94
- # 建两个软链:launch.js / logger.js 通过 __dirname 读到的就是项目根的数据
95
- ln -sfn "$NEW_SESSION_DIR/session.jsonl" "$SESSION_LINK"
96
- ln -sfn "$PROFILE_DIR" "$PROFILE_LINK"
97
130
  echo "$TS" > "$CURRENT_FILE"
98
131
 
99
132
  cd "$RECORDER_DIR"
100
- nohup node launch.js "$URL" > "$LOG_FILE" 2>&1 &
133
+ CRAFT_RPA_SESSION_FILE="$NEW_SESSION_DIR/session.jsonl" \
134
+ CRAFT_RPA_PROFILE_DIR="$PROFILE_DIR" \
135
+ CRAFT_RPA_PLAYWRIGHT_MODULE="$RUNTIME_MODULES_DIR/playwright" \
136
+ nohup node launch.js "$URL" > "$LOG_FILE" 2>&1 &
101
137
  PID=$!
102
138
  echo "$PID" > "$PID_FILE"
103
139
  sleep 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flower-trellis",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "一键安装/升级 Trellis 并自动融合 skill-garden 强化包(默认 Claude + agents)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -68,9 +68,9 @@
68
68
  "commit-and-tag-version": "^12.7.3"
69
69
  },
70
70
  "flowerReleaseNotes": {
71
- "version": "0.5.2",
71
+ "version": "0.5.3",
72
72
  "source": "CHANGELOG.md",
73
- "body": "### ✨ 新功能 Features\n\n* **0.6:** 同步 Push 门禁与意图识别 ([8abf3b1](https://github.com/SilentFlower/flower-trellis/commit/8abf3b1439d7a5dd2af0d698c4094aa6c7207fb3))\n* **brief:** 同步 Brief 显式预授权流程 ([7c179af](https://github.com/SilentFlower/flower-trellis/commit/7c179affd7389cac053d28104e0d624773159c72))\n* **spec-router:** 同步章节感知加载能力 ([0d51a24](https://github.com/SilentFlower/flower-trellis/commit/0d51a24e6136bb9b167cd5716eeb9958b5d8412e))\n* **trellis:** 补全 Meta 架构并修复 Auto-Loop 自恢复 ([943179f](https://github.com/SilentFlower/flower-trellis/commit/943179f42e3e3d068bf98f8ff60b2aa324acc4dd))\n* **plugin:** 建立契约与项目状态存储 ([3c664c3](https://github.com/SilentFlower/flower-trellis/commit/3c664c337806849d7bf040f269eb0f1ebed115f0))\n* **plugin:** 支持 GitHub 外部格式与交互来源管理 ([8701039](https://github.com/SilentFlower/flower-trellis/commit/8701039ea8f8018e7e8d017c0ac5f545b9b2549b))\n* **plugin:** 新增 GitLab 市场外部来源 ([3688744](https://github.com/SilentFlower/flower-trellis/commit/3688744d7663952d437f4bc4e5c8187798c45453))\n* **plugin:** 新增插件能力授权策略 ([32ec993](https://github.com/SilentFlower/flower-trellis/commit/32ec993de77686fb411451c4165ca17367cf5e33))\n* **plugin:** 新增插件运行时生命周期 ([0d7c5b1](https://github.com/SilentFlower/flower-trellis/commit/0d7c5b1ae4821e8cbadcd857608399c2e725c560))\n* **plugin:** 添加作者工具与 CI 契约 ([4e34b87](https://github.com/SilentFlower/flower-trellis/commit/4e34b878f37fddb3732b2264b90f79b3610c82d0))\n* **plugin:** 迁移 skill-garden 到插件运行时 ([2e9fd18](https://github.com/SilentFlower/flower-trellis/commit/2e9fd18123e3164a68971725269619401b2625b4))\n* 增加 Check-All 下一步引导 ([fa488f8](https://github.com/SilentFlower/flower-trellis/commit/fa488f83c85983b8b8c545b85e4e42d20b290bdf))\n\n\n### 🐛 修复 Bug Fixes\n\n* **trellis:** 移除旧更新检查 marker ([a33f376](https://github.com/SilentFlower/flower-trellis/commit/a33f376d7efe15441cba8fbe0b0a67c6f1a15f6c))\n* **plugin:** 优化外部来源交互与 archive 安全检测 ([4699a22](https://github.com/SilentFlower/flower-trellis/commit/4699a2211cb280e5a085c852950fda52af28c456))\n* **plugin:** 修复远程更新并完成集成验收 ([3db8320](https://github.com/SilentFlower/flower-trellis/commit/3db83208a6da12c3bbcad1730e144271ff02a96a))\n* **trellis:** 优化 auto-loop 重试预算与跟踪状态 ([19d7a1a](https://github.com/SilentFlower/flower-trellis/commit/19d7a1ae5302af56085e1053706442ad7cb347b0))\n* **trellis:** 支持跳过 Check-All 并修复 update -y ([1bd0a12](https://github.com/SilentFlower/flower-trellis/commit/1bd0a126f91f6b915d2253f1a497bda40fc41edb))\n* 修复 Windows Trellis Patch 命令漂移 ([5892e7f](https://github.com/SilentFlower/flower-trellis/commit/5892e7f7629be0a7ee075a3758f594a220c7db1f))\n\n\n### ♻️ 重构 Refactor\n\n* **trellis:** 拆分 Check-All 检查档位并同步快照 ([ef1a8c4](https://github.com/SilentFlower/flower-trellis/commit/ef1a8c434244c06cf565c449173c6d6763a0a160))\n\n\n### 🧰 维护 Maintenance\n\n* **trellis:** 同步 Flower 0.5.1-beta.1 清单 ([d3dfc27](https://github.com/SilentFlower/flower-trellis/commit/d3dfc2759927b0759b10e2ae246b68438dc3d339))\n* **trellis:** 执行 Flower 自更新同步 ([f79ce33](https://github.com/SilentFlower/flower-trellis/commit/f79ce33bcc409129a054f1b59a016cdbe1ea991b))",
73
+ "body": "### 🐛 修复 Bug Fixes\n\n* **plugin:** 修复 Craft RPA 运行时软链重放 ([3a68641](https://github.com/SilentFlower/flower-trellis/commit/3a686414c0941ca365b9443328064ab6143cd6f8))",
74
74
  "truncated": false
75
75
  },
76
76
  "optionalDependencies": {
@@ -5,6 +5,11 @@ import { ENHANCEMENT_SKILL_TARGETS } from "../../constants.js";
5
5
  import { readLegacyManifestStatus } from "../../lib/manifest.js";
6
6
  import { shouldInstallName } from "../../lib/skill-filter.js";
7
7
  import { listCanonicalTreeFiles } from "../../plugin/integrity/canonical-tree.js";
8
+ import {
9
+ PluginIntegrityError,
10
+ PluginIoError,
11
+ PluginPathError,
12
+ } from "../../plugin/errors.js";
8
13
  import { assertSafePosixRelativePath } from "../../plugin/schemas/shared.js";
9
14
  import { compareUtf8 } from "../../plugin/stable-order.js";
10
15
  import {
@@ -48,16 +53,97 @@ const SCRIPT_ALIASES = Object.freeze({
48
53
  ],
49
54
  });
50
55
  const TRELLIS_TEXT_EXTENSIONS = new Set([".json", ".md", ".toml", ".txt", ".yaml", ".yml"]);
56
+ const COMMON_SKILL_RUNTIME_EXCLUDES = Object.freeze({
57
+ "craft-rpa": Object.freeze([
58
+ "recorder/node_modules",
59
+ "recorder/profile",
60
+ "recorder/session.jsonl",
61
+ ]),
62
+ });
51
63
 
52
64
  /**
53
65
  * 列出目标目录中的普通文件。
54
66
  *
67
+ * 只有 builtin common skill 明确登记的旧运行时路径可以跳过;遍历在读取条目
68
+ * 类型前先匹配排除项,确保不会跟随旧软链。其它路径继续使用 canonical 规则。
69
+ *
55
70
  * @param {string} directory 目标目录
56
- * @returns {Array<{path:string,absolutePath:string}>} 稳定文件列表
71
+ * @param {string[]} [excludedPaths] 允许跳过的精确相对路径及其子树
72
+ * @returns {Array<{path:string,absolutePath:string,size:number}>} 稳定文件列表
57
73
  */
58
- function listExistingFiles(directory) {
74
+ function listExistingFiles(directory, excludedPaths = []) {
59
75
  if (!fs.existsSync(directory)) return [];
60
- return listCanonicalTreeFiles(directory);
76
+ if (excludedPaths.length === 0) return listCanonicalTreeFiles(directory);
77
+
78
+ const excluded = new Set(excludedPaths);
79
+ const absoluteRoot = path.resolve(directory);
80
+ let rootStat;
81
+ try {
82
+ rootStat = fs.lstatSync(absoluteRoot);
83
+ } catch (error) {
84
+ throw new PluginIoError(`无法读取 Plugin 根目录:${absoluteRoot}`, { path: absoluteRoot, cause: error });
85
+ }
86
+ if (rootStat.isSymbolicLink()) {
87
+ throw new PluginPathError(`Plugin 根目录不能是软链:${absoluteRoot}`, { path: absoluteRoot });
88
+ }
89
+ if (!rootStat.isDirectory()) {
90
+ throw new PluginIntegrityError(`Plugin 根目录必须是目录:${absoluteRoot}`, { path: absoluteRoot });
91
+ }
92
+
93
+ const files = [];
94
+
95
+ /**
96
+ * 判断路径是否属于已登记运行时边界。
97
+ *
98
+ * @param {string} relativePath POSIX 相对路径
99
+ * @returns {boolean} 是否跳过该路径
100
+ */
101
+ function isExcluded(relativePath) {
102
+ for (const candidate of excluded) {
103
+ if (relativePath === candidate || relativePath.startsWith(`${candidate}/`)) return true;
104
+ }
105
+ return false;
106
+ }
107
+
108
+ /**
109
+ * 遍历已安装目标树,但不读取已登记运行时条目的类型或目标。
110
+ *
111
+ * @param {string} current 当前绝对目录
112
+ * @param {string} relativeDirectory 当前 POSIX 相对目录
113
+ * @returns {void}
114
+ */
115
+ function visit(current, relativeDirectory) {
116
+ let entries;
117
+ try {
118
+ entries = fs.readdirSync(current, { withFileTypes: true });
119
+ } catch (error) {
120
+ throw new PluginIoError(`无法读取 Plugin 目录:${current}`, { path: current, cause: error });
121
+ }
122
+ for (const entry of entries) {
123
+ const relativePath = relativeDirectory
124
+ ? `${relativeDirectory}/${entry.name}`
125
+ : entry.name;
126
+ assertSafePosixRelativePath(relativePath, "Plugin tree 路径");
127
+ if (isExcluded(relativePath)) continue;
128
+
129
+ const absolutePath = path.join(current, entry.name);
130
+ let stat;
131
+ try {
132
+ stat = fs.lstatSync(absolutePath);
133
+ } catch (error) {
134
+ throw new PluginIoError(`无法读取 Plugin 条目:${relativePath}`, { path: relativePath, cause: error });
135
+ }
136
+ if (stat.isSymbolicLink()) {
137
+ throw new PluginPathError(`Plugin tree 不允许软链:${relativePath}`, { path: relativePath });
138
+ }
139
+ if (stat.isDirectory()) visit(absolutePath, relativePath);
140
+ else if (stat.isFile()) files.push({ path: relativePath, absolutePath, size: stat.size });
141
+ else throw new PluginIntegrityError(`Plugin tree 不允许特殊文件:${relativePath}`, { path: relativePath });
142
+ }
143
+ }
144
+
145
+ visit(absoluteRoot, "");
146
+ return files.sort((left, right) => compareUtf8(left.path, right.path));
61
147
  }
62
148
 
63
149
  /**
@@ -123,6 +209,7 @@ export function projectSkillGardenContent(options) {
123
209
  * @param {string} sourceLabel 来源标签
124
210
  * @param {"exclusive"|"shared"} ownership 所有权
125
211
  * @param {boolean} materializePython 是否按目标项目命令物化明确文本载荷
212
+ * @param {string[]} runtimeExcludes 已登记的目标运行时路径
126
213
  */
127
214
  function addTree(
128
215
  sourceRoot,
@@ -130,6 +217,7 @@ export function projectSkillGardenContent(options) {
130
217
  sourceLabel,
131
218
  ownership = "exclusive",
132
219
  materializePython = false,
220
+ runtimeExcludes = [],
133
221
  ) {
134
222
  const desired = new Set();
135
223
  for (const file of listCanonicalTreeFiles(sourceRoot)) {
@@ -143,7 +231,10 @@ export function projectSkillGardenContent(options) {
143
231
  materializePython,
144
232
  );
145
233
  }
146
- for (const file of listExistingFiles(path.join(projectRoot, ...targetRoot.split("/")))) {
234
+ for (const file of listExistingFiles(
235
+ path.join(projectRoot, ...targetRoot.split("/")),
236
+ runtimeExcludes,
237
+ )) {
147
238
  if (desired.has(file.path)) continue;
148
239
  const target = `${targetRoot}/${file.path}`;
149
240
  mutations.push({
@@ -266,6 +357,8 @@ export function projectSkillGardenContent(options) {
266
357
  common.target,
267
358
  `skill-garden:common:${common.name}`,
268
359
  "shared",
360
+ false,
361
+ COMMON_SKILL_RUNTIME_EXCLUDES[common.name] || [],
269
362
  );
270
363
  }
271
364
  for (const removedTarget of commonSync.removedTargets) {