@nathan33/nao-skill 0.12.1 → 0.16.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.
@@ -34,6 +34,7 @@ PM 在**立项 / 终签 / 发布**时读取;RD 在**起分支 / 开 PR / 合
34
34
  - **禁止** `git push` 到 main、**禁止绕过 PR 合并**;feature 分支允许 `--force-with-lease`(改基 / 压缩历史时)。
35
35
  - 提交规范、`wip()` 前缀、路径级暂存、可读性:见 @.agents/skills/commit.md。
36
36
  - **squash 后 main 上的提交信息 = PR 标题 + PR 正文** → **可读性硬性落在 PR 标题**(用户可见行为,禁纯编号/类名/路径)。
37
+ - ⚠️ **单提交 PR 的 squash 陷阱(T511 实测 3/3 仓中招)**:`gh pr merge --squash` 在 PR **只有 1 条提交**时,取的是**该提交的 subject**(**不是** PR 标题)⇒ 分支纪律的 `wip(<编号>):` 前缀会直接进主干。**合并命令必须显式指定**:`gh pr merge <n> --squash --delete-branch --subject "<用户可读主题>" --body "<摘要>"`;或合并前把分支压成一条用户可读提交。主干受保护(禁 force-push / `enforce_admins=true`)时该偏差**不可回溯**,只能记录。
37
38
 
38
39
  ## Issue 与状态(避免双源)
39
40
 
@@ -31,7 +31,10 @@ nao 是一套「多角色 AI 开发舰队」:6 个分工明确的常驻会话
31
31
  2. **拉起舰队**:`bash .agents/scripts/nao-fleet.sh ensure pm arch-designer rd-fe rd-be qa rd-infra`
32
32
  - 任务派生(并行隔离):`ensure --task <编号> <别名>`
33
33
  - 状态 / 回收:`status` / `close --task <编号> <别名>`
34
- 3. **迁移旧版安装**:`npx @nathan33/nao-skill migrate`(旧文件备份到 `.agents/.nao-obsolete/`,shim 就位)。
34
+ - 实验场清理:`cleanup -S <隔离socket>`(强制显式 socket;无 `-S`/指向默认 socket ⇒ exit 2 拒绝)
35
+ ⚠ 实验纪律:实验/夹具一律 `env -u TMUX` + `-S <隔离socket>`——tmux 客户端在 `$TMUX` 已设时
36
+ 无视 `TMUX_TMPDIR`,裸 `tmux`/`kill-server` 会连坐真实 server(T515 两次自灭根因)
37
+ 3. **迁移旧版安装**:`npx @nathan33/nao-skill migrate`(旧文件备份到 `.agents/.nao-obsolete/`)。是否写 shim 由**判定式**决定(旧脚本 / 已有 shim / 全仓 `nao-fleet|nao-skill|NAO_SKILLS` 引用 ⇒ 写);纯文档仓判定为跳过并显式告知,可用 `--shim` / `--no-shim` 覆盖。
35
38
 
36
39
  ## 硬约束(勿违反)
37
40
 
@@ -36,7 +36,7 @@
36
36
 
37
37
  ## 指针(勿复制内容)
38
38
 
39
- > 项目内 `.agents/` **只有 shim**(`.agents/scripts/nao-fleet.sh`);机制单一事实来源在 **nao 机制包**内,包根由 `NAO_SKILLS` 给出(fleet 注入到会话系统提示)。所有机制引用一律以 `$NAO_SKILLS/.agents/` 为根解析。
39
+ > 项目内 `.agents/` **只有 shim**(`.agents/scripts/nao-fleet.sh`);机制单一事实来源在 **nao 机制包**内,包根由 `NAO_SKILLS` 给出(fleet 注入到会话系统提示)。所有机制引用一律以 `$NAO_SKILLS/.agents/` 为根解析。**例外:无脚本、无引用的纯文档仓不建 shim**(迁移判定式自动跳过,可用 `--shim` 覆盖)。
40
40
 
41
41
  - 团队级协议/角色清单:`$NAO_SKILLS/.agents/common/` + `$NAO_SKILLS/.agents/roles.yaml`
42
42
  - 角色卡:`$NAO_SKILLS/.agents/prompts/`
package/README.md CHANGED
@@ -24,7 +24,7 @@ nao-skills 是一套 **角色卡 + 协作规矩 + 小工具**。装上以后,
24
24
 
25
25
  ```bash
26
26
  cd 你的项目
27
- pi install --local npm:@nathan33/nao-skill@0.12.0 # 声明 pin + 把机制包物化到项目内 .pi/npm
27
+ pi install --local npm:@nathan33/nao-skill@0.14.0 # 声明 pin + 把机制包物化到项目内 .pi/npm
28
28
  npx @nathan33/nao-skill init # 写入转发 shim + 生成 AGENTS.md
29
29
  ```
30
30
 
@@ -36,11 +36,7 @@ nao-skills 是一套 **角色卡 + 协作规矩 + 小工具**。装上以后,
36
36
 
37
37
  升级:改 `.pi/settings.json` 里的 pin 版本,再 `pi update --extensions`(机制以包为准,项目里只有 shim)。
38
38
 
39
- **老项目迁移**(曾在项目里铺开整套 `.agents/` 的):
40
-
41
- ```bash
42
- npx @nathan33/nao-skill migrate # 旧文件备份到 .agents/.nao-obsolete/,shim 就位,旧命令照跑
43
- ```
39
+ **老项目迁移**(曾在项目里铺开整套 `.agents/` 的):执行 `npx @nathan33/nao-skill migrate` 即可,细则见文末「老用户迁移与维护者注记」。
44
40
 
45
41
  ## 用起来(3 步)
46
42
 
@@ -51,6 +47,8 @@ npx @nathan33/nao-skill migrate # 旧文件备份到 .agents/.nao-obsolete/,
51
47
 
52
48
  角色名可以写**别名**或**规范名**(两者等价):`arch` = `arch-designer`、`infra` = `rd-infra`;上例里两者混用也照常拉起。完整别名表见 `.agents/roles.yaml` 或 `nao-fleet.sh --help`。
53
49
 
50
+ **不在 tmux 里也能用**:默认每个角色开一个**看得见的终端窗口**(走 `xdg-terminal-exec`,窗口标识 `org.nao.<角色>`),不是后台黑箱;无图形会话(如 SSH)默认拒绝,需显式 `--background` 才建后台会话,并打印可复制的 `tmux attach` 命令。宿主的完整选择顺序与降级边界见 `nao-fleet.sh --help`。不过我们**推荐装 tmux**——多数能力在 tmux 宿主下体验最完整,理由见下方常见问题。
51
+
54
52
  然后**在 PM 会话里说需求**就行,剩下的它自己流转。大概长这样:
55
53
 
56
54
  ```text
@@ -76,17 +74,6 @@ PM:在 PR 核 AC 并评论 → 验收通过(授权合并)→ rd-be squash
76
74
 
77
75
  不想要哪个角色,删掉对应文件即可;只留 PM + 一个研发角色也能跑。
78
76
 
79
- ## 可选:关键节点 QQ 主动推送
80
-
81
- 装上 [`pi-agent-qqbot`](https://github.com/Nathan3303) 并配好 `~/.pi/agent/pi-agent-qqbot.json`(`appId` / `clientSecret` / `ownerOpenId` / `sandbox`)后,PM 可把**关键节点**主动推到你 QQ——批次进度汇总、验收结论、发版或合并完成、异常阻塞:
82
-
83
- ```bash
84
- $NAO_SKILLS/.agents/scripts/qq-notify "[T301] 验收通过 · PR #4 已合并 · v0.8.0"
85
- $NAO_SKILLS/.agents/scripts/qq-notify --dry-run "连通性自检" # 只取 token、不发送
86
- ```
87
-
88
- **为什么默认不主动打扰**:这是**可选能力**——没配 `pi-agent-qqbot` 就自动视为未启用;四类节点之外不发,一个节点最多一条(禁刷屏);推送失败只记回执行风险项,**不阻断交付**。默认走 sandbox(测试环境),正式发布通知才考虑 prod。能力边界、退出码与纪律见 `$NAO_SKILLS/.agents/skills/qq-notify.md`。
89
-
90
77
  ## 为什么它省 token(这是本项目的核心追求)
91
78
 
92
79
  和 AI 聊天,钱花在「它每轮要读多少字」。所以:
@@ -108,6 +95,15 @@ $NAO_SKILLS/.agents/scripts/qq-notify --dry-run "连通性自检" # 只取
108
95
 
109
96
  **跑完的会话要留着吗?** 后台派生会话验收完可以回收:`nao-fleet.sh close --task T1 rd-be`(等价写法 `nao-fleet.sh close rd-be-T1`,直接用派生会话名即可);常驻角色留着复用。`nao-fleet.sh status` 会提示哪些是残留。目标在别的项目里也能回收——`close` 会按名册/窗口标题跨项目定位;定位不到时会**非 0 退出并打印诊断**,不会假装已回收。
110
97
 
98
+ **推荐用 tmux 吗?** **推荐**——装上 tmux 后,舰队机制的使用体验最完整:
99
+
100
+ - **布局可控**:6 个角色会话在 tmux 里按三种布局自动排布(`main-row2` / `main-col` / `grid`),间距与列宽可用 `NAO_TMUX_*` 变量调,不会把桌面铺满一圈窗口。
101
+ - **后台会话可回看**:后台派生的会话随时 `tmux attach` 进去看,不挑图形环境(SSH 无图形会话也能用)。
102
+ - **定位回收更可靠**:`status` 查残留、`close` 跨项目回收,都是按会话名册与窗口标题定位的——tmux 宿主下窗口标题稳定可寻,误判和找不到的概率最低。
103
+ - **多种宿主探测路径**:机制里 tmux 属首选阶梯;非 tmux 宿主走外部终端窗口,探测与降级链路更长。
104
+
105
+ 没装 tmux 也能正常用(见「不在 tmux 里也能用」),只是上述便利项会依次降级。
106
+
111
107
  **终端窗口太挤怎么办?** tmux 宿主支持三种布局:默认 `main-row2`(主会话占左,其余往右分列)、`main-col`(其余在右列竖排)、`grid`(等大网格)。窗口太窄时 `main-row2` 会**自动回退** `main-col`、仍不够再退 `grid`,并打印带数字的提示(不中断拉起);用 `NAO_TMUX_LAYOUT` / `NAO_TMUX_MAIN_WIDTH` / `NAO_TMUX_MIN_PANE_WIDTH` 调整,详见 `bash .agents/scripts/nao-fleet.sh --help`。
112
108
 
113
109
  **它会自己上网查吗?** 会,但有分工:产品经理查同类功能的**产品形态**(有哪些功能点、属性、视图、协作方式),架构师对照**业界成熟的架构模式**。两者都必须把来源和「适不适用本项目」落成文件(`docs/research/`),抓来的网页内容不会堆进聊天记录;技术选型仍然只由架构师定。
@@ -128,29 +124,35 @@ $NAO_SKILLS/.agents/scripts/qq-notify --dry-run "连通性自检" # 只取
128
124
 
129
125
  机制本体(6 张角色卡、协作规矩、清单、按需技能、小工具)都在 npm 包 `@nathan33/nao-skill` 的 `.agents/` 里——**包里是什么就是什么**,项目里不留副本。
130
126
 
131
- ## 发布形态与完整性判据(0.12 起有变化)
127
+ ## 老用户迁移与维护者注记(新用户可跳过)
128
+
129
+ 本节面向**已有旧版安装**或需要维护使用机制的仓库 CI 的读者,新用户可以直接跳到「想深入」。
130
+
131
+ ### 老项目迁移(migrate)
132
+
133
+ ```bash
134
+ npx @nathan33/nao-skill migrate # 旧文件备份到 .agents/.nao-obsolete/,再按需写入转发入口
135
+ ```
136
+
137
+ 迁移默认按**判定式**决定是否写转发入口(shim):仓里还有旧的 `nao-fleet.sh`、或已存在 shim、或全仓有 `nao-fleet` / `nao-skill` / `NAO_SKILLS` 引用,就写;**无脚本、无引用的纯文档仓会被判定跳过**,并打印一行告知。要改变判定结果,显式加 `--shim`(强制写)或 `--no-shim`(强制不写),两者不能同时给(`exit 2`)。旧版的 `.agents/.nao-migrated` 标记**已废弃**:迁移不再读写它,可安全删除。`.agents/.nao-version` 的语义是「本机已安装机制包版本」(供升级检测),**不表达迁移状态**——迁移状态由「是否还有旧资产」推断。
138
+
139
+ ### 发布形态与完整性判据(0.12 起有变化)
132
140
 
133
141
  **从 0.12 起,机制不再铺进项目**:项目里只留一个转发入口,机制的**单一事实来源**是 npm 包 `@nathan33/nao-skill` 内的 `.agents/`。
134
142
 
135
- - **旧的完整性判据已失效**:以前下游靠「项目内 `.agents/**` 与上游逐字节 sha 一致」证明机制没漏没改;现在项目内不再有机制副本,这条判据不再适用。
143
+ - **旧的完整性判据已失效**:以前靠「项目内 `.agents/**` 与包内逐字节 sha 一致」证明机制没漏没改;现在项目内不再有机制副本,这条判据不再适用。
136
144
  - **新的完整性判据:以 pin 的包版本为准**。`.pi/settings.json` 记录版本,`pi install/update` 从包物化;包里是什么就是什么,不存在「项目内被就地改动」的问题。转发入口是生成的固定文本,可用 `npx @nathan33/nao-skill init` 幂等重写核对。
137
- - **版本权威口径**:`.pi/settings.json` 里的 pin 是**唯一权威**(`pi install/update` 据此物化到 `.pi/npm`);`.agents/.nao-version` 只是本机安装标记(供升级检测与迁移提示),**不作为版本依据**;两者不一致时**以 pin 为准**。
138
- - **fmt 边界**:包内 `.agents/**` 仍以上游原始存储形态发布(部分 YAML / Markdown / MTS 未按 oxfmt / prettier 归一)。迁移期仍持有旧版全套 `.agents/**` 的仓库,继续把 `.agents/**` 排除出 fmt 校验即可,⛔ **不要为过 fmt 就地格式化**;新形态项目内只有转发入口,无需再处理。
145
+ - **版本权威口径**:`.pi/settings.json` 里的 pin 是**唯一权威**(`pi install/update` 据此物化到 `.pi/npm`);`.agents/.nao-version` 的语义是**本机已安装机制包版本**(供升级检测),**不表达迁移状态**、**不作为版本依据**;两者不一致时**以 pin 为准**。
146
+ - **fmt 边界**:包内 `.agents/**` 按原始存储形态发布(部分 YAML / Markdown / MTS 未按 oxfmt / prettier 归一)。迁移期仍持有旧版全套 `.agents/**` 的仓库,继续把 `.agents/**` 排除出 fmt 校验即可,⛔ **不要为过 fmt 就地格式化**;新形态项目内只有转发入口,无需再处理。
139
147
 
140
148
  ### 物化与离线(CI / 无 pi 环境必读)
141
149
 
142
150
  - `.pi/npm/` 由 pi 管理并 **gitignore**。因此 **fresh clone 上没有 `.pi/npm/package.json` 与 lockfile**,`npm ci --prefix .pi/npm` 当前**不可用**。
143
151
  - 物化只有一条路:**显式执行** `pi install -l --approve npm:@nathan33/nao-skill@<pin>`(需要 pi 与网络,是有意为之的一步)。
144
- - **联动条款**:若某个仓库想把机制体检 `check` 放进 CI(即改变「下游 CI 零改动」的约定),**必须同时**用 `git add -f` 把 `.pi/npm/package.json`、`.pi/npm/package-lock.json` 纳入版本控制,`npm ci` 才有输入。
152
+ - **联动条款**:若某个仓库想把机制体检 `check` 放进 CI,**必须同时**用 `git add -f` 把 `.pi/npm/package.json`、`.pi/npm/package-lock.json` 纳入版本控制,`npm ci` 才有输入。
145
153
  - **pi 启动的隐式安装**:当 `.pi/npm/node_modules` 缺失时,pi 启动会隐式执行 `npm install --prefix .pi/npm --legacy-peer-deps`;离线且缓存为空会得到可读的 npm 报错,并让 pi 以非 0 退出(不会静默假装成功)。
146
154
  - **运行纪律**:**先显式物化,再 `PI_OFFLINE=1` 运行**。nao 机制自身(转发入口 / fleet / CLI / check)**零网络调用**;入口找不到机制包时不静默成功,而是 `exit 2` 并打印一行 `DEGRADED:` + 恢复命令。
147
155
 
148
- ## 下游仓库迁移(nao-todo / nao-todo-server / nue-ui)
149
-
150
- - **迁移期零改动**:旧的全套 `.agents/scripts/nao-fleet.sh` 照常可用,这些仓库的 CI 目前不调用 `check`,不受影响。
151
- - 想切到新形态时,在各仓执行 `npx @nathan33/nao-skill migrate`:只删**已知 nao 资产**(共享的 `.agents/skills/` 里项目自有的技能会保留),并把 `skills-lock.json` 里重复的 `frontend-design` 来源收敛为「机制包唯一来源」。
152
- - **`nao-todo-minimal` 特例**:无脚本、无 nao-fleet 引用 ⇒ **不建转发入口**,按纯文档/指针迁移;它的 `.agents/commands/`、`.agents/prompts/` 不在 pi 的 skill 扫描面(pi 只扫 `.agents/skills/**`)。
153
-
154
156
  ## 想深入
155
157
 
156
158
  README 只讲怎么用。真正的机制都在依赖包 `@nathan33/nao-skill` 的 `.agents/` 里(会话里以 `$NAO_SKILLS/.agents/` 指向),想改就从这几处入手:
package/bin/nao-skill.js CHANGED
@@ -42,18 +42,16 @@ const SHIM_SRC = fileURLToPath(new URL('./shim/nao-fleet.sh', import.meta.url));
42
42
  const OBSOLETE = ['.agents/skills/checklists'];
43
43
  // 安装版本标记(install/update 写入,供下次检测升级)
44
44
  const VERSION_FILE = '.agents/.nao-version';
45
- // 迁移一次性提示标记(BR4:每版本最多提示一次)
46
- const MIGRATED_FILE = '.agents/.nao-migrated';
47
45
 
48
46
  // 旧版全套安装的已知 nao 资产(迁移时**精准**备份+移除;共享目录不得整目录删)
49
47
  const LEGACY_TOP_DIRS = ['prompts', 'common', 'checklists', 'templates'];
50
48
  const LEGACY_TOP_FILES = ['roles.yaml'];
51
- const LEGACY_SCRIPT_FILES = ['nao-fleet.sh', 'intercom-probe.mts', 'qq-notify', 'ui-tokens-check.sh'];
49
+ const LEGACY_SCRIPT_FILES = ['nao-fleet.sh', 'intercom-probe.mts', 'ui-tokens-check.sh'];
52
50
  const LEGACY_SKILL_ENTRIES = [
53
51
  'nao-fleet', 'frontend-design',
54
52
  'arch-patterns.md', 'backend-ddd-details.md', 'codegraph.md', 'commit.md',
55
53
  'frontend-ddd-details.md', 'github-flow.md', 'pm-grill.md', 'pm-operations.md',
56
- 'pm-rice.md', 'pm-routing.md', 'qq-notify.md', 'research.md', 'test-design.md',
54
+ 'pm-rice.md', 'pm-routing.md', 'research.md', 'test-design.md',
57
55
  ];
58
56
 
59
57
  const log = (...a) => console.log('[nao-skill]', ...a);
@@ -287,10 +285,67 @@ function detectLegacyAssets(target) {
287
285
  return hits;
288
286
  }
289
287
 
288
+ // ---------------------------------------------------------------------------
289
+ // F4 shim 判定式:needsShim = A ∨ A′ ∨ B(Node 实现,无 shell grep / 无网络时间依赖)
290
+ // A 旧版真脚本存在 · A′ 已存在 shim · B 存在外部调用点
291
+ // IGNORE = .git/** · node_modules/** · .pi/** · .agents/.nao-obsolete/** · 二进制 · >1MiB
292
+ // B 额外排除待移除旧资产(legacyAssetPaths);.pi/** 必须排除,否则已物化仓恒真
293
+ // ---------------------------------------------------------------------------
294
+ const SHIM_SCAN_IGNORE_TOP = new Set(['.git', 'node_modules', '.pi']);
295
+ const SHIM_REF_RE = /nao-fleet|nao-skill|NAO_SKILLS/;
296
+
297
+ function detectShimNeed(target) {
298
+ const shim = join(target, '.agents', 'scripts', 'nao-fleet.sh');
299
+ if (existsSync(shim)) return { needed: true, reason: isShimFile(shim) ? 'shim-present' : 'legacy-script' };
300
+ const legacyRel = detectLegacyAssets(target);
301
+ const isLegacyPath = (rel) => legacyRel.some((l) => (l.endsWith('/') ? rel.startsWith(l) : rel === l));
302
+ const stack = [target];
303
+ while (stack.length) {
304
+ const dir = stack.pop();
305
+ let entries;
306
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
307
+ for (const e of entries) {
308
+ const p = join(dir, e.name);
309
+ const rel = relative(target, p);
310
+ if (e.isSymbolicLink()) continue;
311
+ if (e.isDirectory()) {
312
+ if (SHIM_SCAN_IGNORE_TOP.has(rel.split('/')[0]) || e.name === 'node_modules') continue;
313
+ if (rel === '.agents/.nao-obsolete' || rel.startsWith('.agents/.nao-obsolete/')) continue;
314
+ stack.push(p);
315
+ continue;
316
+ }
317
+ if (!e.isFile() || isLegacyPath(rel)) continue;
318
+ let st;
319
+ try { st = statSync(p); } catch { continue; }
320
+ if (st.size > 1024 * 1024) continue;
321
+ let buf;
322
+ try { buf = readFileSync(p); } catch { continue; }
323
+ if (buf.includes(0)) continue; // 二进制(含 NUL 字节)
324
+ if (SHIM_REF_RE.test(buf.toString('utf8'))) return { needed: true, reason: `reference:${rel}` };
325
+ }
326
+ }
327
+ return { needed: false, reason: 'no-reference' };
328
+ }
329
+
330
+ function resolveShim(target, mode) {
331
+ if (mode === 'shim') return { needed: true, reason: 'forced' };
332
+ if (mode === 'no-shim') return { needed: false, reason: 'forced-off' };
333
+ return detectShimNeed(target);
334
+ }
335
+
336
+ function applyShim(target, decision, verbose) {
337
+ if (decision.needed) { installShim(target, verbose); return; }
338
+ if (decision.reason === 'forced-off') {
339
+ warn('按 --no-shim 跳过 shim 安装(项目内将没有 .agents/scripts/nao-fleet.sh;需要时用 --shim 覆盖)。');
340
+ } else {
341
+ warn('未发现 nao 脚本/引用 → 判定为纯文档迁移,未写 shim(可用 --shim 强制)。');
342
+ }
343
+ }
344
+
290
345
  // ---------------------------------------------------------------------------
291
346
  // init:干净项目 —— 写 shim + AGENTS.md(不复制机制;机制在 .pi/npm 包内)
292
347
  // ---------------------------------------------------------------------------
293
- function init(target, force, verbose) {
348
+ function init(target, force, verbose, shimMode = 'shim', decision = null) {
294
349
  const legacy = detectLegacyAssets(target);
295
350
  if (legacy.length) {
296
351
  if (!force) {
@@ -301,9 +356,9 @@ function init(target, force, verbose) {
301
356
  }
302
357
  // F2:--force 不得绕过迁移守卫(否则 shim 覆盖 + 旧副本残留 = 混装,违 BR5/AC1 双注册)
303
358
  log('init --force 检测到旧版 .agents/:改走 migrate(备份 + 精准移除 + shim),避免混装。');
304
- return migrate(target, true, verbose);
359
+ return migrate(target, true, verbose, shimMode === 'no-shim' ? 'no-shim' : 'shim');
305
360
  }
306
- installShim(target, verbose);
361
+ applyShim(target, decision || resolveShim(target, shimMode), verbose);
307
362
  writeVersion(target);
308
363
  if (!hasNaoPackageDeclared(target)) {
309
364
  warn(`未在 .pi/settings.json 发现 npm:${PKG.name} 声明;请先执行 pi install --local npm:${PKG.name}@<pin>。`);
@@ -399,69 +454,82 @@ function removeJsonProperty(raw, key) {
399
454
  while (vs < raw.length && /\s/.test(raw[vs])) vs++;
400
455
  const ve = findJsonValueEnd(raw, vs);
401
456
  if (ve < 0) return null;
402
- // 本属性所在行的起始位置(仅当该行确以 "key" 开头时才做行级删除,防异形格式误伤)
457
+ // 行级删除(保缩进/其余字节):仅当该属性独占一行(行首即 key)时适用
403
458
  let lineStart = m.index;
404
459
  while (lineStart > 0 && raw[lineStart - 1] !== '\n') lineStart--;
405
460
  let nl = raw.indexOf('\n', lineStart);
406
461
  if (nl === -1) nl = raw.length;
407
- if (!raw.slice(lineStart, nl).trim().startsWith(`"${key}"`)) return null;
408
- // 本属性值闭合所在行之后(含换行)
409
- let lineEnd = ve;
410
- while (lineEnd < raw.length && raw[lineEnd] !== '\n') lineEnd++;
411
- if (lineEnd < raw.length) lineEnd++;
412
- // 是否还有后续属性(值后跟逗号)
462
+ if (raw.slice(lineStart, nl).trim().startsWith(`"${key}"`)) {
463
+ // 本属性值闭合所在行之后(含换行)
464
+ let lineEnd = ve;
465
+ while (lineEnd < raw.length && raw[lineEnd] !== '\n') lineEnd++;
466
+ if (lineEnd < raw.length) lineEnd++;
467
+ // 是否还有后续属性(值后跟逗号)
468
+ let after = ve;
469
+ while (after < raw.length && (raw[after] === ' ' || raw[after] === '\t')) after++;
470
+ if (raw[after] === ',') {
471
+ return raw.slice(0, lineStart) + raw.slice(lineEnd);
472
+ }
473
+ // 末位属性:删本行,并去掉前一属性末尾的逗号
474
+ let out = raw.slice(0, lineStart) + raw.slice(lineEnd);
475
+ let p = lineStart;
476
+ while (p > 0 && /[ \t\r\n]/.test(out[p - 1])) p--;
477
+ if (out[p - 1] === ',') out = out.slice(0, p - 1) + out.slice(p);
478
+ return out;
479
+ }
480
+ // F7 属性级回退(单行/异形格式):删「属性 token + 一个分隔逗号」,其余字节不动
413
481
  let after = ve;
414
- while (after < raw.length && (raw[after] === ' ' || raw[after] === '\t')) after++;
415
- if (raw[after] === ',') {
416
- return raw.slice(0, lineStart) + raw.slice(lineEnd);
417
- }
418
- // 末位属性:删本行,并去掉前一属性末尾的逗号
419
- let out = raw.slice(0, lineStart) + raw.slice(lineEnd);
420
- let p = lineStart;
421
- while (p > 0 && /[ \t\r\n]/.test(out[p - 1])) p--;
422
- if (out[p - 1] === ',') out = out.slice(0, p - 1) + out.slice(p);
423
- return out;
482
+ while (after < raw.length && /\s/.test(raw[after])) after++;
483
+ if (raw[after] === ',') return raw.slice(0, m.index) + raw.slice(after + 1);
484
+ // 末位属性:删前一个分隔逗号 + 本属性 token
485
+ let p = m.index;
486
+ while (p > 0 && /[ \t\r\n]/.test(raw[p - 1])) p--;
487
+ if (raw[p - 1] === ',') return raw.slice(0, p - 1) + raw.slice(ve);
488
+ return null;
424
489
  }
425
490
 
426
491
  // §12-C:nao 包为唯一来源 —— 移除 skills-lock.json 里重复的 frontend-design 条目(原文件先备份)
427
492
  // F1:文本级删除单条,保留原缩进与其余字节(4 空格 / 2 空格均不重排)
493
+ // F7:三态返回 ok | absent | failed;failed 时恰一行 warn(不静默)且不改写
428
494
  function stripLockDuplicate(target, stamp, verbose) {
429
495
  const lock = join(target, 'skills-lock.json');
430
- if (!existsSync(lock)) return false;
496
+ if (!existsSync(lock)) return 'absent';
431
497
  const raw = readFileSync(lock, 'utf8');
432
498
  let data;
433
- try { data = JSON.parse(raw); } catch { return false; }
434
- if (!data.skills || !data.skills['frontend-design']) return false;
499
+ try { data = JSON.parse(raw); } catch { return 'absent'; }
500
+ if (!data.skills || !data.skills['frontend-design']) return 'absent';
501
+ const failWarn = () => warn('skills-lock.json 存在重复 frontend-design,但无法安全文本删除:请手工移除该条目;其余迁移步骤不受影响。');
435
502
  const next = removeJsonProperty(raw, 'frontend-design');
436
- if (next == null) return false;
503
+ if (next == null) { failWarn(); return 'failed'; }
437
504
  try {
438
505
  const parsed = JSON.parse(next);
439
- if (!parsed.skills || 'frontend-design' in parsed.skills) return false;
440
- } catch { return false; }
506
+ if (!parsed.skills || 'frontend-design' in parsed.skills) { failWarn(); return 'failed'; }
507
+ } catch { failWarn(); return 'failed'; }
441
508
  const bak = join(target, '.agents', '.nao-obsolete', stamp, 'skills-lock.json');
442
509
  mkdirSync(join(bak, '..'), { recursive: true });
443
510
  cpSync(lock, bak);
444
511
  writeFileSync(lock, next);
445
512
  if (verbose) log(' strip skills-lock.json → 文本级移除 frontend-design(其余字节不变)');
446
- return true;
513
+ return 'ok';
447
514
  }
448
515
 
449
- function migrate(target, force, verbose) {
516
+ function migrate(target, force, verbose, shimMode = 'auto') {
450
517
  const legacy = detectLegacyAssets(target);
451
518
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
519
+ // F4 判定式必须在备份/移除之前采样(B 需排除待移除旧资产)
520
+ const decision = resolveShim(target, shimMode);
521
+ // B2:去重置于早退判断之前 —— 无 legacy 资产(走 init 路径)时同样生效
522
+ stripLockDuplicate(target, stamp, verbose);
452
523
  if (!legacy.length) {
453
524
  log('未发现旧版全套 .agents/ 机制副本;按 init 处理。');
454
- return init(target, force, verbose);
525
+ return init(target, force, verbose, shimMode, decision);
455
526
  }
456
527
  log(`迁移:发现 ${legacy.length} 处旧版 nao 资产,备份到 .agents/.nao-obsolete/${stamp}/ 后移除。`);
457
528
  const removed = migrateLegacy(target, stamp, verbose);
458
- stripLockDuplicate(target, stamp, verbose);
459
- installShim(target, verbose);
529
+ applyShim(target, decision, verbose);
460
530
  writeVersion(target);
461
- mkdirSync(join(target, '.agents'), { recursive: true });
462
- writeFileSync(join(target, MIGRATED_FILE), PKG.version);
463
- log(`✔ 迁移完成:移除 ${removed.length} 项已知 nao 资产(备份在 .agents/.nao-obsolete/${stamp}/);shim 已就位。`);
464
- log(' 旧命令照跑:bash .agents/scripts/nao-fleet.sh check / ensure ...');
531
+ log(`✔ 迁移完成:移除 ${removed.length} 项已知 nao 资产(备份在 .agents/.nao-obsolete/${stamp}/)。`);
532
+ if (decision.needed) log(' 旧命令照跑:bash .agents/scripts/nao-fleet.sh check / ensure ...');
465
533
  }
466
534
 
467
535
  // ---------------------------------------------------------------------------
@@ -484,8 +552,8 @@ function help() {
484
552
  console.log(`nao-skill v${PKG.version} — nao 多角色 AI 开发舰队(pi 原生分发)
485
553
 
486
554
  用法:
487
- nao-skill init [dir] [-v] 干净项目:写 shim + AGENTS.md(机制留在 .pi/npm 包内)
488
- nao-skill migrate [dir] [-v] 迁移旧版全套 .agents/:备份 .nao-obsolete/ + 移除 + shim 就位
555
+ nao-skill init [dir] [-v] [--shim|--no-shim] 干净项目:写 shim + AGENTS.md(默认写)
556
+ nao-skill migrate [dir] [-v] [--shim|--no-shim] 迁移旧版全套 .agents/:备份 .nao-obsolete/ + 移除 + shim
489
557
  nao-skill exec <参数...> 转发给包内 nao-fleet.sh(CI/脚本用;NAO_SKILLS=包根)
490
558
  nao-skill plugins list 列出已知 pi 插件与安装状态
491
559
  nao-skill plugins install <名...> 安装指定插件(intercom/ask-me/subagents/web-access/codegraph)
@@ -493,6 +561,11 @@ function help() {
493
561
  nao-skill --version 显示版本
494
562
  nao-skill --help 显示本帮助
495
563
 
564
+ shim 判定式(migrate 默认;init 默认装):
565
+ needsShim = A ∨ A′ ∨ B(Node 扫描,跳过 >1MiB 与二进制,排除 .git/.pi/node_modules/.nao-obsolete)
566
+ A 旧版真脚本存在 · A′ 已存在 shim · B 全仓有 nao-fleet|nao-skill|NAO_SKILLS 引用(排除待移除旧资产)
567
+ 判定为纯文档迁移时不写 shim,并打印显式告知;用 --shim / --no-shim 覆盖(互斥 → exit 2)
568
+
496
569
  兼容(旧流程,建议迁移到 init/migrate):
497
570
  nao-skill install [dir] [--force] 复制全套 .agents/ 到项目(--plugins 顺带装插件)
498
571
  nao-skill update [dir] 源优先覆盖升级旧版全套安装
@@ -517,20 +590,29 @@ if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
517
590
  let target = process.cwd();
518
591
  let force = false;
519
592
  let verbose = false;
593
+ let wantShim = false;
594
+ let wantNoShim = false;
520
595
  const rest = args.slice(1);
521
596
  for (let i = 0; i < rest.length; i++) {
522
597
  if (rest[i] === '--force' || rest[i] === '-f') force = true;
523
598
  else if (rest[i] === '--verbose' || rest[i] === '-v') verbose = true;
599
+ else if (rest[i] === '--shim') wantShim = true;
600
+ else if (rest[i] === '--no-shim') wantNoShim = true;
524
601
  else if (rest[i] === '--target') target = resolve(rest[++i]);
525
602
  else if (!rest[i].startsWith('-')) target = resolve(rest[i]);
526
603
  else { warn(`未知参数: ${rest[i]}`); process.exit(2); }
527
604
  }
605
+ if (wantShim && wantNoShim) {
606
+ warn('--shim 与 --no-shim 互斥,不能同时使用。');
607
+ process.exit(2);
608
+ }
528
609
  if (!statIsDir(target)) {
529
610
  warn(`目标目录无效: ${target}`);
530
611
  process.exit(2);
531
612
  }
532
- if (cmd === 'init') init(target, force, verbose);
533
- else migrate(target, force, verbose);
613
+ const shimMode = wantShim ? 'shim' : wantNoShim ? 'no-shim' : (cmd === 'init' ? 'shim' : 'auto');
614
+ if (cmd === 'init') init(target, force, verbose, shimMode);
615
+ else migrate(target, force, verbose, shimMode);
534
616
  } else if (cmd === 'exec') {
535
617
  execFleet(args.slice(1));
536
618
  } else if (cmd === 'install' || cmd === 'i') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathan33/nao-skill",
3
- "version": "0.12.1",
3
+ "version": "0.16.0",
4
4
  "description": "nao 多角色 AI 开发舰队:角色卡 / 技能 / 协作协议 / 工具链,一键接入项目(pi / Claude / 任意 Agent)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,199 +0,0 @@
1
- #!/bin/sh
2
- //bin/true; exec node "$0" "$@"
3
- //
4
- // qq-notify —— QQ 官方机器人「主动消息」推送器(舰队可选能力)
5
- //
6
- // 用法(三种形式等价:./qq-notify · node qq-notify · sh qq-notify)
7
- // ./qq-notify "文本" 发给配置中的 ownerOpenId(默认收件人)
8
- // ./qq-notify --to OPENID "文本" 发给指定 openid
9
- // ./qq-notify --dry-run "文本" 只取 token、不发送(连通性自检)
10
- // ./qq-notify --config PATH "文本" 指定配置文件
11
- // ./qq-notify --help 显示帮助
12
- // stdin 管道 无参数时从标准输入读取文本
13
- //
14
- // 配置优先级:--config PATH > 环境变量 QQ_NOTIFY_CONFIG > ~/.pi/agent/pi-agent-qqbot.json
15
- // 退出码:0 成功 · 1 参数/配置错 · 2 取 token 失败 · 3 发送失败
16
- // 安全:绝不打印/落盘 clientSecret 与 access_token;错误输出截断。
17
- //
18
- // ⛔ 禁用 `bash <本脚本>` 这类「用 shell 逐行解释 Node 脚本」的调用方式:
19
- // shell 会把注释里的示例当命令执行(管道、重定向、glob),可能触发意外发送
20
- // (本仓历史上正因此误发过一条真实消息)。正确形式:./qq-notify 或 node qq-notify。
21
- // 第 2 行的 exec 守卫已使 sh/bash 调用自动转交 node,但仍不建议依赖该行为。
22
- //
23
- // 实现说明:本文件无扩展名,需同时兼容宿主项目 package.json 的
24
- // "type": "module" 与 CommonJS 两种解析;因此只用动态 import()(两种格式都合法)
25
- // 并包在 async IIFE 中,避免静态 import / 顶层 await / require。
26
- ;(async () => {
27
- const fs = await import('node:fs')
28
- const os = await import('node:os')
29
- const path = await import('node:path')
30
-
31
- const EXIT = { OK: 0, ARG: 1, TOKEN: 2, SEND: 3 }
32
- const DEFAULT_CFG = path.join(os.homedir(), '.pi/agent/pi-agent-qqbot.json')
33
- const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
34
- const BODY_LIMIT = 300
35
-
36
- const USAGE = [
37
- 'qq-notify —— QQ 官方机器人「主动消息」推送器',
38
- '',
39
- '用法',
40
- ' qq-notify "文本" 发给配置中的 ownerOpenId',
41
- ' qq-notify --to <openid> "文本" 发给指定 openid',
42
- ' echo "文本" | qq-notify 从 stdin 读取文本',
43
- ' qq-notify --dry-run "文本" 只取 token、不发送(连通性自检)',
44
- ' qq-notify --config <path> ... 指定配置文件',
45
- ' qq-notify -h | --help 显示本帮助',
46
- '',
47
- '配置(优先级从高到低)',
48
- ' 1) --config <path>',
49
- ' 2) 环境变量 QQ_NOTIFY_CONFIG',
50
- ' 3) ' + DEFAULT_CFG,
51
- ' 字段:{ "appId": "...", "clientSecret": "...", "ownerOpenId": "...", "sandbox": true }',
52
- '',
53
- '退出码',
54
- ' 0 成功 1 参数/配置错 2 取 token 失败 3 发送失败',
55
- '',
56
- '说明',
57
- ' · 主动消息受 QQ 开放平台权限与月度配额限制;sandbox 与 prod 权限不通用',
58
- ' · 绝不打印或落盘 clientSecret / access_token',
59
- ].join('\n')
60
-
61
- const trunc = (v, n = BODY_LIMIT) => {
62
- const s = String(v ?? '')
63
- return s.length > n ? s.slice(0, n) + '…(截断)' : s
64
- }
65
- // 从任何即将打印的文本中抹去已知凭证值(客户端密钥 / access_token)
66
- const redact = (v, secrets = [], n = BODY_LIMIT) => {
67
- let s = String(v ?? '')
68
- for (const sec of secrets) {
69
- if (sec && String(sec).length >= 6) s = s.split(String(sec)).join('***')
70
- }
71
- return trunc(s, n)
72
- }
73
- const maskId = (v) => (v ? String(v).slice(0, 6) + '…' : '<无>')
74
- const fail = (code, msg, hint) => {
75
- process.stderr.write('FAIL: ' + msg + '\n')
76
- if (hint) process.stderr.write(' → ' + hint + '\n')
77
- process.exit(code)
78
- }
79
-
80
- // ---- 参数解析 -----------------------------------------------------------
81
- const argv = process.argv.slice(2)
82
- let to = null
83
- let cfgPath = null
84
- let dry = false
85
- let text = null
86
- let endOpts = false
87
-
88
- for (let i = 0; i < argv.length; i++) {
89
- const a = argv[i]
90
- if (endOpts) {
91
- if (text === null) { text = a; continue }
92
- fail(EXIT.ARG, '多余的位置参数: ' + trunc(a, 60), '文本请用引号括起为一个参数')
93
- }
94
- if (a === '-h' || a === '--help') { process.stdout.write(USAGE + '\n'); process.exit(EXIT.OK) }
95
- else if (a === '--dry-run') dry = true
96
- else if (a === '--') endOpts = true
97
- else if (a === '--to') {
98
- const v = argv[++i]
99
- if (!v || v.startsWith('--')) fail(EXIT.ARG, '--to 缺少 openid', '用法:qq-notify --to <openid> "文本"')
100
- to = v
101
- } else if (a.startsWith('--to=')) {
102
- to = a.slice(5)
103
- if (!to) fail(EXIT.ARG, '--to 缺少 openid', '用法:qq-notify --to <openid> "文本"')
104
- } else if (a === '--config') {
105
- const v = argv[++i]
106
- if (!v || v.startsWith('--')) fail(EXIT.ARG, '--config 缺少路径', '用法:qq-notify --config <path> "文本"')
107
- cfgPath = v
108
- } else if (a.startsWith('--config=')) {
109
- cfgPath = a.slice(9)
110
- if (!cfgPath) fail(EXIT.ARG, '--config 缺少路径', '用法:qq-notify --config <path> "文本"')
111
- } else if (a.startsWith('-') && a !== '-') {
112
- fail(EXIT.ARG, '未知参数: ' + trunc(a, 60), '查看帮助:qq-notify --help')
113
- } else {
114
- if (text === null) text = a
115
- else fail(EXIT.ARG, '多余的位置参数: ' + trunc(a, 60), '文本请用引号括起为一个参数')
116
- }
117
- }
118
-
119
- if (!text && !process.stdin.isTTY) {
120
- try {
121
- const piped = fs.readFileSync(0, 'utf8').trim()
122
- if (piped) text = piped
123
- } catch { /* 无 stdin:忽略 */ }
124
- }
125
- if (!text) fail(EXIT.ARG, '缺少推送文本', '用法:qq-notify "文本" 或 echo "文本" | qq-notify')
126
-
127
- // ---- 配置 ---------------------------------------------------------------
128
- const cfgFile = cfgPath || process.env.QQ_NOTIFY_CONFIG || DEFAULT_CFG
129
- let cfg
130
- try {
131
- cfg = JSON.parse(fs.readFileSync(cfgFile, 'utf8'))
132
- } catch (e) {
133
- if (e && e.code === 'ENOENT') {
134
- fail(EXIT.ARG, '读不到配置:' + cfgFile,
135
- '创建该 JSON(appId / clientSecret / ownerOpenId / sandbox),或用 --config <path> / QQ_NOTIFY_CONFIG=<path> 指定')
136
- }
137
- fail(EXIT.ARG, '配置解析失败:' + cfgFile + ':' + trunc(e && e.message, 160), '确认该文件是合法 JSON')
138
- }
139
- if (!cfg || typeof cfg !== 'object') fail(EXIT.ARG, '配置内容非法:' + cfgFile, '应为 JSON 对象')
140
- if (!cfg.appId || !cfg.clientSecret) fail(EXIT.ARG, '配置缺少 appId 或 clientSecret', '检查 ' + cfgFile)
141
-
142
- const target = to || cfg.ownerOpenId
143
- if (!target) {
144
- fail(EXIT.ARG, '没有收件人:配置缺 ownerOpenId,且未传 --to',
145
- '在配置中填 ownerOpenId,或 qq-notify --to <openid> "文本"')
146
- }
147
- const env = cfg.sandbox ? 'sandbox' : 'prod'
148
- const base = cfg.sandbox ? 'https://sandbox.api.sgroup.qq.com' : 'https://api.sgroup.qq.com'
149
-
150
- // ---- 取 access token(绝不打日志) --------------------------------------
151
- let tokenRes
152
- try {
153
- tokenRes = await fetch(TOKEN_URL, {
154
- method: 'POST',
155
- headers: { 'content-type': 'application/json' },
156
- body: JSON.stringify({ appId: cfg.appId, clientSecret: cfg.clientSecret }),
157
- })
158
- } catch (e) {
159
- fail(EXIT.TOKEN, '取 token 网络错误:' + trunc(e && e.message, 160), '检查网络/代理后重试')
160
- }
161
- let tokenJson = {}
162
- try { tokenJson = await tokenRes.json() } catch { /* 非 JSON 响应 */ }
163
- if (!tokenRes.ok || !tokenJson.access_token) {
164
- fail(EXIT.TOKEN, '取 token 失败:status=' + tokenRes.status + ' body=' + redact(JSON.stringify(tokenJson), [cfg.clientSecret]),
165
- '核对 appId/clientSecret,并确认 QQ 开放平台应用已开通机器人能力')
166
- }
167
-
168
- if (dry) {
169
- process.stdout.write('OK (dry-run) 取 token 成功 · env=' + env + ' · to=' + maskId(target) + '\n')
170
- process.exit(EXIT.OK)
171
- }
172
-
173
- // ---- 发送主动消息(不带 msg_id) ----------------------------------------
174
- let res
175
- try {
176
- res = await fetch(base + '/v2/users/' + encodeURIComponent(target) + '/messages', {
177
- method: 'POST',
178
- headers: {
179
- 'content-type': 'application/json',
180
- authorization: 'QQBot ' + tokenJson.access_token,
181
- },
182
- body: JSON.stringify({ content: text, msg_type: 0 }),
183
- })
184
- } catch (e) {
185
- fail(EXIT.SEND, '发送网络错误:' + trunc(e && e.message, 160), '检查网络后重试')
186
- }
187
- const body = await res.text().catch(() => '')
188
- if (res.ok) {
189
- process.stdout.write('OK env=' + env + ' to=' + maskId(target) + ' ' + redact(body, [cfg.clientSecret, tokenJson.access_token], 120) + '\n')
190
- process.exit(EXIT.OK)
191
- }
192
- fail(EXIT.SEND, '发送失败:status=' + res.status + ' body=' + redact(body, [cfg.clientSecret, tokenJson.access_token]),
193
- '核对收件人 openid、主动消息权限与月度配额;sandbox 与 prod 权限不通用')
194
- })().catch((e) => {
195
- // 兜底:任何未预期错误都不抛栈(此处不依赖 IIFE 内作用域)
196
- const msg = String((e && e.message) ? e.message : e)
197
- process.stderr.write('FAIL: 未预期错误:' + (msg.length > 200 ? msg.slice(0, 200) + '…(截断)' : msg) + '\n')
198
- process.exit(1)
199
- })