@nathan33/nao-skill 0.13.0 → 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.
- package/.agents/common/intercom-protocol.md +3 -5
- package/.agents/prompts/product-manager.md +0 -4
- package/.agents/scripts/nao-fleet.sh +511 -83
- package/.agents/skills/github-flow.md +1 -0
- package/.agents/skills/nao-fleet/SKILL.md +3 -0
- package/README.md +29 -29
- package/bin/nao-skill.js +2 -2
- package/package.json +1 -1
- package/.agents/scripts/qq-notify +0 -199
- package/.agents/skills/qq-notify.md +0 -87
|
@@ -29,11 +29,9 @@ description: pi-intercom 多会话协作协议(常驻引用)
|
|
|
29
29
|
|
|
30
30
|
- **判重 / 在线判定**:脚本按 pi 自设的**终端标题**(`π - <会话名> - <仓库名>`)+ `pi-intercom` 名册判在线(**不用 `pgrep --name`**:pi 启动后 argv 被改写为 `pi`,会恒失配);已运行则跳过并 warn。确需重开加 `--force`。
|
|
31
31
|
- **标题被改写时的 pane 定位**(v0.9.4):终端标题可被外部改写(实测变为 `pi:c`),此时 `status`/`close`/`ensure` 按三层后备定位 pane:① 标题契约 → ② pi-intercom 名册 `tmuxPane`(注册时读 `$TMUX_PANE`,与标题改名无关,权威)→ ③ 本仓「未被认领 pi pane」唯一兜底。三层均未命中才认「无法确认」并 exit 非 0(不静默 no-op)。`close` 的在跑 turn 判定叠加名册活动状态(thinking / tool:* 等)与 pane 末 12 行状态行双重信号。
|
|
32
|
-
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
自动回退 `main-col`,仍不足再退 `grid`(打印带数字的 warn,**不中断拉起**)。不在 tmux 内则创建
|
|
36
|
-
detached 会话,需 `tmux attach -t nao-<别名>`。
|
|
32
|
+
- **宿主阶梯(tmux 外也看得见)**:`nao-fleet.sh ensure` 按环境选宿主:① `NAO_TERMINAL` 显式(`tmux`/`foot`/`ghostty`/`ptyxis`/`screen`;命令缺失或非法 ⇒ `exit 2`)→ ② `$TMUX` 内:当前窗口分屏 → ③ tmux 可用且有图形会话:建 `nao-<别名>` 会话并用 `xdg-terminal-exec` 开**可见窗口** attach(默认 socket,句柄模型完整)→ ④ 无 tmux 有图形:`xdg-terminal-exec --app-id=org.nao.<别名> --dir=<repo>` 直接开窗 → ⑤ 无图形:仅显式 `--background` 才建 detached 会话 → ⑥ 否则 `exit 2` + 一行诊断与恢复命令。**失败不假成功**:打印「已拉起」前必先校验宿主句柄。
|
|
33
|
+
- **tmux 布局**:②/③ 的 tmux 宿主三选一(`NAO_TMUX_LAYOUT`):默认 `main-row2`(首 pane 全高占左,后续每角色往右开列、每列上下 2 个);`main-col`(其余在右列竖排);`grid`(等大网格)。**窄列守卫**:`main-row2` 最窄非主 pane < `NAO_TMUX_MIN_PANE_WIDTH`(默认 30)时自动回退 `main-col`,仍不足再退 `grid`(打印带数字的 warn,**不中断拉起**)。
|
|
34
|
+
- **非 tmux 宿主降级边界(D2/D6)**:④ 路径无 tmux pane 句柄,`status` 会明确标注「turn 判定来源 = 名册」(在跑 turn 判定仅供参照),`close` 按窗口 `app-id`(`org.nao.<别名>`)→ pid(校验 `comm=pi`)回收;无 `hyprctl`/`swaymsg`/`xdotool`/`wmctrl` 等 class 探针时,接受启动并打印一行 warn(**无法校验 ≠ 校验失败**)。
|
|
37
35
|
- **模型规则**:用户未指定则**禁止传 `--model`**,也禁止继承 PM 自身模型。
|
|
38
36
|
显式指定时由 `NAO_MODEL_WHITELIST` 在**命令行入口解析阶段**即校验,
|
|
39
37
|
不命中直接 `die`,无部分拉起;PM 只需如实转达用户 `-m` 指定。
|
|
@@ -122,8 +122,4 @@ PM 负责 **Issue 立项与同步、PR 验收与评论、Tag Release、仓库治
|
|
|
122
122
|
|
|
123
123
|
细则(八阶段、分支/PR 规范、离线降级、发布、hotfix):@.agents/skills/github-flow.md(立项 / 终签 / 发布时读取)。
|
|
124
124
|
|
|
125
|
-
## 十四、关键节点主动推送(可选能力)
|
|
126
|
-
|
|
127
|
-
**可选**(装了 `pi-agent-qqbot` 才可用);仅**四类节点**(批次进度汇总 / 验收结论 / 发版或合并完成 / 异常阻塞)**各最多一条**,默认 sandbox,失败**不阻断交付**。用法与纪律 ⇒ 见 @.agents/skills/qq-notify.md。
|
|
128
|
-
|
|
129
125
|
**沟通规范**:中文;先方案后细节;关键决策附理由;交付前跑检查清单(只报未过项)。
|
|
@@ -3,22 +3,31 @@
|
|
|
3
3
|
# nao-fleet.sh — 按角色一键拉起 pi 会话窗口(nao 团队工具箱)
|
|
4
4
|
#
|
|
5
5
|
# 用法
|
|
6
|
-
# nao-fleet.sh check [--strict] [-v] 静态体检:roles.yaml/缩进/EOL/角色卡/交叉引用/PR
|
|
6
|
+
# nao-fleet.sh check [--strict] [-v] 静态体检:roles.yaml/缩进/EOL/角色卡/交叉引用/PR 模板/白名单/布局
|
|
7
7
|
# 默认单行摘要(含 warn 计数);-v 展开完整报告;失败始终展开
|
|
8
8
|
# nao-fleet.sh status 角色会话在线状态(按终端标题/名册判定;权威名单见 intercom list)
|
|
9
9
|
# nao-fleet.sh ensure <别名>[@<repo>] [更多...] 拉起角色窗口(默认工作区=roles.yaml workspace)
|
|
10
10
|
# nao-fleet.sh ensure -m <model> <别名>... 显式指定模型(须命中白名单)
|
|
11
11
|
# nao-fleet.sh ensure --task <编号> <别名>[@<repo>] 任务派生会话:--name <别名>-<编号>(并行隔离,避免同名冲突)
|
|
12
12
|
# nao-fleet.sh ensure --force <别名>... 忽略"已在运行"判重
|
|
13
|
+
# nao-fleet.sh ensure --background <别名>... 无图形会话时显式允许后台 detached(否则 exit 2)
|
|
13
14
|
# nao-fleet.sh close <别名|派生会话名> [--task <编号>] [--force]
|
|
14
15
|
# 回收已完成会话(派生名如 rd-be-T1;跨仓定位;定位失败/名册报在线但无句柄 ⇒ 非 0 + 诊断)
|
|
15
|
-
# (闸门:在跑 turn / tasks-state 未推进 → 拒绝,--force
|
|
16
|
+
# (闸门:在跑 turn / tasks-state 未推进 → 拒绝,--force 跳过;
|
|
17
|
+
# 常驻缺席而其派生在场 ⇒ 目标歧义拒绝,T516 AC3)
|
|
18
|
+
# nao-fleet.sh cleanup -S <socket> 终结实验场 tmux server(**强制**显式 -S socket)
|
|
19
|
+
# 不带 -S / 命中生产默认 socket ⇒ exit 2 + 单行拒绝(真 server 不受影响)
|
|
20
|
+
# ⚠ 生产默认 socket 判定 = $TMUX 句柄 + /tmp/tmux-<uid>/default(+$TMPDIR 变体),
|
|
21
|
+
# **与 TMUX_TMPDIR 无关**(T516 事故:夹具设 TMUX_TMPDIR 曾令真默认 socket 被放行而连坐自灭)
|
|
22
|
+
# ⚠ 实验纪律:一律 `env -u TMUX` + `-S <隔离 socket>`(T515 血训:
|
|
23
|
+
# tmux 客户端在 $TMUX 已设时无视 TMUX_TMPDIR,裸 tmux/kill-server 会连坐真 server);
|
|
24
|
+
# 验证脚本勿在承载自己会话的 tmux 内执行(清 TMUX_PANE 会令 F1 自 pane 闸门失效)
|
|
16
25
|
#
|
|
17
26
|
# 角色别名 → 角色卡:见 .agents/roles.yaml(单一事实来源)
|
|
18
27
|
# 当前:pm / arch-designer(arch) / rd-fe / rd-be / qa / rd-infra(infra)
|
|
19
28
|
#
|
|
20
29
|
# 环境变量
|
|
21
|
-
# NAO_TERMINAL=ghostty|ptyxis|
|
|
30
|
+
# NAO_TERMINAL=tmux|foot|ghostty|ptyxis|screen 强制宿主(非法/命令缺失 ⇒ exit 2)
|
|
22
31
|
# NAO_TMUX_LAYOUT=main-row2|main-col|grid tmux 布局(默认 main-row2)
|
|
23
32
|
# NAO_TMUX_MAIN_WIDTH=<10..90> 主 pane 宽度百分比(默认 35;main-row2/main-col 共用)
|
|
24
33
|
# NAO_TMUX_MIN_PANE_WIDTH=<10..80> 最小非主 pane 列宽守卫(默认 30;低于则回退)
|
|
@@ -38,6 +47,11 @@
|
|
|
38
47
|
# pi-intercom 官方 cli.ts(无 tmuxPane),再不可用回退 tmux/screen 会话名;
|
|
39
48
|
# 仍无法确认「不在线」时 close 不做静默 no-op(exit 非 0)。
|
|
40
49
|
#
|
|
50
|
+
# 宿主阶梯(非 tmux 用户;ADR 2026-10-12-non-tmux-host-spawn)
|
|
51
|
+
# ① NAO_TERMINAL 显式 → ② $TMUX 内分屏 → ③ tmux+图形:tmux 会话 + 可见窗口 attach
|
|
52
|
+
# → ④ 图形无 tmux:xdg-terminal-exec --app-id=org.nao.<角色> --dir=<repo> 直开窗
|
|
53
|
+
# → ⑤ 无图形:仅 --background 时 detached tmux/screen → ⑥ 否则 exit 2 + 一行 DEGRADED 诊断
|
|
54
|
+
#
|
|
41
55
|
# tmux 宿主行为
|
|
42
56
|
# - 已在 tmux 内($TMUX 存在):当前窗口分屏拉起,不新建窗口。
|
|
43
57
|
# - 不在 tmux 内:创建 detached 会话 nao-<角色>,需 tmux attach -t nao-<角色>。
|
|
@@ -78,10 +92,20 @@ VERBOSE=false
|
|
|
78
92
|
CLOSE_BUSY_PATTERN="${NAO_CLOSE_BUSY_PATTERN:-Working \(|Thinking\.\.\.|to interrupt|to cancel|Retrying \(|Compacting|Summarizing branch}"
|
|
79
93
|
# 任务状态文件(相对当前目录;供残留检测与回收闸门)
|
|
80
94
|
TASKS_STATE="${NAO_TASKS_STATE:-docs/tasks-state.md}"
|
|
95
|
+
# T513-S2 F2:窗口/分离宿主派生前清 TMUX/TMUX_PANE —— 防子进程继承调用者的 pane 句柄,
|
|
96
|
+
# 导致 pi-intercom 按调用者 pane 错注册名册、close 反噬调用者(见 ADR 不变量)。
|
|
97
|
+
# ⚠️ 只用于「非 tmux 窗口/screen」宿主:tmux 自身的 split-window/new-session/attach
|
|
98
|
+
# 会为新 pane 注入正确的 TMUX_PANE(实测 -L 隔离 server:外层 TMUX_PANE=%FAKE 被覆盖为 %0),
|
|
99
|
+
# 对这些路径前缀剥离反而会抹掉正确句柄。
|
|
100
|
+
TMUX_ENV_SCRUB="env -u TMUX -u TMUX_PANE"
|
|
81
101
|
|
|
82
102
|
log() { printf '\033[1;32m[fleet]\033[0m %s\n' "$*"; }
|
|
83
103
|
warn() { printf '\033[1;33m[fleet]\033[0m %s\n' "$*" >&2; }
|
|
84
104
|
die() { printf '\033[1;31m[fleet]\033[0m %s\n' "$*" >&2; exit 1; }
|
|
105
|
+
# 环境不足/宿主不可运行统一出口:exit 2,单行 `DEGRADED: <诊断>;恢复:<命令>`(对齐 0.12.0 D5 口径)
|
|
106
|
+
die_env() { printf 'DEGRADED: %s;恢复:%s\n' "$1" "$2" >&2; exit 2; }
|
|
107
|
+
# 宿主存在但动作失败(拉起未成):exit 1(BR3);不清零/不声称已回收(回收结果由各派生函数自报)
|
|
108
|
+
die_spawn() { printf '\033[1;31m[fleet]\033[0m %s\n' "$*" >&2; exit 1; }
|
|
85
109
|
|
|
86
110
|
# ---------------------------------------------------------------------------
|
|
87
111
|
# roles.yaml 解析(严格子集:顶层 roles:,2 空格角色条目 / 4 空格字段,禁 Tab)
|
|
@@ -240,50 +264,6 @@ check_alias_resolution() {
|
|
|
240
264
|
return $rc
|
|
241
265
|
}
|
|
242
266
|
|
|
243
|
-
# qq-notify 主动推送器契约:存在 + 可执行 + EOL 全 LF + 无 Tab + node --check 语法
|
|
244
|
-
check_qq_notify() {
|
|
245
|
-
local rc=0 f="$SKILLS_DIR/.agents/scripts/qq-notify"
|
|
246
|
-
if [[ ! -f "$f" ]]; then
|
|
247
|
-
printf ' ✗ 缺失: .agents/scripts/qq-notify\n'
|
|
248
|
-
return 1
|
|
249
|
-
fi
|
|
250
|
-
printf ' ✓ 存在: .agents/scripts/qq-notify\n'
|
|
251
|
-
if [[ -x "$f" ]]; then
|
|
252
|
-
printf ' ✓ 可执行位已设置\n'
|
|
253
|
-
else
|
|
254
|
-
printf ' ✗ 缺少可执行位(修复:chmod +x .agents/scripts/qq-notify)\n'; rc=1
|
|
255
|
-
fi
|
|
256
|
-
if grep -Iq $'\r' "$f"; then
|
|
257
|
-
printf ' ✗ 含 CR(须转 LF;CRLF 会破坏 shebang/管道)\n'; rc=1
|
|
258
|
-
else
|
|
259
|
-
printf ' ✓ 行尾 LF\n'
|
|
260
|
-
fi
|
|
261
|
-
if grep -Iq $'\t' "$f"; then
|
|
262
|
-
printf ' ✗ 含 Tab(禁止 Tab 缩进)\n'; rc=1
|
|
263
|
-
else
|
|
264
|
-
printf ' ✓ 无 Tab\n'
|
|
265
|
-
fi
|
|
266
|
-
# shell 转交守卫:禁止 `bash/sh <本脚本>` 逐行解释注释(防误执行示例/误发)
|
|
267
|
-
local l1 l2
|
|
268
|
-
l1="$(head -n 1 "$f")"
|
|
269
|
-
l2="$(sed -n '2p' "$f")"
|
|
270
|
-
if [[ "$l1" == '#!/bin/sh' ]] && [[ "$l2" == *'exec node "$0" "$@"'* ]]; then
|
|
271
|
-
printf ' ✓ shell 转交守卫(#!/bin/sh + exec node)\n'
|
|
272
|
-
else
|
|
273
|
-
printf ' ✗ 缺少 shell 转交守卫(第 1 行须 #!/bin/sh、第 2 行须含 exec node "$0" "$@")\n'; rc=1
|
|
274
|
-
fi
|
|
275
|
-
if command -v node >/dev/null 2>&1; then
|
|
276
|
-
if node --check "$f" >/dev/null 2>&1; then
|
|
277
|
-
printf ' ✓ node --check 语法通过\n'
|
|
278
|
-
else
|
|
279
|
-
printf ' ✗ node --check 语法失败\n'; rc=1
|
|
280
|
-
fi
|
|
281
|
-
else
|
|
282
|
-
printf ' ! node 未安装(跳过语法校验)\n'
|
|
283
|
-
fi
|
|
284
|
-
return $rc
|
|
285
|
-
}
|
|
286
|
-
|
|
287
267
|
# CodeGraph 索引健康(ensure 拉起前 / check 用;缺失或过期仅 warn,不阻塞)
|
|
288
268
|
check_codegraph() {
|
|
289
269
|
local repo="$1" name st
|
|
@@ -313,13 +293,21 @@ resolve_role() {
|
|
|
313
293
|
PROMPT="${ROLE_CARDS[$id]}"
|
|
314
294
|
}
|
|
315
295
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
296
|
+
# 宿主阶梯(ADR 实施闸门 · T513b/c)——由「枚举模拟器」改为「环境查询 + 显式覆盖」:
|
|
297
|
+
# ① NAO_TERMINAL 显式(档值 ∈ {tmux,foot,ghostty,ptyxis,screen};命令缺失/非法 ⇒ exit 2)
|
|
298
|
+
# ② $TMUX 存在 ⇒ 当前窗口 split-window(逐值零回归)
|
|
299
|
+
# ③ 非 tmux + tmux 可用 + 图形 ⇒ new-session -d + xdg-terminal-exec 开窗 attach(默认 socket)
|
|
300
|
+
# ④ 无 tmux + 图形 ⇒ xdg-terminal-exec --app-id=org.nao.<角色> --dir=<repo> 直开窗
|
|
301
|
+
# ⑤ 无图形 + 显式 --background ⇒ detached tmux/screen
|
|
302
|
+
# ⑥ 否则 exit 2 + 单行 DEGRADED 诊断 + 可复制恢复命令
|
|
303
|
+
NAO_TERMINAL_ALLOWED=(tmux foot ghostty ptyxis screen)
|
|
304
|
+
NAO_TERMINAL_LIST='tmux|foot|ghostty|ptyxis|screen' # 文案用(与 ALLOWED 同步)
|
|
305
|
+
|
|
306
|
+
# 图形会话探针(ADR 门禁):有显示变量且 `xdg-terminal-exec --print-id` 成功
|
|
307
|
+
xdg_launcher_ok() {
|
|
308
|
+
command -v xdg-terminal-exec >/dev/null 2>&1 || return 1
|
|
309
|
+
[[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || return 1
|
|
310
|
+
xdg-terminal-exec --print-id >/dev/null 2>&1
|
|
323
311
|
}
|
|
324
312
|
|
|
325
313
|
# ---------------------------------------------------------------------------
|
|
@@ -481,8 +469,12 @@ pi_panes() {
|
|
|
481
469
|
| awk -F'\t' '{ c=$2; sub(/^.*\//,"",c); if (c=="pi") print $1 "\t" $3 }' || true
|
|
482
470
|
}
|
|
483
471
|
|
|
484
|
-
# 兜底:本仓 pi pane
|
|
485
|
-
#
|
|
472
|
+
# 兜底:本仓 pi pane 中「未被其它会话认领」且**跨全部角色唯一**者。
|
|
473
|
+
# ⚠ 层③ 误配根因(T513f):新 pane 尚未被标题/名册认领时会被误判给别的角色
|
|
474
|
+
# (实测 ensure qa rd-infra ⇒ rd-infra 被 qa 的新 pane 命中而误判「已在运行」跳过)。
|
|
475
|
+
# 收紧(不猜):① 本仓未认领 pi pane 必须**跨全部角色唯一**(>1 ⇒ warn + 不返回);
|
|
476
|
+
# ② 还须叠加「名册 cwd 匹配」(目标会话确实登记在本仓)才可返回。
|
|
477
|
+
# 层①标题契约 / 层②名册 tmuxPane 语义不变(T512-① 属独立课题)。
|
|
486
478
|
fallback_pane_for() {
|
|
487
479
|
local name="$1" repo="$2" abs="" cp j v
|
|
488
480
|
command -v tmux >/dev/null 2>&1 || return 0
|
|
@@ -504,13 +496,15 @@ fallback_pane_for() {
|
|
|
504
496
|
[[ -n "${claimed[$pid]:-}" ]] && continue
|
|
505
497
|
cand+=("$pid")
|
|
506
498
|
done < <(pi_panes)
|
|
507
|
-
|
|
508
|
-
printf '%s\n' "${cand[0]}"; return 0
|
|
509
|
-
fi
|
|
499
|
+
# ① 跨全部角色唯一(歧义不猜)
|
|
510
500
|
if (( ${#cand[@]} > 1 )); then
|
|
511
|
-
warn "标题被改写且名册无 pane 记录:$abs 内有多个未认领 pi pane(${cand[*]})→ 无法唯一确定 $name"
|
|
501
|
+
warn "标题被改写且名册无 pane 记录:$abs 内有多个未认领 pi pane(${cand[*]})→ 无法唯一确定 $name(不认领)"
|
|
502
|
+
return 0
|
|
512
503
|
fi
|
|
513
|
-
return 0
|
|
504
|
+
(( ${#cand[@]} == 0 )) && return 0
|
|
505
|
+
# ② 名册 cwd 匹配(目标会话须确实登记在本仓),否则绝不认领本仓 pane
|
|
506
|
+
intercom_online "$name" "$abs" || return 0
|
|
507
|
+
printf '%s\n' "${cand[0]}"; return 0
|
|
514
508
|
}
|
|
515
509
|
|
|
516
510
|
find_pane_for() {
|
|
@@ -543,6 +537,8 @@ pi_pids_for() {
|
|
|
543
537
|
local pane root pid
|
|
544
538
|
pane="$(find_pane_for "$1" "${2:-}")"
|
|
545
539
|
[[ -n "$pane" ]] || return 0
|
|
540
|
+
# F1 纵深(T513-S2):绝不据「调用者自己的 pane」反查 pid,否则会 kill 到自身进程。
|
|
541
|
+
[[ -n "${TMUX_PANE:-}" && "$pane" == "$TMUX_PANE" ]] && return 0
|
|
546
542
|
root="$(tmux display-message -p -t "$pane" '#{pane_pid}' 2>/dev/null)"
|
|
547
543
|
[[ -n "$root" ]] || return 0
|
|
548
544
|
{ printf '%s\n' "$root"; pgrep -P "$root" 2>/dev/null || true; } | while IFS= read -r pid; do
|
|
@@ -551,12 +547,86 @@ pi_pids_for() {
|
|
|
551
547
|
done
|
|
552
548
|
}
|
|
553
549
|
|
|
550
|
+
# ---- ④ 路径句柄(app-id/class;BR7/AC8)-------------------------------------
|
|
551
|
+
# 窗口 client pid:Hyprland `hyprctl clients -j` 主路;sway 树后备(仅存在性探测;NFR4/NFR5)
|
|
552
|
+
app_client_pid() { # <name> → echo 窗口进程 pid(无则空)
|
|
553
|
+
local name="$1" cls="org.nao.$name" json=""
|
|
554
|
+
if command -v hyprctl >/dev/null 2>&1; then
|
|
555
|
+
json="$(hyprctl clients -j 2>/dev/null || true)"
|
|
556
|
+
elif command -v swaymsg >/dev/null 2>&1; then
|
|
557
|
+
json="$(swaymsg -t get_tree 2>/dev/null || true)"
|
|
558
|
+
fi
|
|
559
|
+
[[ -n "$json" ]] || return 0
|
|
560
|
+
printf '%s' "$json" | node -e '
|
|
561
|
+
let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
|
|
562
|
+
try{const a=JSON.parse(s);const arr=Array.isArray(a)?a:[a];
|
|
563
|
+
const walk=x=>{for(const n of (x||[])){const c=n.class||n.initialClass||n.app_id;
|
|
564
|
+
if(c===process.argv[1]&&n.pid!=null){process.stdout.write(String(n.pid));return;}
|
|
565
|
+
walk(n.nodes);walk(n.floating_nodes);}};
|
|
566
|
+
walk(arr);}catch(e){}
|
|
567
|
+
});' "$cls" 2>/dev/null || true
|
|
568
|
+
return 0
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
# 深度优先找 comm=pi 的后代(窗口进程往往是终端,pi 在其下)
|
|
572
|
+
pi_descendant_of() { # <root pid> → echo 首个 pi 后代 pid
|
|
573
|
+
local root="$1" c r
|
|
574
|
+
for c in $(pgrep -P "$root" 2>/dev/null); do
|
|
575
|
+
[[ "$(ps -o comm= -p "$c" 2>/dev/null | tr -d ' ')" == "pi" ]] && { printf '%s\n' "$c"; return 0; }
|
|
576
|
+
r="$(pi_descendant_of "$c")"
|
|
577
|
+
[[ -n "$r" ]] && { printf '%s\n' "$r"; return 0; }
|
|
578
|
+
done
|
|
579
|
+
return 0
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
# app-id/class 句柄 → 校验 comm=pi 后的 pi pid(BR7:取 pid 后校验 comm=pi)
|
|
583
|
+
app_pid_for() { # <name> → echo pi pid(无句柄/不可校验则空)
|
|
584
|
+
local name="$1" pid d
|
|
585
|
+
pid="$(app_client_pid "$name")"
|
|
586
|
+
[[ -n "$pid" ]] || return 0
|
|
587
|
+
if [[ "$(ps -o comm= -p "$pid" 2>/dev/null | tr -d ' ')" == "pi" ]]; then printf '%s\n' "$pid"; return 0; fi
|
|
588
|
+
d="$(pi_descendant_of "$pid")"
|
|
589
|
+
[[ -n "$d" ]] && printf '%s\n' "$d"
|
|
590
|
+
return 0
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
# ④ 路径 class 探针存在性由 app_class_visible() 返回码 2 表达(无独立 helper,避免死代码)
|
|
594
|
+
|
|
595
|
+
# 派生会话在场检测(T516 AC3):visible_session_names 中形如 <name>-<非空后缀> 者。
|
|
596
|
+
# 用于 close <常驻名> 时判目标歧义:常驻精确句柄缺席而其派生在场 ⇒ 拒绝 + 指路(禁静默谎报「未运行」)。
|
|
597
|
+
derived_in_field() { # <name> → 派生名列表(每行一个;空 = 无)
|
|
598
|
+
local n
|
|
599
|
+
while IFS= read -r n; do
|
|
600
|
+
[[ "$n" == "$1"-?* ]] && printf '%s\n' "$n"
|
|
601
|
+
done < <(visible_session_names)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
# screen 会话精确令牌(T516 F1/F2 · arch 评审):从 `screen -ls` 取**精确** nao-<名> 会话的 `pid.name` 令牌。
|
|
605
|
+
# 与 running()/close 检测同一锚定(名字后必须是空白或行尾;`\b` 在派生名的 "-" 处仍成立,故不用)。
|
|
606
|
+
# 检测与销毁共用本 helper ⇒ 检测锚与销毁目标不会漂移;**取不到令牌即空,调用方不得 quit**(宁不回收,也不误伤派生)。
|
|
607
|
+
screen_token_for() { # <name> → echo pid.name(无精确匹配则空;恒 rc 0)
|
|
608
|
+
local name="$1"
|
|
609
|
+
command -v screen >/dev/null 2>&1 || return 0
|
|
610
|
+
# 左锚:`[0-9]+\.nao-` 必须紧接 pid 点(弃 `[^[:space:]]*` —— 旧式允许 `pid.Xnao-<名>` 型非目标会话被当精确令牌,
|
|
611
|
+
# QA 波4 (e) 红:误杀非目标 + 幻影成功 + 误报在线)。右锚 `([[:space:]]|$)` 同时排除派生 `nao-<名>-<任务>`。
|
|
612
|
+
screen -ls 2>/dev/null \
|
|
613
|
+
| grep -oE '[0-9]+\.nao-'"$name"'([[:space:]]|$)' \
|
|
614
|
+
| sed -E 's/[[:space:]]+$//' \
|
|
615
|
+
| head -1 || true
|
|
616
|
+
return 0
|
|
617
|
+
}
|
|
618
|
+
screen_present() { # <name> → 0 = 精确 screen 会话在场
|
|
619
|
+
[[ -n "$(screen_token_for "$1")" ]]
|
|
620
|
+
}
|
|
621
|
+
|
|
554
622
|
running() {
|
|
555
623
|
local name="$1" repo="${2:-}"
|
|
556
624
|
if intercom_online "$name" "$repo"; then return 0; fi
|
|
557
625
|
if [[ -n "$(find_pane_for "$name" "$repo")" ]]; then return 0; fi
|
|
558
|
-
if command -v tmux >/dev/null 2>&1 && tmux has-session -t "nao-$name" 2>/dev/null; then return 0; fi
|
|
559
|
-
|
|
626
|
+
if command -v tmux >/dev/null 2>&1 && tmux has-session -t "=nao-$name" 2>/dev/null; then return 0; fi
|
|
627
|
+
# T516 #1(=锚):tmux 会话名唯一前缀匹配曾误命中派生 nao-<名>-<任务>(T515 V4 实证)⇒ 一律 =nao-<名>;
|
|
628
|
+
# screen 同理:锚定收敛到 screen_present()(同一 helper 服务检测与销毁,见 screen_token_for)。
|
|
629
|
+
if command -v screen >/dev/null 2>&1 && screen_present "$name"; then return 0; fi
|
|
560
630
|
return 1
|
|
561
631
|
}
|
|
562
632
|
|
|
@@ -802,7 +872,9 @@ apply_tmux_layout() {
|
|
|
802
872
|
fi
|
|
803
873
|
# main-row2
|
|
804
874
|
local win_w win_h
|
|
805
|
-
|
|
875
|
+
# D7(T513-S3):display-message 无输出时 `read` 返回 1,set -e 会硬中断整轮 ensure
|
|
876
|
+
# ⇒ 守卫为 true(读不到尺寸即跳过布局,不硬崩)。
|
|
877
|
+
read -r win_w win_h < <(tmux display-message -p '#{window_width} #{window_height}' 2>/dev/null) || true
|
|
806
878
|
[[ -n "$win_w" && -n "$win_h" ]] || return 0
|
|
807
879
|
|
|
808
880
|
local -a pane_ids=()
|
|
@@ -883,27 +955,262 @@ build_system_prompt() {
|
|
|
883
955
|
echo "$f"
|
|
884
956
|
}
|
|
885
957
|
|
|
958
|
+
# ---- 派生 pid 传递(T513-S5 · M2 设计修复)--------------------------------
|
|
959
|
+
# 父进程建「匿名管道」(在 mktemp -d 内 mkfifo,打开 fd 后立即 rm -rf 该目录 ⇒ 仅 fd 存活)
|
|
960
|
+
# → 子进程经**继承 fd** 写自身 pid → 父进程读。子进程**不打开任何路径**,因此
|
|
961
|
+
# 「父先清理、子后写重建」的竞态与 /tmp 残留从设计上不可能(无文件系统对象可残留);
|
|
962
|
+
# 失败时父仍能拿到 pid ⇒ M1 回收能力保留。
|
|
963
|
+
# ⚠ 管道以 O_RDWR 打开(exec {fd}<>):父读端提前关闭时,子进程写不会 SIGPIPE(实测)。
|
|
964
|
+
# ⚠ 实测 foot 等终端保留继承 fd,故 ④ 路径的 M1 仍可拿到 pid。
|
|
965
|
+
PID_PIPE_FD=""
|
|
966
|
+
open_pid_pipe() {
|
|
967
|
+
local d
|
|
968
|
+
d="$(mktemp -d "/tmp/nao-fleet-pidpipe-XXXXXX")" || return 1
|
|
969
|
+
mkfifo "$d/p" 2>/dev/null || { rm -rf -- "$d"; return 1; }
|
|
970
|
+
# 不得在 exec 的 fd 分配上加 `2>/dev/null`:exec 重定向会**持久**作用于当前 shell(会静默后续所有 warn/xtrace)。
|
|
971
|
+
if ! exec {PID_PIPE_FD}<>"$d/p"; then rm -rf -- "$d"; return 1; fi
|
|
972
|
+
rm -rf -- "$d" # 立即 unlink:路径消失,仅 fd 存活
|
|
973
|
+
return 0
|
|
974
|
+
}
|
|
975
|
+
close_pid_pipe() {
|
|
976
|
+
if [[ -n "${PID_PIPE_FD:-}" ]]; then exec {PID_PIPE_FD}>&- || true; fi
|
|
977
|
+
PID_PIPE_FD=""
|
|
978
|
+
return 0
|
|
979
|
+
}
|
|
980
|
+
# 读子进程 pid(有界轮询;echo pid / 超时 rc=1)
|
|
981
|
+
wait_for_pid_pipe() { # [tries]
|
|
982
|
+
local tries="${1:-30}" pid=""
|
|
983
|
+
[[ -n "${PID_PIPE_FD:-}" ]] || return 1
|
|
984
|
+
while (( tries-- > 0 )); do
|
|
985
|
+
if IFS= read -r -t 0.1 -u "$PID_PIPE_FD" pid 2>/dev/null && [[ -n "$pid" ]]; then
|
|
986
|
+
printf '%s' "$pid"; return 0
|
|
987
|
+
fi
|
|
988
|
+
done
|
|
989
|
+
return 1
|
|
990
|
+
}
|
|
991
|
+
# 子进程包装命令:把自身 $$ 写入继承 fd(写成后 exec 目标 ⇒ pid 不变,即最终 pi/宿主 pid)
|
|
992
|
+
pid_pipe_wrapper() { printf 'echo $$ >&%s; exec "$@"' "$PID_PIPE_FD"; }
|
|
993
|
+
|
|
994
|
+
# 非 tmux 窗口宿主(ghostty/ptyxis/foot)派生:P4 根因 = `setsid -f <缺失命令>` 仍 rc=0,
|
|
995
|
+
# 故不能以 setsid 的 rc 当成功 —— 必须验证子进程 pid 存活(BR2)。
|
|
996
|
+
# pid 经继承管道回传(M2:无 pidfile ⇒ 无残留);失败即回收 pid(不产生孤儿窗口,AC6)。
|
|
997
|
+
spawn_fork_window() { # <name> <cmd...>
|
|
998
|
+
local name="$1"; shift
|
|
999
|
+
local pid=""
|
|
1000
|
+
open_pid_pipe || return 1
|
|
1001
|
+
if ! setsid -f bash -c "$(pid_pipe_wrapper)" _ "$@" >/dev/null 2>&1; then
|
|
1002
|
+
close_pid_pipe; return 1
|
|
1003
|
+
fi
|
|
1004
|
+
pid="$(wait_for_pid_pipe)" || pid=""
|
|
1005
|
+
close_pid_pipe
|
|
1006
|
+
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then return 0; fi
|
|
1007
|
+
[[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
|
|
1008
|
+
return 1
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
# screen 宿主派生:以「会话存在」为「已拉起」前置;未出现即回收会话(AC6)。
|
|
1012
|
+
spawn_screen() { # <name> <inner>
|
|
1013
|
+
local name="$1" inner="$2" tries=30 stok
|
|
1014
|
+
screen -dmS "nao-$name" $TMUX_ENV_SCRUB bash -lc "$inner" >/dev/null 2>&1 || return 1
|
|
1015
|
+
while (( tries-- > 0 )); do
|
|
1016
|
+
if screen_present "$name"; then return 0; fi
|
|
1017
|
+
sleep 0.1
|
|
1018
|
+
done
|
|
1019
|
+
# F1(arch 评审):失败清理**只 quit 锚定令牌**指向的会话;取不到令牌即不 quit(不对 nao-<名> 前缀下手)。
|
|
1020
|
+
stok="$(screen_token_for "$name")"
|
|
1021
|
+
[[ -n "$stok" ]] && screen -S "$stok" -X quit >/dev/null 2>&1 || true
|
|
1022
|
+
return 1
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
# ---- 阶梯③/④ 原子件:可见窗口 + 限时句柄校验 -----------------------------
|
|
1026
|
+
# tmux 会话是否有客户端 attach(ADR:会话在、窗口无 ⇒ 降级④;防隐形会话)
|
|
1027
|
+
wait_for_tmux_client() { # <sess> [tries]
|
|
1028
|
+
local sess="$1" tries="${2:-50}" out
|
|
1029
|
+
while (( tries-- > 0 )); do
|
|
1030
|
+
out="$(tmux list-clients -t "=$sess" 2>/dev/null || true)"
|
|
1031
|
+
[[ -n "$out" ]] && return 0
|
|
1032
|
+
sleep 0.1
|
|
1033
|
+
done
|
|
1034
|
+
return 1
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
# 开可见窗口(只启动、不校验):xdg-terminal-exec;--app-id 含角色名,--dir 为 repo
|
|
1038
|
+
# 默认 socket(无 -L);setsid -f 的 rc 不作成功依据,由调用方做句柄前置校验(BR2)。
|
|
1039
|
+
open_xdg_window() { # <name> <repo> <cmd...>
|
|
1040
|
+
local name="$1" repo="$2"; shift 2
|
|
1041
|
+
command -v xdg-terminal-exec >/dev/null 2>&1 || return 1
|
|
1042
|
+
setsid -f xdg-terminal-exec --app-id="org.nao.$name" --dir="$repo" -- "$@" >/dev/null 2>&1 || return 1
|
|
1043
|
+
return 0
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
# 窗口 class 可见性(④ 句柄):Hyprland 主路;其它合成器/X11 仅存在性探测(NFR4;覆盖限制 NFR5)
|
|
1047
|
+
# 返回 0=可见 / 1=不可见 / 2=无查询器(不可校验)
|
|
1048
|
+
app_class_visible() { # <name>
|
|
1049
|
+
local name="$1" cls="org.nao.$name" out
|
|
1050
|
+
if command -v hyprctl >/dev/null 2>&1; then
|
|
1051
|
+
out="$(hyprctl clients -j 2>/dev/null || true)"
|
|
1052
|
+
[[ "$out" == *"$cls"* ]] && return 0
|
|
1053
|
+
return 1
|
|
1054
|
+
fi
|
|
1055
|
+
if command -v swaymsg >/dev/null 2>&1; then
|
|
1056
|
+
out="$(swaymsg -t get_tree 2>/dev/null || true)"
|
|
1057
|
+
[[ "$out" == *"$cls"* ]] && return 0
|
|
1058
|
+
return 1
|
|
1059
|
+
fi
|
|
1060
|
+
if command -v xdotool >/dev/null 2>&1; then
|
|
1061
|
+
xdotool search --class "$cls" >/dev/null 2>&1 && return 0
|
|
1062
|
+
return 1
|
|
1063
|
+
fi
|
|
1064
|
+
if command -v wmctrl >/dev/null 2>&1; then
|
|
1065
|
+
out="$(wmctrl -lx 2>/dev/null || true)"
|
|
1066
|
+
[[ "$out" == *"$cls"* ]] && return 0
|
|
1067
|
+
return 1
|
|
1068
|
+
fi
|
|
1069
|
+
return 2
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
wait_for_app_class() { # <name> [tries] → 0 可见 / 1 未出现 / 2 无查询器
|
|
1073
|
+
local name="$1" tries="${2:-50}" rc
|
|
1074
|
+
while (( tries-- > 0 )); do
|
|
1075
|
+
rc=0; app_class_visible "$name" || rc=$?
|
|
1076
|
+
(( rc == 0 )) && return 0
|
|
1077
|
+
(( rc == 2 )) && return 2
|
|
1078
|
+
sleep 0.1
|
|
1079
|
+
done
|
|
1080
|
+
return 1
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
# ④ 直开窗:启动 + 限时 class 出现校验;失败尽力回收(M1:不假称已清理)
|
|
1084
|
+
# pid 经继承管道回传(wrapper exec 后即 pi)⇒ 终端单实例/daemon 化形态下仍能可靠定位 pi,
|
|
1085
|
+
# 不依赖窗口生命周期;无 pidfile ⇒ 父先返回也不会被「子进程晚写」残留(M2 设计修复)。
|
|
1086
|
+
spawn_xdg_window() { # <name> <repo> <cmd...>
|
|
1087
|
+
local name="$1" repo="$2"; shift 2
|
|
1088
|
+
local pid="" vrc=0
|
|
1089
|
+
open_pid_pipe || return 1
|
|
1090
|
+
if ! open_xdg_window "$name" "$repo" bash -c "$(pid_pipe_wrapper)" _ "$@"; then
|
|
1091
|
+
close_pid_pipe; return 1
|
|
1092
|
+
fi
|
|
1093
|
+
wait_for_app_class "$name" || vrc=$?
|
|
1094
|
+
# 成功 / D6 接受:无需 pid,直接关管道(子进程 O_RDWR 写不会 SIGPIPE;无路径 ⇒ 无残留)
|
|
1095
|
+
if (( vrc == 0 )); then close_pid_pipe; return 0; fi
|
|
1096
|
+
if (( vrc == 2 )); then
|
|
1097
|
+
warn "无 class 探针(hyprctl/swaymsg/xdotool/wmctrl 均缺):未能校验 $name 窗口可见性(D6)"
|
|
1098
|
+
close_pid_pipe; return 0
|
|
1099
|
+
fi
|
|
1100
|
+
# 有探针但限时未出现 class ⇒ 尽力回收 pi,如实报告回收结果(M1)
|
|
1101
|
+
pid="$(wait_for_pid_pipe 10)" || pid=""
|
|
1102
|
+
close_pid_pipe
|
|
1103
|
+
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
|
1104
|
+
kill "$pid" 2>/dev/null || true
|
|
1105
|
+
warn "④ 校验失败:已尽力回收 $name 的 pi 进程(pid=$pid);若窗口仍在请手动关闭"
|
|
1106
|
+
else
|
|
1107
|
+
warn "④ 校验失败:未捕获 $name 的 pi 进程 pid,已开窗口可能仍在,请手动关闭"
|
|
1108
|
+
fi
|
|
1109
|
+
return 1
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
# ③ tmux 会话 + 可见窗口 attach:new-session -d 后开窗 attach,限时校验 list-clients;
|
|
1113
|
+
# 无客户端 ⇒ kill-session 并返回非 0(调用方降级④;用户裁量①:降级而非 exit 1)。
|
|
1114
|
+
spawn_tmux_attach() { # <name> <repo> <inner>
|
|
1115
|
+
local name="$1" repo="$2" inner="$3" sess="nao-$name"
|
|
1116
|
+
tmux new-session -d -s "$sess" "bash -lc $(printf %q "$inner")" >/dev/null 2>&1 || return 1
|
|
1117
|
+
if ! open_xdg_window "$name" "$repo" tmux attach -t "$sess"; then
|
|
1118
|
+
tmux kill-session -t "=$sess" >/dev/null 2>&1 || true
|
|
1119
|
+
return 1
|
|
1120
|
+
fi
|
|
1121
|
+
if wait_for_tmux_client "$sess"; then
|
|
1122
|
+
log "tmux 会话 $sess 已创建 + 可见窗口 attach(默认 socket;pane 句柄完整)"
|
|
1123
|
+
return 0
|
|
1124
|
+
fi
|
|
1125
|
+
tmux kill-session -t "=$sess" >/dev/null 2>&1 || true
|
|
1126
|
+
return 1
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
# 是否有 tmux pane 句柄(L6:status 降级标注按「④ 路径」而非「tmux 命令缺失」判断)
|
|
1130
|
+
# in tmux|NAO_TERMINAL=tmux|未显式指定且本机有 tmux(将走 ②/③)⇒ 有 pane 句柄
|
|
1131
|
+
has_tmux_handle() {
|
|
1132
|
+
[[ -n "${TMUX:-}" ]] && return 0
|
|
1133
|
+
[[ "${NAO_TERMINAL:-}" == tmux ]] && return 0
|
|
1134
|
+
[[ -z "${NAO_TERMINAL:-}" ]] && command -v tmux >/dev/null 2>&1 && return 0
|
|
1135
|
+
return 1
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
# 宿主阶梯判定(ADR 实施闸门):echo 动作标签;rc 11=档值非法 / 12=命令缺失 / 1=无可用宿主
|
|
1139
|
+
resolve_action() {
|
|
1140
|
+
if [[ -n "${NAO_TERMINAL:-}" ]]; then
|
|
1141
|
+
local h ok=0
|
|
1142
|
+
for h in "${NAO_TERMINAL_ALLOWED[@]}"; do [[ "$h" == "$NAO_TERMINAL" ]] && { ok=1; break; }; done
|
|
1143
|
+
(( ok )) || return 11
|
|
1144
|
+
command -v "$NAO_TERMINAL" >/dev/null 2>&1 || return 12
|
|
1145
|
+
printf 'nao:%s\n' "$NAO_TERMINAL"; return 0
|
|
1146
|
+
fi
|
|
1147
|
+
if [[ -n "${TMUX:-}" ]]; then
|
|
1148
|
+
if command -v tmux >/dev/null 2>&1; then printf 'tmux-in\n'; return 0; fi
|
|
1149
|
+
# L1(PM 裁定):$TMUX 已设但 tmux 命令缺失 ⇒ warn + 继续(不因升级中途变量残留卡死)
|
|
1150
|
+
warn "TMUX 已设置但 tmux 命令缺失:忽略 tmux 句柄,按环境继续选择宿主"
|
|
1151
|
+
fi
|
|
1152
|
+
if command -v tmux >/dev/null 2>&1 && xdg_launcher_ok; then printf 'tmux-attach\n'; return 0; fi
|
|
1153
|
+
if xdg_launcher_ok; then printf 'xdg-window\n'; return 0; fi
|
|
1154
|
+
if [[ "${BACKGROUND:-false}" == true ]]; then
|
|
1155
|
+
if command -v tmux >/dev/null 2>&1; then printf 'tmux-bg\n'; return 0; fi
|
|
1156
|
+
if command -v screen >/dev/null 2>&1; then printf 'screen-bg\n'; return 0; fi
|
|
1157
|
+
fi
|
|
1158
|
+
return 1
|
|
1159
|
+
}
|
|
1160
|
+
|
|
886
1161
|
spawn_one() {
|
|
887
|
-
local name="$1" repo="$2" model="$3" card prompt_file
|
|
1162
|
+
local name="$1" repo="$2" model="$3" card prompt_file inner action rrc=0 spawn_rc=0 htag
|
|
888
1163
|
card="$PROMPTS_DIR/$PROMPT"
|
|
889
1164
|
[[ -f "$card" ]] || die "角色卡不存在: $card"
|
|
890
1165
|
[[ -d "$repo" ]] || die "工作区不存在: $repo"
|
|
891
1166
|
|
|
1167
|
+
# 宿主阶梯判定先于任何资源创建:环境不足 ⇒ exit 2,失败路径零残留(BR3/AC3/AC4/AC6)
|
|
1168
|
+
action="$(resolve_action)" || rrc=$?
|
|
1169
|
+
case "$rrc" in
|
|
1170
|
+
0) ;;
|
|
1171
|
+
11) die_env "不支持的宿主:NAO_TERMINAL=$NAO_TERMINAL(可选 ${NAO_TERMINAL_ALLOWED[*]})" \
|
|
1172
|
+
"改用受支持宿主:\`NAO_TERMINAL=<$NAO_TERMINAL_LIST> nao-fleet.sh ensure $name\`" ;;
|
|
1173
|
+
12) die_env "宿主不可用:NAO_TERMINAL=$NAO_TERMINAL 未找到命令 $NAO_TERMINAL" \
|
|
1174
|
+
"先用 \`command -v $NAO_TERMINAL\` 确认安装,或改用已安装宿主(如 \`NAO_TERMINAL=tmux nao-fleet.sh ensure $name\`)" ;;
|
|
1175
|
+
*) die_env "无可用宿主:无 tmux / 无图形窗口宿主(xdg-terminal-exec)且未显式 --background" \
|
|
1176
|
+
"安装或指定已安装宿主:\`NAO_TERMINAL=<$NAO_TERMINAL_LIST> nao-fleet.sh ensure $name\`;无图形会话时用 \`nao-fleet.sh ensure --background $name\`" ;;
|
|
1177
|
+
esac
|
|
1178
|
+
|
|
892
1179
|
prompt_file="$(build_system_prompt "$card")"
|
|
893
1180
|
inner="cd $(printf %q "$repo") && exec pi --name $(printf %q "$name")"
|
|
894
1181
|
[[ -n "$model" ]] && inner+=" --model $(printf %q "$model")"
|
|
895
1182
|
inner+=" --append-system-prompt $(printf %q "$prompt_file")"
|
|
896
1183
|
( sleep 60; rm -f -- "$prompt_file" ) & disown 2>/dev/null || true
|
|
897
1184
|
|
|
898
|
-
|
|
899
|
-
case "$
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1185
|
+
# 拉起 + 句柄前置校验;失败不打印「已拉起」,清理本轮资源后 exit 1(BR2/BR3/AC6)
|
|
1186
|
+
case "$action" in
|
|
1187
|
+
tmux-in|nao:tmux|tmux-bg) spawn_tmux "$name" "$inner" ;;
|
|
1188
|
+
tmux-attach)
|
|
1189
|
+
if ! spawn_tmux_attach "$name" "$repo" "$inner"; then
|
|
1190
|
+
warn "tmux 会话 attach 限时内无客户端 → 降级为 xdg-terminal-exec 直开窗(阶梯④;用户裁量①)"
|
|
1191
|
+
spawn_xdg_window "$name" "$repo" $TMUX_ENV_SCRUB bash -lc "$inner" || spawn_rc=1
|
|
1192
|
+
fi ;;
|
|
1193
|
+
xdg-window) spawn_xdg_window "$name" "$repo" $TMUX_ENV_SCRUB bash -lc "$inner" || spawn_rc=1 ;;
|
|
1194
|
+
screen-bg|nao:screen) spawn_screen "$name" "$inner" || spawn_rc=1 ;;
|
|
1195
|
+
nao:foot) spawn_fork_window "$name" $TMUX_ENV_SCRUB foot --app-id="org.nao.$name" --working-directory="$repo" -e bash -lc "$inner" || spawn_rc=1 ;;
|
|
1196
|
+
nao:ghostty) spawn_fork_window "$name" $TMUX_ENV_SCRUB ghostty -e bash -lc "$inner" || spawn_rc=1 ;;
|
|
1197
|
+
nao:ptyxis) spawn_fork_window "$name" $TMUX_ENV_SCRUB ptyxis -- bash -lc "$inner" || spawn_rc=1 ;;
|
|
1198
|
+
*) spawn_rc=1 # 理论上不可达(resolve_action 标签全覆盖);防御性不假成功
|
|
1199
|
+
esac
|
|
1200
|
+
if (( spawn_rc != 0 )); then
|
|
1201
|
+
rm -f -- "$prompt_file" 2>/dev/null || true
|
|
1202
|
+
die_spawn "宿主 $action 未能拉起 $name(动作失败,exit 1;回收结果见上方提示)"
|
|
1203
|
+
fi
|
|
1204
|
+
case "$action" in
|
|
1205
|
+
tmux-in|nao:tmux) htag="tmux" ;;
|
|
1206
|
+
tmux-attach) htag="tmux+window" ;;
|
|
1207
|
+
xdg-window) htag="xdg-terminal-exec" ;;
|
|
1208
|
+
tmux-bg) htag="tmux(detached)" ;;
|
|
1209
|
+
screen-bg|nao:screen) htag="screen" ;;
|
|
1210
|
+
nao:*) htag="${action#nao:}" ;;
|
|
1211
|
+
*) htag="$action" ;;
|
|
905
1212
|
esac
|
|
906
|
-
log "[$
|
|
1213
|
+
log "[$htag] 已拉起 $name @ $repo${model:+(model=$model)}$([[ "$action" == tmux-in || "$action" == nao:tmux ]] && echo "(layout=$TMUX_LAYOUT)")"
|
|
907
1214
|
}
|
|
908
1215
|
|
|
909
1216
|
# ---------------------------------------------------------------------------
|
|
@@ -930,9 +1237,6 @@ cmd_check() {
|
|
|
930
1237
|
fi
|
|
931
1238
|
check_eol || rc=1
|
|
932
1239
|
|
|
933
|
-
echo "== 主动推送器(qq-notify,可选能力)=="
|
|
934
|
-
check_qq_notify || rc=1
|
|
935
|
-
|
|
936
1240
|
echo "== 角色清单 roles.yaml =="
|
|
937
1241
|
load_manifest
|
|
938
1242
|
printf ' ✓ 解析成功,%d 个角色: %s\n' "${#ROLE_ORDER[@]}" "${ROLE_ORDER[*]}"
|
|
@@ -1081,11 +1385,37 @@ cmd_check() {
|
|
|
1081
1385
|
done
|
|
1082
1386
|
fi
|
|
1083
1387
|
|
|
1388
|
+
echo "== 宿主阶梯(BR1;非 tmux 用户)=="
|
|
1389
|
+
echo " ① NAO_TERMINAL → ② tmux 内分屏 → ③ tmux 会话+可见窗口 attach → ④ xdg-terminal-exec 直开窗 → ⑤ --background → ⑥ exit 2"
|
|
1390
|
+
if [[ -n "${NAO_TERMINAL:-}" ]]; then
|
|
1391
|
+
local okh=0 h
|
|
1392
|
+
for h in "${NAO_TERMINAL_ALLOWED[@]}"; do [[ "$h" == "$NAO_TERMINAL" ]] && { okh=1; break; }; done
|
|
1393
|
+
if (( ! okh )); then
|
|
1394
|
+
printf ' ✗ NAO_TERMINAL=%-10s 非法(可选 %s)\n' "$NAO_TERMINAL" "${NAO_TERMINAL_ALLOWED[*]}"; rc=1
|
|
1395
|
+
elif ! command -v "$NAO_TERMINAL" >/dev/null 2>&1; then
|
|
1396
|
+
printf ' ✗ NAO_TERMINAL=%-10s 命令缺失(ensure 将 exit 2)\n' "$NAO_TERMINAL"; rc=1
|
|
1397
|
+
else
|
|
1398
|
+
printf ' ✓ NAO_TERMINAL=%-10s 合法且可用\n' "$NAO_TERMINAL"
|
|
1399
|
+
fi
|
|
1400
|
+
else
|
|
1401
|
+
echo ' · NAO_TERMINAL 未设置(按环境自动选择阶梯)'
|
|
1402
|
+
fi
|
|
1403
|
+
if command -v xdg-terminal-exec >/dev/null 2>&1; then
|
|
1404
|
+
echo ' ✓ xdg-terminal-exec 可用(阶梯③/④ 开可见窗口)'
|
|
1405
|
+
else
|
|
1406
|
+
echo ' · xdg-terminal-exec 缺失(阶梯③/④ 不可用;无图形会话需 --background)'
|
|
1407
|
+
fi
|
|
1408
|
+
if command -v tmux >/dev/null 2>&1; then
|
|
1409
|
+
echo ' ✓ tmux 可用(阶梯②/③/⑤)'
|
|
1410
|
+
else
|
|
1411
|
+
echo ' · tmux 缺失(阶梯②/③/⑤ 不可用)'
|
|
1412
|
+
fi
|
|
1413
|
+
|
|
1084
1414
|
echo "== tmux 布局 =="
|
|
1085
1415
|
if [[ -n "${TMUX:-}" ]]; then
|
|
1086
1416
|
echo " 当前在 tmux 内(会话 $(tmux display-message -p '#S' 2>/dev/null || echo '?'))"
|
|
1087
1417
|
else
|
|
1088
|
-
echo " (当前不在 tmux
|
|
1418
|
+
echo " (当前不在 tmux 内:宿主阶梯将按 ③/④/⑤ 选择可见窗口或 --background)"
|
|
1089
1419
|
fi
|
|
1090
1420
|
case "$TMUX_LAYOUT" in
|
|
1091
1421
|
main-row2|main-col|grid)
|
|
@@ -1119,6 +1449,10 @@ cmd_status() {
|
|
|
1119
1449
|
local a
|
|
1120
1450
|
intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存(后续子 shell 复用)
|
|
1121
1451
|
echo "== 角色会话在线状态(权威名单见 intercom({action:'list'});本表按终端标题/名册判定)=="
|
|
1452
|
+
# ④ 路径降级标注(BR6/D2/L6):无 tmux pane 句柄(含 NAO_TERMINAL=foot 等)时 turn 判定仅靠名册;恰好一行
|
|
1453
|
+
if ! has_tmux_handle; then
|
|
1454
|
+
echo ' (turn 判定来源 = 名册;非 tmux 宿主降级:无 tmux pane 句柄,在跑 turn 判定仅供参考)'
|
|
1455
|
+
fi
|
|
1122
1456
|
for a in "${ROLE_ORDER[@]}"; do
|
|
1123
1457
|
if running "$a" "${ROLE_WS[$a]:-}"; then
|
|
1124
1458
|
printf ' ✓ %-14s 在线(--name %s;卡片 %s)\n' "$a" "$a" "${ROLE_CARDS[$a]}"
|
|
@@ -1163,7 +1497,7 @@ cmd_status() {
|
|
|
1163
1497
|
cmd_close() {
|
|
1164
1498
|
local force="$1" task="$2" target="${3:-}"
|
|
1165
1499
|
[[ -n "$target" ]] || die "close 需要目标:角色别名或派生会话名(如 rd-be / rd-be-T1 / qa-T1)"
|
|
1166
|
-
local name repo cls bus pane pids st tgt a best=""
|
|
1500
|
+
local name repo cls bus pane pids st tgt a best="" stok
|
|
1167
1501
|
intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存
|
|
1168
1502
|
|
|
1169
1503
|
# ---- 目标解析:别名/canonical id | 派生会话名 <角色>-<编号> ----
|
|
@@ -1208,13 +1542,35 @@ cmd_close() {
|
|
|
1208
1542
|
|
|
1209
1543
|
# ---- 宿主句柄定位(跨仓):pane 按标题任意仓 basename / 名册 tmuxPane 不限定 cwd ----
|
|
1210
1544
|
pane="$(find_pane_for_any_repo "$name")"
|
|
1545
|
+
# F1 不变量(T513-S2):close 任何路径不得回收调用者自己的 pane/窗口。
|
|
1546
|
+
# 根因:派生窗口若继承 $TMUX_PANE,子进程按该 pane 注册名册 ⇒ 层② 命中调用者自身;
|
|
1547
|
+
# 本闸门是纵深防御——无论上游解析为何,命中即拒绝(不 kill-pane、不进入 pi_pids_for 兜底)。
|
|
1548
|
+
if [[ -n "$pane" && -n "${TMUX_PANE:-}" && "$pane" == "$TMUX_PANE" ]]; then
|
|
1549
|
+
warn "拒绝回收 $name:解析出的 pane $pane 与调用者自己的 pane(\$TMUX_PANE=$TMUX_PANE)相同"
|
|
1550
|
+
warn " 名册 tmuxPane 可能因派生时继承 TMUX_PANE 而错注册;请用 intercom({action:'list'}) 核对目标会话后再处理"
|
|
1551
|
+
return 1
|
|
1552
|
+
fi
|
|
1211
1553
|
if [[ -z "$pane" ]]; then
|
|
1212
1554
|
# nao-<会话名> detached tmux / screen 会话(不依赖 repo)
|
|
1213
|
-
if command -v tmux >/dev/null 2>&1 && tmux has-session -t "nao-$name" 2>/dev/null; then
|
|
1214
|
-
if tmux kill-session -t "nao-$name" 2>/dev/null; then log "已回收 $name(tmux 会话 nao-$name)@ $repo"; return 0; fi
|
|
1555
|
+
if command -v tmux >/dev/null 2>&1 && tmux has-session -t "=nao-$name" 2>/dev/null; then
|
|
1556
|
+
if tmux kill-session -t "=nao-$name" 2>/dev/null; then log "已回收 $name(tmux 会话 nao-$name)@ $repo"; return 0; fi
|
|
1215
1557
|
fi
|
|
1216
|
-
|
|
1217
|
-
|
|
1558
|
+
# F2(arch 评审):销毁目标 = `screen -ls` 锚定行取出的 `pid.name` 精确令牌;取不到即不 quit
|
|
1559
|
+
# (此前 `screen -S nao-<名> -X quit` 是前缀目标,精确+派生并存时无「只杀精确」保障)。
|
|
1560
|
+
stok="$(screen_token_for "$name")"
|
|
1561
|
+
if [[ -n "$stok" ]] && screen -S "$stok" -X quit 2>/dev/null; then
|
|
1562
|
+
log "已回收 $name(screen 会话 $stok)@ $repo"; return 0
|
|
1563
|
+
fi
|
|
1564
|
+
# ④ 路径句柄(BR7/AC8):app-id/class → pid(校验 comm=pi)→ kill 优先;
|
|
1565
|
+
# 有句柄即回收,不再依赖名册(名册可能滞后)。
|
|
1566
|
+
local app_pid
|
|
1567
|
+
app_pid="$(app_pid_for "$name")"
|
|
1568
|
+
if [[ -n "$app_pid" ]]; then
|
|
1569
|
+
if kill "$app_pid" 2>/dev/null; then
|
|
1570
|
+
log "已回收 $name(app-id=org.nao.$name · pid=$app_pid)@ $repo"; return 0
|
|
1571
|
+
fi
|
|
1572
|
+
warn "已定位 $name 的 app-id 句柄 pid=$app_pid 但回收失败(进程可能刚好退出;核对后可忽略)"
|
|
1573
|
+
return 1
|
|
1218
1574
|
fi
|
|
1219
1575
|
# 无任何可杀句柄:名册若报在线 ⇒ 定位失败,必须非静默(禁止「未运行」而 pane 仍在)
|
|
1220
1576
|
if intercom_online "$name"; then
|
|
@@ -1223,6 +1579,15 @@ cmd_close() {
|
|
|
1223
1579
|
warn " 请用 intercom({action:'list'}) 人工核对;确认已退出后忽略,或按 pane_id 手工处理"
|
|
1224
1580
|
return 1
|
|
1225
1581
|
fi
|
|
1582
|
+
# T516 AC3(#1 错定缺陷的负向闸):常驻精确句柄全缺席而其派生在场 ⇒ 目标歧义,
|
|
1583
|
+
# 拒绝并指路(不得落入 offline_verifiable 的「未运行(无需回收)」静默 no-op)。
|
|
1584
|
+
local -a dfield=()
|
|
1585
|
+
mapfile -t dfield < <(derived_in_field "$name")
|
|
1586
|
+
if (( ${#dfield[@]} > 0 )); then
|
|
1587
|
+
warn "拒绝回收 $name:常驻会话 nao-$name 不存在,但其派生在场:${dfield[*]}"
|
|
1588
|
+
warn " 目标歧义(tmux 会话名前缀匹配曾误回收派生,T515 #1):如需回收派生用 close <派生名>(如 close ${dfield[0]});如需拉起常驻用 ensure $tgt"
|
|
1589
|
+
return 1
|
|
1590
|
+
fi
|
|
1226
1591
|
if offline_verifiable; then
|
|
1227
1592
|
log "$name 未运行(无需回收)"; return 0
|
|
1228
1593
|
fi
|
|
@@ -1266,6 +1631,57 @@ cmd_close() {
|
|
|
1266
1631
|
warn "已定位到 $name 的 pane $pane 但回收失败(pane 可能刚好退出;核对 pane 已消失后可忽略)"; return 1
|
|
1267
1632
|
}
|
|
1268
1633
|
|
|
1634
|
+
# ---------------------------------------------------------------------------
|
|
1635
|
+
# 实验场安全清理入口(T516 #2 · D2 形态 A):终结指定实验场 tmux server。
|
|
1636
|
+
# 守卫:① 无 -S ⇒ exit 2(默认 socket 是生产名册所在,禁止默认清理);
|
|
1637
|
+
# ② -S 指向默认 socket ⇒ exit 2(防呆:显式传入也不放行);
|
|
1638
|
+
# ③ socket 不存在 ⇒ 幂等成功(rc 0);④ kill 失败 ⇒ 非 0 + 诊断,不谎报。
|
|
1639
|
+
# 生产默认 socket 候选。T516 事故修正:判定**不得**依赖 `${TMUX_TMPDIR:-/tmp}` ——
|
|
1640
|
+
# 实验夹具为让 fleet 内部裸 `tmux` 落到夹具 server 会把 TMUX_TMPDIR 指向隔离目录,
|
|
1641
|
+
# 若据此判定,真实默认 socket 会被误判为「非默认」而遭清理 ⇒ 真 server 连坐自灭
|
|
1642
|
+
# (2026-10-13 AC5e 负向用例实测杀掉调用者自己的 server)。恒含常量 /tmp/tmux-<uid>/default
|
|
1643
|
+
# (id -u 失败也是安全路径);回归脚本护栏用同一常量**独立复算**(双保险,互不依赖)。
|
|
1644
|
+
prod_sock_candidates() { # 每行一个候选
|
|
1645
|
+
local uid; uid="$(id -u)"
|
|
1646
|
+
[[ -n "${TMUX:-}" ]] && printf '%s\n' "${TMUX%%,*}"
|
|
1647
|
+
printf '%s\n' "/tmp/tmux-$uid/default" "${TMPDIR:-/tmp}/tmux-$uid/default"
|
|
1648
|
+
}
|
|
1649
|
+
cmd_cleanup() {
|
|
1650
|
+
local sock="${CLEANUP_SOCK:-}"
|
|
1651
|
+
if [[ -z "$sock" ]]; then
|
|
1652
|
+
printf '[fleet] cleanup 拒绝:未指定 -S <socket>。生产默认 socket 是生产名册所在,禁止默认清理;实验场清理用:env -u TMUX nao-fleet.sh cleanup -S <隔离socket>\n' >&2
|
|
1653
|
+
return 2
|
|
1654
|
+
fi
|
|
1655
|
+
# F3-B3(arch 评审 · 低):`-S` 若为符号链接/未归一化路径(`/tmp//…`、symlink)可绕过字符串比对
|
|
1656
|
+
# ⇒ 比对前 readlink -f 归一化;readlink -f 不可用/不可解析时退回原串(保持原语义、无新依赖)。
|
|
1657
|
+
local sock_cmp
|
|
1658
|
+
sock_cmp="$(readlink -f -- "$sock" 2>/dev/null || true)"
|
|
1659
|
+
[[ -n "$sock_cmp" ]] || sock_cmp="$sock"
|
|
1660
|
+
local -a prod=() p
|
|
1661
|
+
mapfile -t prod < <(prod_sock_candidates | awk '!seen[$0]++')
|
|
1662
|
+
for p in "${prod[@]:-}"; do
|
|
1663
|
+
if [[ "$sock_cmp" == "$p" ]]; then
|
|
1664
|
+
printf '[fleet] cleanup 拒绝:-S 指向生产默认 socket(%s);请用隔离 socket(须不同于生产候选:%s)\n' "$sock" "${prod[*]}" >&2
|
|
1665
|
+
return 2
|
|
1666
|
+
fi
|
|
1667
|
+
done
|
|
1668
|
+
[[ -n "${TMUX:-}" ]] && warn "当前在 tmux 客户端内:本次 -S 已显式(安全),但实验纪律建议先 env -u TMUX 再执行"
|
|
1669
|
+
if [[ ! -S "$sock" ]]; then
|
|
1670
|
+
log "socket 不存在(无 server,幂等清理):$sock"; return 0
|
|
1671
|
+
fi
|
|
1672
|
+
# 陈旧 socket(kill-server 后残留的 socket 文件,无 server 监听):清除文件即幂等成功
|
|
1673
|
+
# (实测 tmux 3.7c 在 -S 自定义路径下 kill-server 不卸载 socket 文件 ⇒ 二次 cleanup 需兜底)
|
|
1674
|
+
if ! tmux -S "$sock" ls >/dev/null 2>&1; then
|
|
1675
|
+
rm -f "$sock" 2>/dev/null || true
|
|
1676
|
+
log "socket 无 server 监听(残留),已清除:$sock"; return 0
|
|
1677
|
+
fi
|
|
1678
|
+
if tmux -S "$sock" kill-server 2>/dev/null; then
|
|
1679
|
+
log "已终结实验场 tmux server:$sock"; return 0
|
|
1680
|
+
fi
|
|
1681
|
+
warn "cleanup 失败:tmux -S $sock kill-server 非 0(server 可能刚好退出;核对 socket 后重试)"
|
|
1682
|
+
return 1
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1269
1685
|
# ---------------------------------------------------------------------------
|
|
1270
1686
|
cmd_ensure() {
|
|
1271
1687
|
local force="$1" model="$2" task="$3"; shift 3
|
|
@@ -1305,12 +1721,14 @@ usage() {
|
|
|
1305
1721
|
}
|
|
1306
1722
|
|
|
1307
1723
|
# ---- 入口 ----
|
|
1308
|
-
CMD=""; FORCE=false; STRICT=false; MODEL=""; TASK=""; TARGETS=()
|
|
1724
|
+
CMD=""; FORCE=false; STRICT=false; MODEL=""; TASK=""; BACKGROUND=false; CLEANUP_SOCK=""; TARGETS=()
|
|
1309
1725
|
while [[ $# -gt 0 ]]; do
|
|
1310
1726
|
case "$1" in
|
|
1311
1727
|
check) CMD="check"; shift ;;
|
|
1312
1728
|
status) CMD="status"; shift ;;
|
|
1313
1729
|
close) CMD="close"; shift ;;
|
|
1730
|
+
cleanup) CMD="cleanup"; shift ;;
|
|
1731
|
+
-S) CLEANUP_SOCK="${2:-}"; [[ -n "$CLEANUP_SOCK" ]] || die "-S 需要socket路径参数"; shift 2 ;;
|
|
1314
1732
|
ensure) CMD="ensure"; shift ;;
|
|
1315
1733
|
-m|--model)
|
|
1316
1734
|
MODEL="${2:-}"
|
|
@@ -1318,6 +1736,7 @@ while [[ $# -gt 0 ]]; do
|
|
|
1318
1736
|
check_model "$MODEL" || die "模型 '$MODEL' 不在白名单内。允许: $MODEL_WHITELIST(可通过 NAO_MODEL_WHITELIST 覆盖)"
|
|
1319
1737
|
shift 2 ;;
|
|
1320
1738
|
--force) FORCE=true; shift ;;
|
|
1739
|
+
--background) BACKGROUND=true; shift ;;
|
|
1321
1740
|
--task) TASK="${2:-}"; [[ -n "$TASK" ]] || die "--task 需要任务编号(如 T1)"; [[ "$TASK" =~ ^[A-Za-z0-9_-]+$ ]] || die "--task 非法: $TASK(仅字母/数字/-/_)"; shift 2 ;;
|
|
1322
1741
|
--strict) STRICT=true; shift ;;
|
|
1323
1742
|
-v|--verbose) VERBOSE=true; shift ;;
|
|
@@ -1330,6 +1749,14 @@ case "$CMD" in
|
|
|
1330
1749
|
check) : ;; # cmd_check 自行先做文本契约体检,再 load_manifest(缩进违例时也能先出报告)
|
|
1331
1750
|
ensure|status|close) load_manifest ;;
|
|
1332
1751
|
esac
|
|
1752
|
+
case "$CMD" in
|
|
1753
|
+
cleanup) : ;; # cleanup 不需角色清单
|
|
1754
|
+
esac
|
|
1755
|
+
|
|
1756
|
+
# -S 仅 cleanup 消费:其他子命令带上 -S 视为用法错误(避免静默吞参)
|
|
1757
|
+
if [[ -n "${CLEANUP_SOCK:-}" && "$CMD" != "cleanup" ]]; then
|
|
1758
|
+
die "-S 仅用于 cleanup 子命令(实验场安全清理):nao-fleet.sh cleanup -S <socket>"
|
|
1759
|
+
fi
|
|
1333
1760
|
|
|
1334
1761
|
case "$CMD" in
|
|
1335
1762
|
check)
|
|
@@ -1359,6 +1786,7 @@ case "$CMD" in
|
|
|
1359
1786
|
;;
|
|
1360
1787
|
status) cmd_status ;;
|
|
1361
1788
|
close) cmd_close "$FORCE" "$TASK" "${TARGETS[0]:-}" ;;
|
|
1789
|
+
cleanup) cmd_cleanup ;;
|
|
1362
1790
|
ensure) cmd_ensure "$FORCE" "$MODEL" "$TASK" "${TARGETS[@]}" ;;
|
|
1363
1791
|
*) usage ;;
|
|
1364
1792
|
esac
|
|
@@ -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,6 +31,9 @@ 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
|
+
- 实验场清理:`cleanup -S <隔离socket>`(强制显式 socket;无 `-S`/指向默认 socket ⇒ exit 2 拒绝)
|
|
35
|
+
⚠ 实验纪律:实验/夹具一律 `env -u TMUX` + `-S <隔离socket>`——tmux 客户端在 `$TMUX` 已设时
|
|
36
|
+
无视 `TMUX_TMPDIR`,裸 `tmux`/`kill-server` 会连坐真实 server(T515 两次自灭根因)
|
|
34
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
|
## 硬约束(勿违反)
|
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.
|
|
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,13 +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/,再按需写入转发入口
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
迁移默认按**判定式**决定是否写转发入口(shim):仓里还有旧的 `nao-fleet.sh`、或已存在 shim、或全仓有 `nao-fleet` / `nao-skill` / `NAO_SKILLS` 引用,就写;**无脚本、无引用的纯文档仓会被判定跳过**,并打印一行告知。要改变判定结果,显式加 `--shim`(强制写)或 `--no-shim`(强制不写),两者不能同时给(`exit 2`)。旧版的 `.agents/.nao-migrated` 标记**已废弃**:迁移不再读写它,可安全删除。`.agents/.nao-version` 的语义是「本机已安装机制包版本」(供升级检测),**不表达迁移状态**——迁移状态由「是否还有旧资产」推断。
|
|
39
|
+
**老项目迁移**(曾在项目里铺开整套 `.agents/` 的):执行 `npx @nathan33/nao-skill migrate` 即可,细则见文末「老用户迁移与维护者注记」。
|
|
46
40
|
|
|
47
41
|
## 用起来(3 步)
|
|
48
42
|
|
|
@@ -53,6 +47,8 @@ npx @nathan33/nao-skill migrate # 旧文件备份到 .agents/.nao-obsolete/,
|
|
|
53
47
|
|
|
54
48
|
角色名可以写**别名**或**规范名**(两者等价):`arch` = `arch-designer`、`infra` = `rd-infra`;上例里两者混用也照常拉起。完整别名表见 `.agents/roles.yaml` 或 `nao-fleet.sh --help`。
|
|
55
49
|
|
|
50
|
+
**不在 tmux 里也能用**:默认每个角色开一个**看得见的终端窗口**(走 `xdg-terminal-exec`,窗口标识 `org.nao.<角色>`),不是后台黑箱;无图形会话(如 SSH)默认拒绝,需显式 `--background` 才建后台会话,并打印可复制的 `tmux attach` 命令。宿主的完整选择顺序与降级边界见 `nao-fleet.sh --help`。不过我们**推荐装 tmux**——多数能力在 tmux 宿主下体验最完整,理由见下方常见问题。
|
|
51
|
+
|
|
56
52
|
然后**在 PM 会话里说需求**就行,剩下的它自己流转。大概长这样:
|
|
57
53
|
|
|
58
54
|
```text
|
|
@@ -78,17 +74,6 @@ PM:在 PR 核 AC 并评论 → 验收通过(授权合并)→ rd-be squash
|
|
|
78
74
|
|
|
79
75
|
不想要哪个角色,删掉对应文件即可;只留 PM + 一个研发角色也能跑。
|
|
80
76
|
|
|
81
|
-
## 可选:关键节点 QQ 主动推送
|
|
82
|
-
|
|
83
|
-
装上 [`pi-agent-qqbot`](https://github.com/Nathan3303) 并配好 `~/.pi/agent/pi-agent-qqbot.json`(`appId` / `clientSecret` / `ownerOpenId` / `sandbox`)后,PM 可把**关键节点**主动推到你 QQ——批次进度汇总、验收结论、发版或合并完成、异常阻塞:
|
|
84
|
-
|
|
85
|
-
```bash
|
|
86
|
-
$NAO_SKILLS/.agents/scripts/qq-notify "[T301] 验收通过 · PR #4 已合并 · v0.8.0"
|
|
87
|
-
$NAO_SKILLS/.agents/scripts/qq-notify --dry-run "连通性自检" # 只取 token、不发送
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
**为什么默认不主动打扰**:这是**可选能力**——没配 `pi-agent-qqbot` 就自动视为未启用;四类节点之外不发,一个节点最多一条(禁刷屏);推送失败只记回执行风险项,**不阻断交付**。默认走 sandbox(测试环境),正式发布通知才考虑 prod。能力边界、退出码与纪律见 `$NAO_SKILLS/.agents/skills/qq-notify.md`。
|
|
91
|
-
|
|
92
77
|
## 为什么它省 token(这是本项目的核心追求)
|
|
93
78
|
|
|
94
79
|
和 AI 聊天,钱花在「它每轮要读多少字」。所以:
|
|
@@ -110,6 +95,15 @@ $NAO_SKILLS/.agents/scripts/qq-notify --dry-run "连通性自检" # 只取
|
|
|
110
95
|
|
|
111
96
|
**跑完的会话要留着吗?** 后台派生会话验收完可以回收:`nao-fleet.sh close --task T1 rd-be`(等价写法 `nao-fleet.sh close rd-be-T1`,直接用派生会话名即可);常驻角色留着复用。`nao-fleet.sh status` 会提示哪些是残留。目标在别的项目里也能回收——`close` 会按名册/窗口标题跨项目定位;定位不到时会**非 0 退出并打印诊断**,不会假装已回收。
|
|
112
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
|
+
|
|
113
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`。
|
|
114
108
|
|
|
115
109
|
**它会自己上网查吗?** 会,但有分工:产品经理查同类功能的**产品形态**(有哪些功能点、属性、视图、协作方式),架构师对照**业界成熟的架构模式**。两者都必须把来源和「适不适用本项目」落成文件(`docs/research/`),抓来的网页内容不会堆进聊天记录;技术选型仍然只由架构师定。
|
|
@@ -130,29 +124,35 @@ $NAO_SKILLS/.agents/scripts/qq-notify --dry-run "连通性自检" # 只取
|
|
|
130
124
|
|
|
131
125
|
机制本体(6 张角色卡、协作规矩、清单、按需技能、小工具)都在 npm 包 `@nathan33/nao-skill` 的 `.agents/` 里——**包里是什么就是什么**,项目里不留副本。
|
|
132
126
|
|
|
133
|
-
##
|
|
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 起有变化)
|
|
134
140
|
|
|
135
141
|
**从 0.12 起,机制不再铺进项目**:项目里只留一个转发入口,机制的**单一事实来源**是 npm 包 `@nathan33/nao-skill` 内的 `.agents/`。
|
|
136
142
|
|
|
137
|
-
-
|
|
143
|
+
- **旧的完整性判据已失效**:以前靠「项目内 `.agents/**` 与包内逐字节 sha 一致」证明机制没漏没改;现在项目内不再有机制副本,这条判据不再适用。
|
|
138
144
|
- **新的完整性判据:以 pin 的包版本为准**。`.pi/settings.json` 记录版本,`pi install/update` 从包物化;包里是什么就是什么,不存在「项目内被就地改动」的问题。转发入口是生成的固定文本,可用 `npx @nathan33/nao-skill init` 幂等重写核对。
|
|
139
145
|
- **版本权威口径**:`.pi/settings.json` 里的 pin 是**唯一权威**(`pi install/update` 据此物化到 `.pi/npm`);`.agents/.nao-version` 的语义是**本机已安装机制包版本**(供升级检测),**不表达迁移状态**、**不作为版本依据**;两者不一致时**以 pin 为准**。
|
|
140
|
-
- **fmt 边界**:包内 `.agents/**`
|
|
146
|
+
- **fmt 边界**:包内 `.agents/**` 按原始存储形态发布(部分 YAML / Markdown / MTS 未按 oxfmt / prettier 归一)。迁移期仍持有旧版全套 `.agents/**` 的仓库,继续把 `.agents/**` 排除出 fmt 校验即可,⛔ **不要为过 fmt 就地格式化**;新形态项目内只有转发入口,无需再处理。
|
|
141
147
|
|
|
142
148
|
### 物化与离线(CI / 无 pi 环境必读)
|
|
143
149
|
|
|
144
150
|
- `.pi/npm/` 由 pi 管理并 **gitignore**。因此 **fresh clone 上没有 `.pi/npm/package.json` 与 lockfile**,`npm ci --prefix .pi/npm` 当前**不可用**。
|
|
145
151
|
- 物化只有一条路:**显式执行** `pi install -l --approve npm:@nathan33/nao-skill@<pin>`(需要 pi 与网络,是有意为之的一步)。
|
|
146
|
-
- **联动条款**:若某个仓库想把机制体检 `check` 放进 CI
|
|
152
|
+
- **联动条款**:若某个仓库想把机制体检 `check` 放进 CI,**必须同时**用 `git add -f` 把 `.pi/npm/package.json`、`.pi/npm/package-lock.json` 纳入版本控制,`npm ci` 才有输入。
|
|
147
153
|
- **pi 启动的隐式安装**:当 `.pi/npm/node_modules` 缺失时,pi 启动会隐式执行 `npm install --prefix .pi/npm --legacy-peer-deps`;离线且缓存为空会得到可读的 npm 报错,并让 pi 以非 0 退出(不会静默假装成功)。
|
|
148
154
|
- **运行纪律**:**先显式物化,再 `PI_OFFLINE=1` 运行**。nao 机制自身(转发入口 / fleet / CLI / check)**零网络调用**;入口找不到机制包时不静默成功,而是 `exit 2` 并打印一行 `DEGRADED:` + 恢复命令。
|
|
149
155
|
|
|
150
|
-
## 下游仓库迁移(nao-todo / nao-todo-server / nue-ui)
|
|
151
|
-
|
|
152
|
-
- **迁移期零改动**:旧的全套 `.agents/scripts/nao-fleet.sh` 照常可用,这些仓库的 CI 目前不调用 `check`,不受影响。
|
|
153
|
-
- 想切到新形态时,在各仓执行 `npx @nathan33/nao-skill migrate`:只删**已知 nao 资产**(共享的 `.agents/skills/` 里项目自有的技能会保留),并把 `skills-lock.json` 里重复的 `frontend-design` 来源收敛为「机制包唯一来源」。
|
|
154
|
-
- **纯文档仓(如 `nao-todo-minimal`)特例**:无脚本、无 `nao-fleet`/`nao-skill` 引用 ⇒ 迁移判定为**纯文档迁移,不建转发入口**(可显式 `--shim` 强制;也可 `--no-shim` 强制不建);它的 `.agents/commands/`、`.agents/prompts/` 不在 pi 的 skill 扫描面(pi 只扫 `.agents/skills/**`)。
|
|
155
|
-
|
|
156
156
|
## 想深入
|
|
157
157
|
|
|
158
158
|
README 只讲怎么用。真正的机制都在依赖包 `@nathan33/nao-skill` 的 `.agents/` 里(会话里以 `$NAO_SKILLS/.agents/` 指向),想改就从这几处入手:
|
package/bin/nao-skill.js
CHANGED
|
@@ -46,12 +46,12 @@ const VERSION_FILE = '.agents/.nao-version';
|
|
|
46
46
|
// 旧版全套安装的已知 nao 资产(迁移时**精准**备份+移除;共享目录不得整目录删)
|
|
47
47
|
const LEGACY_TOP_DIRS = ['prompts', 'common', 'checklists', 'templates'];
|
|
48
48
|
const LEGACY_TOP_FILES = ['roles.yaml'];
|
|
49
|
-
const LEGACY_SCRIPT_FILES = ['nao-fleet.sh', 'intercom-probe.mts', '
|
|
49
|
+
const LEGACY_SCRIPT_FILES = ['nao-fleet.sh', 'intercom-probe.mts', 'ui-tokens-check.sh'];
|
|
50
50
|
const LEGACY_SKILL_ENTRIES = [
|
|
51
51
|
'nao-fleet', 'frontend-design',
|
|
52
52
|
'arch-patterns.md', 'backend-ddd-details.md', 'codegraph.md', 'commit.md',
|
|
53
53
|
'frontend-ddd-details.md', 'github-flow.md', 'pm-grill.md', 'pm-operations.md',
|
|
54
|
-
'pm-rice.md', 'pm-routing.md', '
|
|
54
|
+
'pm-rice.md', 'pm-routing.md', 'research.md', 'test-design.md',
|
|
55
55
|
];
|
|
56
56
|
|
|
57
57
|
const log = (...a) => console.log('[nao-skill]', ...a);
|
package/package.json
CHANGED
|
@@ -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
|
-
})
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: 按需技能——QQ 主动推送(关键节点通知;PM 在里程碑/阻塞时读取)
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
# QQ 主动推送(qq-notify)
|
|
6
|
-
|
|
7
|
-
## 触发时机
|
|
8
|
-
|
|
9
|
-
**仅关键节点**由 PM 使用,读本文件确认纪律后调用;日常轮次不加载、不发送。
|
|
10
|
-
本能力**可选**:未配置 `pi-agent-qqbot` 时不要使用,按普通回执继续交付。
|
|
11
|
-
|
|
12
|
-
## 定位与能力边界
|
|
13
|
-
|
|
14
|
-
- 把「关键节点」用 QQ 官方机器人的**主动消息**推给 owner,让用户离开工位也能收到交付信号。
|
|
15
|
-
- **主动消息**(不带 `msg_id`)受 **QQ 开放平台账号权限与月度配额**限制:配额用尽或未开通对应权限时发送会失败(exit 3),**不得重试轰炸**。
|
|
16
|
-
- 只做**单向推送**,不做接收/被动回复;不做定时调度、不做退避重发(这些是平台与宿主的事)。
|
|
17
|
-
- **默认 sandbox**:`pi-agent-qqbot.json` 中 `sandbox: true` 走测试环境,`false` 才走 `prod`。**两者权限与收件人不通用**,切换 prod 前先确认机器人已在正式环境配置好。
|
|
18
|
-
|
|
19
|
-
## 何时用(四类节点,仅此四类)
|
|
20
|
-
|
|
21
|
-
| 节点 | 典型内容 |
|
|
22
|
-
| :--- | :--- |
|
|
23
|
-
| 批次进度汇总 | 一个批次开始/收尾时的整体进度(不是每个子任务) |
|
|
24
|
-
| 验收结论 | PM 验收通过 / 驳回,含 PR 编号与一句话结论 |
|
|
25
|
-
| 发版或合并完成 | tag / Release / squash 合并完成,含版本号 |
|
|
26
|
-
| 异常阻塞 | 需要用户决策、外部依赖卡住、门禁长期红 |
|
|
27
|
-
|
|
28
|
-
## 纪律(禁刷屏)
|
|
29
|
-
|
|
30
|
-
- **一个节点最多一条**;同一批次不逐任务推送。
|
|
31
|
-
- 推文用**一句话**:`[编号] 结论 + 关键数字/链接`,不贴长正文(正文留 `docs/`)。
|
|
32
|
-
- 用户未回应不重复推;阻塞未解除才允许在**跨天**时补一条。
|
|
33
|
-
- **默认 sandbox**,仅在正式发布通知用户时考虑 prod(须用户明确知情)。
|
|
34
|
-
|
|
35
|
-
## 调用禁令(⛔ 重要:曾因此误发过真实消息)
|
|
36
|
-
|
|
37
|
-
本脚本是 **Node 程序**,文件头部的注释里写着用法示例。⛔ **禁止用 shell 把它当 shell 脚本逐行解释**:
|
|
38
|
-
|
|
39
|
-
```text
|
|
40
|
-
⛔ bash .agents/scripts/qq-notify "文本" ← 错:shell 会执行注释里的示例
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
原因(实测):`bash <本脚本>` 会把注释当命令执行——注释里的 `*` 触发 **glob 展开**(把 `AGENTS.md apps docs …` 当命令跑)、`--to <openid>` 变成 **重定向**、`echo "文本" | qq-notify` 变成 **真实管道**(PATH 命中同名脚本即**真发**)——本仓历史上正因此误发过一条真实 QQ 消息。
|
|
44
|
-
|
|
45
|
-
**正确形式(三者等价,均 rc=0 且输出一致)**:
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
.agents/scripts/qq-notify "文本" # ./ 直接执行(推荐)
|
|
49
|
-
node .agents/scripts/qq-notify "文本" # 显式用 node
|
|
50
|
-
sh .agents/scripts/qq-notify "文本" # 已加固:头部 exec 守卫自动转交 node
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
> 加固方式(v0.9.2):第 1 行 `#!/bin/sh` + 第 2 行 `//bin/true; exec node "$0" "$@"`。
|
|
54
|
-
> `sh/bash` 执行时第 2 行即 `exec node`(**自动转交**,注释不会再被 shell 解析);`node` 执行时第 2 行是 JS 注释。
|
|
55
|
-
> `nao-fleet.sh check` 已增加该守卫的存在性校验(缺失 ⇒ rc=1)。
|
|
56
|
-
|
|
57
|
-
## 用法
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
# 安装后位于当前项目:.agents/scripts/qq-notify
|
|
61
|
-
.agents/scripts/qq-notify "[T301] 验收通过 · PR #4 已合并 · v0.8.0"
|
|
62
|
-
.agents/scripts/qq-notify --dry-run "连通性自检" # 只取 token、不发送
|
|
63
|
-
echo "文本" | .agents/scripts/qq-notify # 从 stdin 读
|
|
64
|
-
.agents/scripts/qq-notify --to <openid> "文本" # 指定收件人
|
|
65
|
-
.agents/scripts/qq-notify -h # 帮助
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
- 配置优先级:`--config <path>` > 环境变量 `QQ_NOTIFY_CONFIG` > `~/.pi/agent/pi-agent-qqbot.json`
|
|
69
|
-
- 配置文件字段:`{ "appId", "clientSecret", "ownerOpenId", "sandbox" }`;**无此文件即视为未启用本能力**(exit 1)。
|
|
70
|
-
|
|
71
|
-
## 退出码与失败回报
|
|
72
|
-
|
|
73
|
-
| 退出码 | 含义 | 处理 |
|
|
74
|
-
| :--- | :--- | :--- |
|
|
75
|
-
| `0` | 发送成功(或 `--dry-run` 取 token 成功) | 正常,可在回执记一行 `notify=ok` |
|
|
76
|
-
| `1` | 参数或配置错(缺文本 / 读不到配置 / 缺 `ownerOpenId`) | 按 stderr 的 `→` 提示修复;**不要重试**,先补配置 |
|
|
77
|
-
| `2` | 取 access token 失败(网络 / 凭证错误) | 报出 `status` 与截断错误体;不重试轰炸 |
|
|
78
|
-
| `3` | 发送失败(权限 / 配额 / openid 非法) | 报出 `status` 与截断错误体;主动消息受权限与配额限制 |
|
|
79
|
-
|
|
80
|
-
- 失败时**不抛栈**:脚本输出 `FAIL: …` 与一行可执行修复提示,**非 0 退出码 + 截断错误体**即为回报内容。
|
|
81
|
-
- 推送失败**不阻断交付**:把 `notify=fail(exit=<n>)` 写进回执的风险项,继续原流程。
|
|
82
|
-
- **安全**:脚本绝不打印/落盘 `clientSecret` 与 `access_token`;回执中也不要粘贴这两者。
|
|
83
|
-
|
|
84
|
-
## 跨项目用法
|
|
85
|
-
|
|
86
|
-
安装后脚本固定位于 **`<项目>/.agents/scripts/qq-notify`**(随 `nao-skill install/update` 整目录分发)。
|
|
87
|
-
在项目里直接用相对路径调用;从别的仓库操作时用绝对路径 `.agents/scripts/qq-notify`(可配 `NAO_SKILLS`)。
|