@nathan33/nao-skill 0.7.0 → 0.9.2

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.
@@ -3,9 +3,9 @@
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 模板/qq-notify/白名单/布局
7
7
  # 默认单行摘要(含 warn 计数);-v 展开完整报告;失败始终展开
8
- # nao-fleet.sh status 角色会话在线状态(权威名单见 intercom list)
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 <别名>-<编号>(并行隔离,避免同名冲突)
@@ -14,24 +14,38 @@
14
14
  # 回收已完成会话(闸门:在跑 turn / tasks-state 未推进 → 拒绝,--force 跳过)
15
15
  #
16
16
  # 角色别名 → 角色卡:见 .agents/roles.yaml(单一事实来源)
17
- # 当前:pm / arch-designer(arch) / rd-fe / rd-be / qa
17
+ # 当前:pm / arch-designer(arch) / rd-fe / rd-be / qa / rd-infra(infra)
18
18
  #
19
19
  # 环境变量
20
20
  # NAO_TERMINAL=ghostty|ptyxis|tmux|screen 强制宿主
21
- # NAO_TMUX_LAYOUT=main-row2|grid tmux 布局(默认 main-row2)
22
- # NAO_TMUX_MAIN_WIDTH=<10..90> main-row2 主 pane 宽度百分比(默认 35)
21
+ # NAO_TMUX_LAYOUT=main-row2|main-col|grid tmux 布局(默认 main-row2)
22
+ # NAO_TMUX_MAIN_WIDTH=<10..90> 主 pane 宽度百分比(默认 35;main-row2/main-col 共用)
23
+ # NAO_TMUX_MIN_PANE_WIDTH=<10..80> 最小非主 pane 列宽守卫(默认 30;低于则回退)
23
24
  # NAO_SKILLS=<dir> 角色卡根目录(默认 <脚本>/../..)
24
25
  # NAO_MODEL_WHITELIST=<glob,...> -m 白名单(默认空=不校验,支持 glob)
25
26
  # NAO_CLOSE_BUSY_PATTERN=<ERE> close 的在跑 turn 判定正则(默认内置 pi 状态行标记)
26
27
  # NAO_TASKS_STATE=<path> 任务状态文件(默认 docs/tasks-state.md,供残留检测/回收闸门)
27
28
  #
29
+ # 在线判定(status / close / ensure 判重共用)
30
+ # pi 启动后用 OSC 0 把终端标题设为 "π - <会话名> - <cwd basename>" 并改写 argv
31
+ # (/proc/<pid>/cmdline 只剩 "pi")⇒ 判定一律面向「终端标题 / pi-intercom 名册 /
32
+ # tmux·screen 会话名」,不使用 `pgrep --name`(历史假阴性根因)。
33
+ # 名册经 $PI_AGENT_DIR/npm/node_modules/pi-intercom/cli.ts(一次调用、进程内缓存),
34
+ # 不可用时回退 tmux/screen;仍无法确认「不在线」时 close 不做静默 no-op(exit 非 0)。
35
+ #
28
36
  # tmux 宿主行为
29
37
  # - 已在 tmux 内($TMUX 存在):当前窗口分屏拉起,不新建窗口。
30
38
  # - 不在 tmux 内:创建 detached 会话 nao-<角色>,需 tmux attach -t nao-<角色>。
31
- # - main-row2:第 1 个 pane 全高占左,后续每角色往右开列、每列上下 2 个:
39
+ # - main-row2:第 1 个 pane 全高占左,后续每角色往右开列、每列上下 2 个(默认):
32
40
  # 1 | 2 | 4
33
41
  # 1 | 3 | 5
34
- # - grid:所有 pane 等大网格(tmux 内建 tiled)。
42
+ # - main-col:第 1 个 pane 全高占左,其余 pane 在右列纵向堆叠(委托内建 main-vertical):
43
+ # 1 | 2
44
+ # 1 | 3
45
+ # 1 | 4
46
+ # - grid:所有 pane 等大网格(tmux 内建 tiled),宽度最优。
47
+ # - 窄列守卫:main-row2 的最窄非主 pane < NAO_TMUX_MIN_PANE_WIDTH ⇒ 回退 main-col;
48
+ # 回退后右列仍不足 ⇒ 再回退 grid(带 warn,不中断 ensure)。
35
49
  # =============================================================================
36
50
  set -euo pipefail
37
51
 
@@ -50,6 +64,9 @@ TMUX_LAYOUT="${NAO_TMUX_LAYOUT:-main-row2}"
50
64
  # main-row2 主 pane 宽度百分比:默认值与非法值回退共用同一常量(防三处漂移)
51
65
  TMUX_MAIN_WIDTH_DEFAULT=35
52
66
  TMUX_MAIN_WIDTH="${NAO_TMUX_MAIN_WIDTH:-$TMUX_MAIN_WIDTH_DEFAULT}"
67
+ # 最小非主 pane 列宽守卫:默认值与非法值回退共用同一常量(防漂移)
68
+ TMUX_MIN_PANE_WIDTH_DEFAULT=30
69
+ TMUX_MIN_PANE_WIDTH="${NAO_TMUX_MIN_PANE_WIDTH:-$TMUX_MIN_PANE_WIDTH_DEFAULT}"
53
70
  # check 输出契约:默认单行摘要(省 PM 上下文),-v 展开完整报告;失败始终展开
54
71
  VERBOSE=false
55
72
  # close 的在跑 turn 判定(pi 默认状态行:Working (esc to interrupt) / Thinking... / Retrying / Compacting)
@@ -185,6 +202,50 @@ check_roles_indent() {
185
202
  ' "$MANIFEST"
186
203
  }
187
204
 
205
+ # qq-notify 主动推送器契约:存在 + 可执行 + EOL 全 LF + 无 Tab + node --check 语法
206
+ check_qq_notify() {
207
+ local rc=0 f="$SKILLS_DIR/.agents/scripts/qq-notify"
208
+ if [[ ! -f "$f" ]]; then
209
+ printf ' ✗ 缺失: .agents/scripts/qq-notify\n'
210
+ return 1
211
+ fi
212
+ printf ' ✓ 存在: .agents/scripts/qq-notify\n'
213
+ if [[ -x "$f" ]]; then
214
+ printf ' ✓ 可执行位已设置\n'
215
+ else
216
+ printf ' ✗ 缺少可执行位(修复:chmod +x .agents/scripts/qq-notify)\n'; rc=1
217
+ fi
218
+ if grep -Iq $'\r' "$f"; then
219
+ printf ' ✗ 含 CR(须转 LF;CRLF 会破坏 shebang/管道)\n'; rc=1
220
+ else
221
+ printf ' ✓ 行尾 LF\n'
222
+ fi
223
+ if grep -Iq $'\t' "$f"; then
224
+ printf ' ✗ 含 Tab(禁止 Tab 缩进)\n'; rc=1
225
+ else
226
+ printf ' ✓ 无 Tab\n'
227
+ fi
228
+ # shell 转交守卫:禁止 `bash/sh <本脚本>` 逐行解释注释(防误执行示例/误发)
229
+ local l1 l2
230
+ l1="$(head -n 1 "$f")"
231
+ l2="$(sed -n '2p' "$f")"
232
+ if [[ "$l1" == '#!/bin/sh' ]] && [[ "$l2" == *'exec node "$0" "$@"'* ]]; then
233
+ printf ' ✓ shell 转交守卫(#!/bin/sh + exec node)\n'
234
+ else
235
+ printf ' ✗ 缺少 shell 转交守卫(第 1 行须 #!/bin/sh、第 2 行须含 exec node "$0" "$@")\n'; rc=1
236
+ fi
237
+ if command -v node >/dev/null 2>&1; then
238
+ if node --check "$f" >/dev/null 2>&1; then
239
+ printf ' ✓ node --check 语法通过\n'
240
+ else
241
+ printf ' ✗ node --check 语法失败\n'; rc=1
242
+ fi
243
+ else
244
+ printf ' ! node 未安装(跳过语法校验)\n'
245
+ fi
246
+ return $rc
247
+ }
248
+
188
249
  # CodeGraph 索引健康(ensure 拉起前 / check 用;缺失或过期仅 warn,不阻塞)
189
250
  check_codegraph() {
190
251
  local repo="$1" name st
@@ -223,25 +284,128 @@ detect_host() {
223
284
  echo screen
224
285
  }
225
286
 
226
- # 该会话名对应的真实 pi 进程 PID(过滤 shell/tmux 等误匹配,避免误命中宿主命令行、误杀包装进程)
287
+ # ---------------------------------------------------------------------------
288
+ # 会话在线判定
289
+ #
290
+ # 历史假阴性根因:pi 启动后用 OSC 0 把终端标题设为
291
+ # "π - <会话名> - <cwd basename>"(pi 源码 updateTerminalTitle),
292
+ # 并同步改写 argv —— /proc/<pid>/cmdline 只剩 "pi",`pgrep -f --name` 恒失配,
293
+ # 于是 status/close 长期判「不在线」(close 退化为静默空转、exit 0)。
294
+ # 现改为按「终端标题契约」取句柄,argv 扫描一律不用:
295
+ # ① pi-intercom 名册(权威在线名单;跨 tmux/ghostty/ptyxis/screen 宿主)
296
+ # ② tmux pane_title(pi 自设;与 pane_start_command 是否为空无关)
297
+ # ③ tmux detached 会话 / screen 会话名 nao-<会话名>
298
+ # repo(可选):给出时要求标题 basename / 名册 cwd 一致,避免跨项目同名会话互串。
299
+
300
+ PI_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
301
+ PI_TITLE="π"
302
+ _INTERCOM_JSON=""; _INTERCOM_PROBED=0
303
+
304
+ repo_abspath() { ( cd "$1" >/dev/null 2>&1 && pwd ) 2>/dev/null || true; }
305
+ repo_basename() {
306
+ local abs; abs="$(repo_abspath "$1")"
307
+ [[ -n "$abs" ]] && printf '%s\n' "${abs##*/}"
308
+ return 0
309
+ }
310
+
311
+ # intercom 名册 JSON(一次调用、进程内缓存);不可用返回 1(不 die)
312
+ intercom_list_json() {
313
+ if (( _INTERCOM_PROBED )); then
314
+ [[ -n "$_INTERCOM_JSON" ]] && printf '%s' "$_INTERCOM_JSON"
315
+ return
316
+ fi
317
+ _INTERCOM_PROBED=1
318
+ local dir="$PI_AGENT_DIR/npm/node_modules/pi-intercom"
319
+ local tsx="$PI_AGENT_DIR/npm/node_modules/tsx/dist/cli.mjs"
320
+ [[ -f "$dir/cli.ts" && -f "$tsx" ]] || return 1
321
+ command -v node >/dev/null 2>&1 || return 1
322
+ local out
323
+ if command -v timeout >/dev/null 2>&1; then
324
+ out="$(timeout 15 node "$tsx" "$dir/cli.ts" list --json 2>/dev/null)" || return 1
325
+ else
326
+ out="$(node "$tsx" "$dir/cli.ts" list --json 2>/dev/null)" || return 1
327
+ fi
328
+ [[ -n "$out" ]] || return 1
329
+ _INTERCOM_JSON="$out"
330
+ printf '%s' "$out"
331
+ }
332
+
333
+ # 名册 → "会话名<TAB>cwd"(每行一条)
334
+ intercom_roster() {
335
+ local json; json="$(intercom_list_json)" || return 1
336
+ awk '
337
+ /"name":[[:space:]]*"/ { s=$0; sub(/.*"name":[[:space:]]*"/,"",s); sub(/".*/,"",s); n=s }
338
+ /"cwd":[[:space:]]*"/ { s=$0; sub(/.*"cwd":[[:space:]]*"/,"",s); sub(/".*/,"",s); print n "\t" s }
339
+ ' <<< "$json"
340
+ }
341
+
342
+ # 名册中是否在线;repo 给出时须 cwd 精确匹配
343
+ intercom_online() {
344
+ local name="$1" repo="${2:-}" abs="" n c
345
+ intercom_list_json >/dev/null 2>&1 || return 1
346
+ if [[ -n "$repo" ]]; then abs="$(repo_abspath "$repo")"; fi
347
+ while IFS=$'\t' read -r n c; do
348
+ [[ "$n" == "$name" ]] || continue
349
+ if [[ -z "$abs" || "$c" == "$abs" ]]; then return 0; fi
350
+ done < <(intercom_roster)
351
+ return 1
352
+ }
353
+
354
+ # 会话所在的 tmux pane(pi 自设标题 "π - <会话名> - <repo basename>")
355
+ find_pane_for() {
356
+ local name="$1" repo="${2:-}" base=""
357
+ command -v tmux >/dev/null 2>&1 || return 0
358
+ if [[ -n "$repo" ]]; then base="$(repo_basename "$repo")"; fi
359
+ tmux list-panes -a -F "#{pane_id}"$'\t'"#{pane_title}" 2>/dev/null \
360
+ | awk -F'\t' -v p="$PI_TITLE" -v n="$name" -v b="$base" '
361
+ { t=$2
362
+ if (b != "") { if (t == p " - " n " - " b) { print $1; exit } }
363
+ else if (index(t, p " - " n " - ") == 1) { print $1; exit }
364
+ }'
365
+ }
366
+
367
+ # 该会话名对应的 pi 进程 PID(经 pane 句柄取 pane_pid 及其子进程中 comm=pi 者)
227
368
  pi_pids_for() {
228
- local pid comm
229
- pgrep -f -- "pi[[:space:]].*--name $1([[:space:]]|$)" 2>/dev/null | while IFS= read -r pid; do
230
- comm="$(ps -o comm= -p "$pid" 2>/dev/null | tr -d ' ')"
231
- case "$comm" in
232
- bash|sh|dash|zsh|fish|tmux|screen|sudo|env) continue ;;
233
- esac
369
+ local pane root pid
370
+ pane="$(find_pane_for "$1" "${2:-}")"
371
+ [[ -n "$pane" ]] || return 0
372
+ root="$(tmux display-message -p -t "$pane" '#{pane_pid}' 2>/dev/null)"
373
+ [[ -n "$root" ]] || return 0
374
+ { printf '%s\n' "$root"; pgrep -P "$root" 2>/dev/null || true; } | while IFS= read -r pid; do
375
+ [[ "$(ps -o comm= -p "$pid" 2>/dev/null | tr -d ' ')" == "pi" ]] || continue
234
376
  printf '%s\n' "$pid"
235
377
  done
236
378
  }
237
379
 
238
- running() { [[ -n "$(pi_pids_for "$1")" ]]; }
380
+ running() {
381
+ local name="$1" repo="${2:-}"
382
+ if intercom_online "$name" "$repo"; then return 0; fi
383
+ if [[ -n "$(find_pane_for "$name" "$repo")" ]]; then return 0; fi
384
+ if command -v tmux >/dev/null 2>&1 && tmux has-session -t "nao-$name" 2>/dev/null; then return 0; fi
385
+ if command -v screen >/dev/null 2>&1 && screen -ls 2>/dev/null | grep -q "[0-9]\.nao-$name\b"; then return 0; fi
386
+ return 1
387
+ }
239
388
 
240
- # 定位会话所在 tmux pane(靠启动命令里的 --name;边界避免 rd-be 误命中 rd-be-T1)
241
- find_pane_for() {
242
- command -v tmux >/dev/null 2>&1 || return 0
243
- tmux list-panes -a -F '#{pane_id} #{pane_start_command}' 2>/dev/null \
244
- | awk -v n="$1" '$0 ~ ("--name[ =]" n "([^A-Za-z0-9_-]|$)") { print $1; exit }'
389
+ # 能否可信地断言「不在线」:名册可用(完整),或本机就是 tmux/screen 宿主(句柄可枚举)
390
+ offline_verifiable() {
391
+ intercom_list_json >/dev/null 2>&1 && return 0
392
+ [[ -n "${TMUX:-}" ]] && return 0
393
+ case "${NAO_TERMINAL:-}" in tmux|screen) return 0 ;; esac
394
+ return 1
395
+ }
396
+
397
+ # 本机可见的会话名(tmux 标题 + tmux/screen 会话 + 名册),供 status 枚举;已去重
398
+ visible_session_names() {
399
+ { if command -v tmux >/dev/null 2>&1; then
400
+ tmux list-panes -a -F '#{pane_title}' 2>/dev/null \
401
+ | sed -n "s/^${PI_TITLE} - \([A-Za-z0-9_-]*\) - .*/\1/p"
402
+ tmux list-sessions -F '#{session_name}' 2>/dev/null | sed -n 's/^nao-//p'
403
+ fi
404
+ if command -v screen >/dev/null 2>&1; then
405
+ screen -ls 2>/dev/null | sed -n 's/.*[0-9]\.nao-\([A-Za-z0-9_-]*\).*/\1/p'
406
+ fi
407
+ intercom_roster 2>/dev/null | awk -F'\t' 'NF {print $1}'
408
+ } | awk '!seen[$0]++'
245
409
  }
246
410
 
247
411
  # 该 pane 末 3 行是否显示在跑 turn(启发式:状态行标记;回执/产物以 PM 核对清单为准)
@@ -252,23 +416,34 @@ pane_busy() {
252
416
  grep -qE "$CLOSE_BUSY_PATTERN" <<< "$tail3"
253
417
  }
254
418
 
255
- # 任务在 tasks-state 的归处:active(进行态)/ closed(已验收·已归档)/ absent(无记录)/ nofile
419
+ # 任务在 tasks-state 的归处:
420
+ # active(在表内进行态)/ closed(在归档区表内)/ mentioned(仅散文提及)
421
+ # / absent(全文无记录)/ nofile
422
+ # 归一处:表内单元格可选带反引号/**(常见写法 `T157` / **T157**)→ 去首尾空白与标记后比较。
423
+ # 标题级别不限(`##` 与 `###` 子表都算);归档区 = 标题含「已验收|已归档|归档」。
424
+ # 边界:先按非 [A-Za-z0-9_-] 切词再比较,避免 T15 命中 T155。
256
425
  task_state_class() {
257
426
  local id="$1"
258
427
  [[ -f "$TASKS_STATE" ]] || { echo nofile; return; }
259
428
  awk -v id="$id" '
260
- /^## / { sec=$0; sub(/^##[[:space:]]*/, "", sec); next }
429
+ function norm(s) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); gsub(/`/, "", s); gsub(/\*\*/, "", s); return s }
430
+ /^#+[[:space:]]/ { sec=$0; sub(/^#+[[:space:]]*/, "", sec); next }
261
431
  {
262
432
  n=split($0, cells, "|")
263
433
  for (i=1;i<=n;i++) {
264
- c=cells[i]; gsub(/^[[:space:]]+|[[:space:]]+$/, "", c)
265
- if (c==id) { hit=sec; break }
434
+ if (norm(cells[i]) != id) continue
435
+ if (sec ~ /已验收|已归档|归档/) arch=1; else cell=1
436
+ break
266
437
  }
438
+ line=$0; gsub(/[^A-Za-z0-9_-]/, " ", line)
439
+ m=split(line, toks, " ")
440
+ for (j=1;j<=m;j++) if (toks[j]==id) { seen=1; break }
267
441
  }
268
442
  END {
269
- if (hit=="") { print "absent"; exit }
270
- if (hit ~ /已验收|已归档/) { print "closed"; exit }
271
- print "active"
443
+ if (arch) { print "closed"; exit }
444
+ if (cell) { print "active"; exit }
445
+ if (seen) { print "mentioned"; exit }
446
+ print "absent"
272
447
  }
273
448
  ' "$TASKS_STATE"
274
449
  }
@@ -301,22 +476,11 @@ _layout_checksum() {
301
476
  printf '%04x' "$c"
302
477
  }
303
478
 
304
- # main-row2:主 pane 全高占左;往右每列 2 个上下堆叠
305
- # 1 | 2 | 4
306
- # 1 | 3 | 5
307
- # tmux 规范:相邻子 pane 之间 1 格 gap;{} 左右排列,[] 上下排列
308
- build_main_row2_layout() {
309
- local win_w="$1" win_h="$2" pct="$3"
310
- shift 3
311
- local -a panes=("$@")
312
- local n=${#panes[@]}
313
- (( n >= 1 )) || return 1
314
- if (( n == 1 )); then
315
- printf '%sx%s,0,0,%s' "$win_w" "$win_h" "${panes[0]}"
316
- return 0
317
- fi
318
-
319
- # 根:水平分割,主 pane + 右侧容器,中间 1 gap
479
+ # main-row2 / main-col 共用几何(单一源头,防守卫与构建两处漂移)
480
+ # 输入:窗口宽 win_w · pane 数 n · 主宽百分比 pct
481
+ # 输出(空格分隔):main_w right_w right_x cols cw_base cw_last
482
+ main_row2_geom() {
483
+ local win_w="$1" n="$2" pct="$3"
320
484
  local usable_w=$(( win_w - 1 ))
321
485
  (( usable_w < 2 )) && usable_w=2
322
486
  local main_w=$(( usable_w * pct / 100 ))
@@ -325,9 +489,8 @@ build_main_row2_layout() {
325
489
  local right_w=$(( usable_w - main_w ))
326
490
  (( right_w < 1 )) && right_w=1
327
491
  local right_x=$(( main_w + 1 ))
328
-
329
- # 右侧 cols 列
330
492
  local m=$(( n - 1 ))
493
+ (( m < 1 )) && m=1
331
494
  local cols=$(( (m + 1) / 2 ))
332
495
  (( cols < 1 )) && cols=1
333
496
  local r_usable=$(( right_w - (cols - 1) ))
@@ -336,6 +499,43 @@ build_main_row2_layout() {
336
499
  (( cw_base < 1 )) && cw_base=1
337
500
  local cw_last=$(( r_usable - cw_base * (cols - 1) ))
338
501
  (( cw_last < 1 )) && cw_last=1
502
+ printf '%s %s %s %s %s %s' "$main_w" "$right_w" "$right_x" "$cols" "$cw_base" "$cw_last"
503
+ }
504
+
505
+ # main-row2 最窄非主 pane 列宽(窄列守卫用;与 build 共用 main_row2_geom)
506
+ main_row2_min_col() {
507
+ local win_w="$1" n="$2" pct="$3"
508
+ (( n >= 2 )) || { printf '%s' "$win_w"; return 0; }
509
+ local main_w right_w right_x cols cw_base cw_last
510
+ read -r main_w right_w right_x cols cw_base cw_last < <(main_row2_geom "$win_w" "$n" "$pct")
511
+ if (( cw_base < cw_last )); then printf '%s' "$cw_base"; else printf '%s' "$cw_last"; fi
512
+ }
513
+
514
+ # main-col 右列宽(tmux main-vertical 百分比语义;与 main_row2_geom 的 right_w 同源)
515
+ main_col_right_w() {
516
+ local main_w right_w right_x cols cw_base cw_last
517
+ read -r main_w right_w right_x cols cw_base cw_last < <(main_row2_geom "$1" 2 "$2")
518
+ printf '%s' "$right_w"
519
+ }
520
+
521
+ # main-row2:主 pane 全高占左;往右每列 2 个上下堆叠
522
+ # 1 | 2 | 4
523
+ # 1 | 3 | 5
524
+ # tmux 规范:相邻子 pane 之间 1 格 gap;{} 左右排列,[] 上下排列
525
+ build_main_row2_layout() {
526
+ local win_w="$1" win_h="$2" pct="$3"
527
+ shift 3
528
+ local -a panes=("$@")
529
+ local n=${#panes[@]}
530
+ (( n >= 1 )) || return 1
531
+ if (( n == 1 )); then
532
+ printf '%sx%s,0,0,%s' "$win_w" "$win_h" "${panes[0]}"
533
+ return 0
534
+ fi
535
+
536
+ # 几何统一来自 main_row2_geom(守卫与构建同源,防漂移)
537
+ local main_w right_w right_x cols cw_base cw_last
538
+ read -r main_w right_w right_x cols cw_base cw_last < <(main_row2_geom "$win_w" "$n" "$pct")
339
539
 
340
540
  # 每列内部:上下 2 个,中间 1 gap
341
541
  local v_usable=$(( win_h - 1 ))
@@ -375,6 +575,31 @@ build_main_row2_layout() {
375
575
  "$right_body"
376
576
  }
377
577
 
578
+ # main-col:委托 tmux 内建 main-vertical(主 pane 左全高 + 右列纵向堆叠)
579
+ # 坑:tmux 默认 main-pane-width=80(格)⇒ 必须显式设百分比
580
+ apply_main_col() {
581
+ local pct="$1" n="$2" min_w="$3" win_w="$4" from="$5"
582
+ if (( n >= 2 )); then
583
+ local right_w; right_w="$(main_col_right_w "$win_w" "$pct")"
584
+ if (( right_w < min_w )); then
585
+ if [[ "$from" == "main-col" ]]; then
586
+ warn "main-col 右列 ${right_w} < 最小 ${min_w}(${n} pane @ ${win_w} 列)→ 回退 grid"
587
+ else
588
+ warn "${from} → main-col 右列 ${right_w} < 最小 ${min_w}(${n} pane @ ${win_w} 列)→ 再回退 grid"
589
+ fi
590
+ tmux select-layout tiled >/dev/null 2>&1 || true
591
+ return 0
592
+ fi
593
+ fi
594
+ tmux set-window-option main-pane-width "${pct}%" >/dev/null 2>&1 || true
595
+ local err
596
+ if ! err=$(tmux select-layout main-vertical 2>&1); then
597
+ warn "main-col(main-vertical)应用失败,回退 grid"
598
+ warn " tmux: ${err:-<no message>}"
599
+ tmux select-layout tiled >/dev/null 2>&1 || true
600
+ fi
601
+ }
602
+
378
603
  apply_tmux_layout() {
379
604
  [[ -n "${TMUX:-}" ]] || return 0
380
605
  if [[ "$TMUX_LAYOUT" == "grid" ]]; then
@@ -391,13 +616,32 @@ apply_tmux_layout() {
391
616
  while IFS= read -r line; do
392
617
  [[ -n "$line" ]] && pane_ids+=("$line")
393
618
  done < <(tmux list-panes -F '#{pane_id}' 2>/dev/null | sed 's/^%//')
394
- (( ${#pane_ids[@]} >= 1 )) || return 0
619
+ local n=${#pane_ids[@]}
620
+ (( n >= 1 )) || return 0
395
621
 
396
622
  local pct="$TMUX_MAIN_WIDTH"
397
623
  [[ "$pct" =~ ^[0-9]+$ ]] || pct="$TMUX_MAIN_WIDTH_DEFAULT"
398
624
  (( pct < 10 )) && pct=10
399
625
  (( pct > 90 )) && pct=90
400
626
 
627
+ local min_w="$TMUX_MIN_PANE_WIDTH"
628
+ [[ "$min_w" =~ ^[0-9]+$ ]] || min_w="$TMUX_MIN_PANE_WIDTH_DEFAULT"
629
+ (( min_w < 10 )) && min_w=10
630
+ (( min_w > 80 )) && min_w=80
631
+
632
+ if [[ "$TMUX_LAYOUT" == "main-col" ]]; then
633
+ apply_main_col "$pct" "$n" "$min_w" "$win_w" "main-col"
634
+ return
635
+ fi
636
+
637
+ # 窄列守卫:main-row2 最窄非主 pane < 阈值 ⇒ 回退 main-col(再不足⇒grid)
638
+ local narrow; narrow="$(main_row2_min_col "$win_w" "$n" "$pct")"
639
+ if (( narrow < min_w )); then
640
+ warn "${n} pane @ ${win_w} 列:main-row2 最窄列 ${narrow} < 最小 ${min_w} → 回退 main-col"
641
+ apply_main_col "$pct" "$n" "$min_w" "$win_w" "main-row2"
642
+ return
643
+ fi
644
+
401
645
  local full; full="$(build_main_row2_layout "$win_w" "$win_h" "$pct" "${pane_ids[@]}")"
402
646
  [[ -n "$full" ]] || return 0
403
647
 
@@ -414,12 +658,19 @@ apply_tmux_layout() {
414
658
  spawn_tmux() {
415
659
  local name="$1" inner="$2"
416
660
  case "$TMUX_LAYOUT" in
417
- main-row2|grid) ;;
418
- *) die "NAO_TMUX_LAYOUT 无效: $TMUX_LAYOUT(可选 main-row2|grid)" ;;
661
+ main-row2|main-col|grid) ;;
662
+ *) die "NAO_TMUX_LAYOUT 无效: $TMUX_LAYOUT(可选 main-row2|main-col|grid)" ;;
419
663
  esac
420
664
  local wrapped="bash -lc $(printf %q "$inner")"
421
665
  if [[ -n "${TMUX:-}" ]]; then
422
- tmux split-window -h "$wrapped" >/dev/null
666
+ # 窄窗口/宿主上限会让 split-window 失败(no space for new pane);
667
+ # 捕获后 warn 并继续,不因单个 pane 失败中断整轮 ensure。
668
+ local serr
669
+ if ! serr=$(tmux split-window -h "$wrapped" 2>&1); then
670
+ warn "pane 创建失败(窗口过窄或宿主已达上限):${serr:-<no message>}"
671
+ warn " 已跳过该 pane,其余角色继续拉起;可放大窗口后重跑 ensure"
672
+ return 0
673
+ fi
423
674
  apply_tmux_layout
424
675
  else
425
676
  tmux new-session -d -s "nao-$name" "$wrapped"
@@ -485,6 +736,9 @@ cmd_check() {
485
736
  fi
486
737
  check_eol || rc=1
487
738
 
739
+ echo "== 主动推送器(qq-notify,可选能力)=="
740
+ check_qq_notify || rc=1
741
+
488
742
  echo "== 角色清单 roles.yaml =="
489
743
  load_manifest
490
744
  printf ' ✓ 解析成功,%d 个角色: %s\n' "${#ROLE_ORDER[@]}" "${ROLE_ORDER[*]}"
@@ -639,18 +893,23 @@ cmd_check() {
639
893
  echo " (当前不在 tmux 内;若宿主命中 tmux 会创建 detached 会话)"
640
894
  fi
641
895
  case "$TMUX_LAYOUT" in
642
- main-row2|grid)
896
+ main-row2|main-col|grid)
643
897
  printf ' ✓ NAO_TMUX_LAYOUT=%-10s 合法\n' "$TMUX_LAYOUT" ;;
644
898
  *)
645
- printf ' ✗ NAO_TMUX_LAYOUT=%-10s 非法(可选 main-row2|grid)\n' "$TMUX_LAYOUT"; rc=1 ;;
899
+ printf ' ✗ NAO_TMUX_LAYOUT=%-10s 非法(可选 main-row2|main-col|grid)\n' "$TMUX_LAYOUT"; rc=1 ;;
646
900
  esac
647
- if [[ "$TMUX_LAYOUT" == "main-row2" ]]; then
901
+ if [[ "$TMUX_LAYOUT" == "main-row2" || "$TMUX_LAYOUT" == "main-col" ]]; then
648
902
  if [[ "$TMUX_MAIN_WIDTH" =~ ^[0-9]+$ ]] && (( TMUX_MAIN_WIDTH >= 10 && TMUX_MAIN_WIDTH <= 90 )); then
649
903
  printf ' ✓ NAO_TMUX_MAIN_WIDTH=%-3s%% 合法\n' "$TMUX_MAIN_WIDTH"
650
904
  else
651
905
  printf ' ! NAO_TMUX_MAIN_WIDTH=%-3s 非法(10..90):越界夹取到 10/90,非数字回退 %s\n' "$TMUX_MAIN_WIDTH" "$TMUX_MAIN_WIDTH_DEFAULT"
652
906
  fi
653
907
  fi
908
+ if [[ "$TMUX_MIN_PANE_WIDTH" =~ ^[0-9]+$ ]] && (( TMUX_MIN_PANE_WIDTH >= 10 && TMUX_MIN_PANE_WIDTH <= 80 )); then
909
+ printf ' ✓ NAO_TMUX_MIN_PANE_WIDTH=%-3s 合法\n' "$TMUX_MIN_PANE_WIDTH"
910
+ else
911
+ printf ' ! NAO_TMUX_MIN_PANE_WIDTH=%-3s 非法(10..80):越界夹取到 10/80,非数字回退 %s\n' "$TMUX_MIN_PANE_WIDTH" "$TMUX_MIN_PANE_WIDTH_DEFAULT"
912
+ fi
654
913
 
655
914
  if [[ "$strict" == "true" && $wl_problems -gt 0 ]]; then
656
915
  warn "strict 模式:白名单发现 $wl_problems 项问题(空条目/重复/过宽)"
@@ -663,33 +922,33 @@ cmd_check() {
663
922
  # ---------------------------------------------------------------------------
664
923
  cmd_status() {
665
924
  local a
666
- echo "== 角色会话在线状态(权威名单以 intercom({action:'list'}) 为准)=="
925
+ intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存(后续子 shell 复用)
926
+ echo "== 角色会话在线状态(权威名单见 intercom({action:'list'});本表按终端标题/名册判定)=="
667
927
  for a in "${ROLE_ORDER[@]}"; do
668
- if running "$a"; then
928
+ if running "$a" "${ROLE_WS[$a]:-}"; then
669
929
  printf ' ✓ %-14s 在线(--name %s;卡片 %s)\n' "$a" "$a" "${ROLE_CARDS[$a]}"
670
930
  else
671
931
  printf ' · %-14s 未运行(ensure 拉起)\n' "$a"
672
932
  fi
673
933
  done
674
- echo "== 任务派生会话(--task 拉起,如 rd-be-T1)=="
675
- local found=0 line dname role id cls tag
676
- while IFS= read -r line; do
677
- [[ -n "$line" ]] || continue
678
- dname="$(awk '{for(i=1;i<=NF;i++) if($i=="--name") {print $(i+1); exit}}' <<< "$line")"
934
+ echo "== 任务派生会话(--task 拉起,如 rd-be-T1 / rd-infra-T1)=="
935
+ local found=0 dname role id cls tag
936
+ while IFS= read -r dname; do
679
937
  [[ -n "$dname" ]] || continue
680
- id=""; tag=""
938
+ id=""
681
939
  for role in "${ROLE_ORDER[@]}"; do [[ "$dname" == "$role-"* ]] && { id="${dname#"$role"-}"; break; }; done
682
- if [[ -n "$id" ]]; then
683
- cls="$(task_state_class "$id")"
684
- case "$cls" in
685
- closed) tag=" ! 残留($id 已归档)→ close --task $id ${dname%-*}" ;;
686
- absent) tag=" ! 残留(tasks-state 无 $id 记录)→ 核对后 close --task $id ${dname%-*}" ;;
687
- active) tag=" · $id 进行态" ;;
688
- esac
689
- fi
940
+ [[ -n "$id" ]] || continue
941
+ cls="$(task_state_class "$id")"
942
+ tag=""
943
+ case "$cls" in
944
+ closed) tag=" ! 残留($id 已归档)→ close --task $id ${dname%-*}" ;;
945
+ active) tag=" · $id 进行态" ;;
946
+ mentioned) tag=" ? $id 仅散文提及(非表内记录)→ 核对后 close --task $id ${dname%-*}" ;;
947
+ absent) tag=" ! 残留(tasks-state 无 $id 记录)→ 核对后 close --task $id ${dname%-*}" ;;
948
+ esac
690
949
  printf ' · %s%s\n' "$dname" "$tag"
691
950
  found=1
692
- done < <(pgrep -af "pi[[:space:]].*--name (pm|arch-designer|rd-fe|rd-be|qa)-[A-Za-z0-9_-]+" 2>/dev/null | head -10)
951
+ done < <(visible_session_names)
693
952
  (( found )) || echo ' (无)'
694
953
  [[ -f "$TASKS_STATE" ]] || echo " ($TASKS_STATE 不存在,残留判定已跳过)"
695
954
  }
@@ -698,8 +957,9 @@ cmd_status() {
698
957
  # 回收已完成会话:闸门(tasks-state 已推进 + 无在跑 turn)+ 落地(pane/会话/screen/进程)
699
958
  cmd_close() {
700
959
  local force="$1" task="$2" target="$3"
701
- [[ -n "$target" ]] || die "close 需要目标:角色别名或派生会话名(如 rd-be / rd-be-T1)"
960
+ [[ -n "$target" ]] || die "close 需要目标:角色别名或派生会话名(如 rd-be / rd-be-T1 或 rd-infra / rd-infra-T1)"
702
961
  local name repo cls bus pane pids r ok
962
+ intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存
703
963
  if [[ -n "$task" ]]; then
704
964
  resolve_role "$target"
705
965
  name="${NAME}-${task}"
@@ -711,7 +971,8 @@ cmd_close() {
711
971
  warn " 先把 $task 移入「已验收/已归档」再回收(或 --force:将丢失打回返工所需上下文)"
712
972
  return 1
713
973
  fi
714
- [[ "$cls" == "absent" ]] && warn "tasks-state 无 $task 记录(仅按会话名回收)"
974
+ [[ "$cls" == "absent" ]] && warn "tasks-state 全文无 $task 记录(仅按会话名回收)"
975
+ [[ "$cls" == "mentioned" ]] && log "tasks-state 仅散文提及 $task(非表内记录,不阻塞回收)"
715
976
  [[ "$cls" == "nofile" ]] && log "未启用 $TASKS_STATE(跳过状态闸门)"
716
977
  elif [[ -n "${ALIAS_ROLE[$target]:-}" ]]; then
717
978
  name="${ALIAS_ROLE[$target]}"; repo="${ROLE_WS[$name]:-$PWD}"
@@ -722,14 +983,22 @@ cmd_close() {
722
983
  # 非别名的目标必须是 <已知角色>-<编号>,否则视为拼错(防静默 no-op)
723
984
  ok=0
724
985
  for r in "${ROLE_ORDER[@]}"; do [[ "$name" == "$r-"* ]] && ok=1; done
725
- (( ok )) || die "未知角色或派生会话名: $name(可用: ${ROLE_ORDER[*]};派生名形如 rd-be-T1)"
986
+ (( ok )) || die "未知角色或派生会话名: $name(可用: ${ROLE_ORDER[*]};派生名形如 rd-be-T1 / rd-infra-T1)"
726
987
  repo="$PWD"
727
988
  fi
728
989
 
729
- running "$name" || { log "$name 未运行(无需回收)"; return 0; }
990
+ if ! running "$name" "$repo"; then
991
+ if offline_verifiable; then
992
+ log "$name 未运行(无需回收)"; return 0
993
+ fi
994
+ warn "无法确认 $name 在线状态:宿主无 tmux/screen 句柄,且 pi-intercom 名册不可用"
995
+ warn " pi 启动后 argv 被改写为 \"pi\",进程扫描不可信 ⇒ 不做静默 no-op(本命令 exit 非 0)"
996
+ warn " 请用 intercom list 人工核对;确认已退出后忽略本提示,或用 --force 跳过闸门"
997
+ return 1
998
+ fi
730
999
 
731
1000
  # 闸门②:在跑 turn(tmux 可判;非 tmux 宿主无法判 → 需 --force)
732
- pane="$(find_pane_for "$name")"
1001
+ pane="$(find_pane_for "$name" "$repo")"
733
1002
  if [[ -n "$pane" ]]; then
734
1003
  pane_busy "$pane" && bus="tmux pane $pane 末行显示在跑 turn"
735
1004
  else
@@ -750,7 +1019,7 @@ cmd_close() {
750
1019
  if command -v screen >/dev/null 2>&1 && screen -ls 2>/dev/null | grep -q "nao-$name" && screen -S "nao-$name" -X quit 2>/dev/null; then
751
1020
  log "已回收 $name(screen 会话 nao-$name)@ $repo"; return 0
752
1021
  fi
753
- pids="$(pi_pids_for "$name" | tr '\n' ' ')"
1022
+ pids="$(pi_pids_for "$name" "$repo" | tr '\n' ' ')"
754
1023
  if [[ -n "$pids" ]] && kill $pids 2>/dev/null; then
755
1024
  log "已回收 $name(结束进程: $pids)@ $repo"; return 0
756
1025
  fi
@@ -762,7 +1031,7 @@ cmd_ensure() {
762
1031
  local force="$1" model="$2" task="$3"; shift 3
763
1032
  local spec role repo key seen k
764
1033
  local -a cg_done=()
765
- [[ $# -eq 0 ]] && die "ensure 需要至少一个角色,如: nao-fleet.sh ensure arch rd-fe"
1034
+ [[ $# -eq 0 ]] && die "ensure 需要至少一个角色,如: nao-fleet.sh ensure arch rd-fe rd-infra"
766
1035
  for spec in "$@"; do
767
1036
  if [[ "$spec" == *"@"* ]]; then
768
1037
  role="${spec%%@*}"; repo="${spec#*@}"
@@ -778,8 +1047,8 @@ cmd_ensure() {
778
1047
  key="$repo"; seen=0
779
1048
  for k in "${cg_done[@]:-}"; do [[ "$k" == "$key" ]] && seen=1; done
780
1049
  if (( ! seen )); then check_codegraph "$repo"; cg_done+=("$key"); fi
781
- if [[ "$force" != "true" ]] && running "$disp"; then
782
- warn "$disp 已在运行(--name 识别),跳过;确需重开请加 --force"
1050
+ if [[ "$force" != "true" ]] && running "$disp" "$repo"; then
1051
+ warn "$disp 已在运行(终端标题/名册命中),跳过;确需重开请加 --force"
783
1052
  continue
784
1053
  fi
785
1054
  spawn_one "$disp" "$repo" "$model"