@nathan33/nao-skill 0.7.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
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
8
  # nao-fleet.sh status 角色会话在线状态(按终端标题/名册判定;权威名单见 intercom list)
9
9
  # nao-fleet.sh ensure <别名>[@<repo>] [更多...] 拉起角色窗口(默认工作区=roles.yaml workspace)
@@ -14,12 +14,13 @@
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 状态行标记)
@@ -35,10 +36,16 @@
35
36
  # tmux 宿主行为
36
37
  # - 已在 tmux 内($TMUX 存在):当前窗口分屏拉起,不新建窗口。
37
38
  # - 不在 tmux 内:创建 detached 会话 nao-<角色>,需 tmux attach -t nao-<角色>。
38
- # - main-row2:第 1 个 pane 全高占左,后续每角色往右开列、每列上下 2 个:
39
+ # - main-row2:第 1 个 pane 全高占左,后续每角色往右开列、每列上下 2 个(默认):
39
40
  # 1 | 2 | 4
40
41
  # 1 | 3 | 5
41
- # - 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)。
42
49
  # =============================================================================
43
50
  set -euo pipefail
44
51
 
@@ -57,6 +64,9 @@ TMUX_LAYOUT="${NAO_TMUX_LAYOUT:-main-row2}"
57
64
  # main-row2 主 pane 宽度百分比:默认值与非法值回退共用同一常量(防三处漂移)
58
65
  TMUX_MAIN_WIDTH_DEFAULT=35
59
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}"
60
70
  # check 输出契约:默认单行摘要(省 PM 上下文),-v 展开完整报告;失败始终展开
61
71
  VERBOSE=false
62
72
  # close 的在跑 turn 判定(pi 默认状态行:Working (esc to interrupt) / Thinking... / Retrying / Compacting)
@@ -192,6 +202,50 @@ check_roles_indent() {
192
202
  ' "$MANIFEST"
193
203
  }
194
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
+
195
249
  # CodeGraph 索引健康(ensure 拉起前 / check 用;缺失或过期仅 warn,不阻塞)
196
250
  check_codegraph() {
197
251
  local repo="$1" name st
@@ -422,22 +476,11 @@ _layout_checksum() {
422
476
  printf '%04x' "$c"
423
477
  }
424
478
 
425
- # main-row2:主 pane 全高占左;往右每列 2 个上下堆叠
426
- # 1 | 2 | 4
427
- # 1 | 3 | 5
428
- # tmux 规范:相邻子 pane 之间 1 格 gap;{} 左右排列,[] 上下排列
429
- build_main_row2_layout() {
430
- local win_w="$1" win_h="$2" pct="$3"
431
- shift 3
432
- local -a panes=("$@")
433
- local n=${#panes[@]}
434
- (( n >= 1 )) || return 1
435
- if (( n == 1 )); then
436
- printf '%sx%s,0,0,%s' "$win_w" "$win_h" "${panes[0]}"
437
- return 0
438
- fi
439
-
440
- # 根:水平分割,主 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"
441
484
  local usable_w=$(( win_w - 1 ))
442
485
  (( usable_w < 2 )) && usable_w=2
443
486
  local main_w=$(( usable_w * pct / 100 ))
@@ -446,9 +489,8 @@ build_main_row2_layout() {
446
489
  local right_w=$(( usable_w - main_w ))
447
490
  (( right_w < 1 )) && right_w=1
448
491
  local right_x=$(( main_w + 1 ))
449
-
450
- # 右侧 cols 列
451
492
  local m=$(( n - 1 ))
493
+ (( m < 1 )) && m=1
452
494
  local cols=$(( (m + 1) / 2 ))
453
495
  (( cols < 1 )) && cols=1
454
496
  local r_usable=$(( right_w - (cols - 1) ))
@@ -457,6 +499,43 @@ build_main_row2_layout() {
457
499
  (( cw_base < 1 )) && cw_base=1
458
500
  local cw_last=$(( r_usable - cw_base * (cols - 1) ))
459
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")
460
539
 
461
540
  # 每列内部:上下 2 个,中间 1 gap
462
541
  local v_usable=$(( win_h - 1 ))
@@ -496,6 +575,31 @@ build_main_row2_layout() {
496
575
  "$right_body"
497
576
  }
498
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
+
499
603
  apply_tmux_layout() {
500
604
  [[ -n "${TMUX:-}" ]] || return 0
501
605
  if [[ "$TMUX_LAYOUT" == "grid" ]]; then
@@ -512,13 +616,32 @@ apply_tmux_layout() {
512
616
  while IFS= read -r line; do
513
617
  [[ -n "$line" ]] && pane_ids+=("$line")
514
618
  done < <(tmux list-panes -F '#{pane_id}' 2>/dev/null | sed 's/^%//')
515
- (( ${#pane_ids[@]} >= 1 )) || return 0
619
+ local n=${#pane_ids[@]}
620
+ (( n >= 1 )) || return 0
516
621
 
517
622
  local pct="$TMUX_MAIN_WIDTH"
518
623
  [[ "$pct" =~ ^[0-9]+$ ]] || pct="$TMUX_MAIN_WIDTH_DEFAULT"
519
624
  (( pct < 10 )) && pct=10
520
625
  (( pct > 90 )) && pct=90
521
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
+
522
645
  local full; full="$(build_main_row2_layout "$win_w" "$win_h" "$pct" "${pane_ids[@]}")"
523
646
  [[ -n "$full" ]] || return 0
524
647
 
@@ -535,12 +658,19 @@ apply_tmux_layout() {
535
658
  spawn_tmux() {
536
659
  local name="$1" inner="$2"
537
660
  case "$TMUX_LAYOUT" in
538
- main-row2|grid) ;;
539
- *) 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)" ;;
540
663
  esac
541
664
  local wrapped="bash -lc $(printf %q "$inner")"
542
665
  if [[ -n "${TMUX:-}" ]]; then
543
- 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
544
674
  apply_tmux_layout
545
675
  else
546
676
  tmux new-session -d -s "nao-$name" "$wrapped"
@@ -606,6 +736,9 @@ cmd_check() {
606
736
  fi
607
737
  check_eol || rc=1
608
738
 
739
+ echo "== 主动推送器(qq-notify,可选能力)=="
740
+ check_qq_notify || rc=1
741
+
609
742
  echo "== 角色清单 roles.yaml =="
610
743
  load_manifest
611
744
  printf ' ✓ 解析成功,%d 个角色: %s\n' "${#ROLE_ORDER[@]}" "${ROLE_ORDER[*]}"
@@ -760,18 +893,23 @@ cmd_check() {
760
893
  echo " (当前不在 tmux 内;若宿主命中 tmux 会创建 detached 会话)"
761
894
  fi
762
895
  case "$TMUX_LAYOUT" in
763
- main-row2|grid)
896
+ main-row2|main-col|grid)
764
897
  printf ' ✓ NAO_TMUX_LAYOUT=%-10s 合法\n' "$TMUX_LAYOUT" ;;
765
898
  *)
766
- 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 ;;
767
900
  esac
768
- if [[ "$TMUX_LAYOUT" == "main-row2" ]]; then
901
+ if [[ "$TMUX_LAYOUT" == "main-row2" || "$TMUX_LAYOUT" == "main-col" ]]; then
769
902
  if [[ "$TMUX_MAIN_WIDTH" =~ ^[0-9]+$ ]] && (( TMUX_MAIN_WIDTH >= 10 && TMUX_MAIN_WIDTH <= 90 )); then
770
903
  printf ' ✓ NAO_TMUX_MAIN_WIDTH=%-3s%% 合法\n' "$TMUX_MAIN_WIDTH"
771
904
  else
772
905
  printf ' ! NAO_TMUX_MAIN_WIDTH=%-3s 非法(10..90):越界夹取到 10/90,非数字回退 %s\n' "$TMUX_MAIN_WIDTH" "$TMUX_MAIN_WIDTH_DEFAULT"
773
906
  fi
774
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
775
913
 
776
914
  if [[ "$strict" == "true" && $wl_problems -gt 0 ]]; then
777
915
  warn "strict 模式:白名单发现 $wl_problems 项问题(空条目/重复/过宽)"
@@ -793,7 +931,7 @@ cmd_status() {
793
931
  printf ' · %-14s 未运行(ensure 拉起)\n' "$a"
794
932
  fi
795
933
  done
796
- echo "== 任务派生会话(--task 拉起,如 rd-be-T1)=="
934
+ echo "== 任务派生会话(--task 拉起,如 rd-be-T1 / rd-infra-T1)=="
797
935
  local found=0 dname role id cls tag
798
936
  while IFS= read -r dname; do
799
937
  [[ -n "$dname" ]] || continue
@@ -819,7 +957,7 @@ cmd_status() {
819
957
  # 回收已完成会话:闸门(tasks-state 已推进 + 无在跑 turn)+ 落地(pane/会话/screen/进程)
820
958
  cmd_close() {
821
959
  local force="$1" task="$2" target="$3"
822
- [[ -n "$target" ]] || die "close 需要目标:角色别名或派生会话名(如 rd-be / rd-be-T1)"
960
+ [[ -n "$target" ]] || die "close 需要目标:角色别名或派生会话名(如 rd-be / rd-be-T1 或 rd-infra / rd-infra-T1)"
823
961
  local name repo cls bus pane pids r ok
824
962
  intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存
825
963
  if [[ -n "$task" ]]; then
@@ -845,7 +983,7 @@ cmd_close() {
845
983
  # 非别名的目标必须是 <已知角色>-<编号>,否则视为拼错(防静默 no-op)
846
984
  ok=0
847
985
  for r in "${ROLE_ORDER[@]}"; do [[ "$name" == "$r-"* ]] && ok=1; done
848
- (( ok )) || die "未知角色或派生会话名: $name(可用: ${ROLE_ORDER[*]};派生名形如 rd-be-T1)"
986
+ (( ok )) || die "未知角色或派生会话名: $name(可用: ${ROLE_ORDER[*]};派生名形如 rd-be-T1 / rd-infra-T1)"
849
987
  repo="$PWD"
850
988
  fi
851
989
 
@@ -862,7 +1000,13 @@ cmd_close() {
862
1000
  # 闸门②:在跑 turn(tmux 可判;非 tmux 宿主无法判 → 需 --force)
863
1001
  pane="$(find_pane_for "$name" "$repo")"
864
1002
  if [[ -n "$pane" ]]; then
865
- pane_busy "$pane" && bus="tmux pane $pane 末行显示在跑 turn"
1003
+ # 显式双分支赋值:pane 空闲时 bus 必须落为 ""。
1004
+ # 曾用 `pane_busy "$pane" && bus=…`:pane_busy 为假时 && 短路,bus 在 set -u 下未绑定 → 行 1007 崩溃。
1005
+ if pane_busy "$pane"; then
1006
+ bus="tmux pane $pane 末行显示在跑 turn"
1007
+ else
1008
+ bus=""
1009
+ fi
866
1010
  else
867
1011
  bus="非 tmux 宿主,无法确认是否在跑 turn"
868
1012
  fi
@@ -893,7 +1037,7 @@ cmd_ensure() {
893
1037
  local force="$1" model="$2" task="$3"; shift 3
894
1038
  local spec role repo key seen k
895
1039
  local -a cg_done=()
896
- [[ $# -eq 0 ]] && die "ensure 需要至少一个角色,如: nao-fleet.sh ensure arch rd-fe"
1040
+ [[ $# -eq 0 ]] && die "ensure 需要至少一个角色,如: nao-fleet.sh ensure arch rd-fe rd-infra"
897
1041
  for spec in "$@"; do
898
1042
  if [[ "$spec" == *"@"* ]]; then
899
1043
  role="${spec%%@*}"; repo="${spec#*@}"
@@ -0,0 +1,199 @@
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
+ })
@@ -0,0 +1,92 @@
1
+ ---
2
+ description: 按需技能——PM 运行手册(调度细则 / 开工确认卡 / 会话生命周期 / AGENTS.md 治理 / 交付归档)
3
+ ---
4
+
5
+ # PM 运行手册
6
+
7
+ > 触发:**调度与运营操作**(拉起会话、派发、重开、建/改 `AGENTS.md`、归档)时读取;身份/边界/红线常驻在角色卡。
8
+ > 角色路由与会话登记在 @.agents/skills/pm-routing.md;需求分支与 PR 流水线在 @.agents/skills/github-flow.md。
9
+
10
+ ## 一、多会话调度细则
11
+
12
+ - **准入判定**:先 `intercom status` + `list`;不可用立即降级不重试。
13
+ - **会话登记**:用户提供名称**及角色标注**(`fe-dev`/`be-dev`/`qa`/`infra`);**未标注禁止猜测**,必须补问。
14
+ - **舰队启动**:不在线先拉起再 `list` 验证(`ask` 仅对在线会话):`bash .agents/scripts/nao-fleet.sh ensure <别名>[@<repo目录>]`;项目独立于 nao-skills 仓库时用 `bash "$NAO_SKILLS/.agents/scripts/nao-fleet.sh" ...`。
15
+ - **环境自检(开工确认后、派发前)**:`bash .agents/scripts/nao-fleet.sh check`——默认单行摘要(完整报告不进 PM 上下文),`warn>0` 或失败加 `-v`;**exit code 非 0(硬错误)→ 停止派发**(白名单 / CodeGraph 处置见 intercom-protocol「开工前自检」)。
16
+ - **架构评审闸门**:PRD 涉及架构/NFR/选型时,开工确认后、派发前 `send` arch-designer 评审。纯 CRUD 跳过。
17
+ - **派发协议(省 token)**:一条消息给 `编号 + 范围 + 引用路径(docs/...md#section)+ AC 编号 + 回执级别 + 需求分支(feat/<issue-id>-<slug>)`;**不贴 PRD/方案全文、不用 `attachments` 传全文**(worker 自己读文件,PM 上下文不留副本);worker `ask` → PM `reply`。
18
+ - **忙闲闸门**:`list` 见目标在线**且 `idle`** 才派完整任务;忙碌(`thinking`/`tool:*`)→ **不投递任务内容**,记入待派发队列(`docs/tasks-state.md`),转 `idle` 再派;**紧急**才抢占(交互会话可 steer 注入并注明 `紧急抢占:…回执须报告挂起任务状态`;非交互会话只能排队)。
19
+ - **任务派生会话(并行隔离)**:`ensure <role>@<repo> --task <编号>` → `--name <角色>-<编号>`(如 `rd-be-T1`);**验收通过后回收**:`nao-fleet.sh close --task <编号> <别名>`(工具内置「在跑 turn / tasks-state 未推进」双闸门,后者防验收未过就回收丢返工上下文);回执 role 写派生名。
20
+ - **状态外部化 + 接续快照(可恢复)**:任务状态落盘 `docs/tasks-state.md`(五栏 + 顶部「PM 接续快照」;骨架见 @.agents/templates/tasks-state.md.example),每次派发/回执/验收/抢占后更新;重开后**先读该文件重建状态**,不依赖历史消息。
21
+ - **终态回执闸门**:每次派发(含架构评审)必须收到 `[编号] done` 回执(两级:默认 `done(lite)`;有阻塞/风险/需决策 `done(full)`,模板见 @.agents/checklists/comm-templates.md);未见回执(含 arch 静默)→ 主动追讨,必要时上报用户,**不默认成功**。
22
+ - **残留自检**:`nao-fleet.sh status` 标出派生会话残留(tasks-state 已归档 / 无记录);发现即核对状态并 `close --task <编号> <别名>` 回收。
23
+ - **会话回收与收窗**:派生会话验收通过即 `close`;**常驻会话不回收**(保留复用,上下文变长时 `ensure --force` 重开);手工关窗前按 checklists/pm.md「会话回收 / 收窗核对」逐项核对(工具不替代回执与产物核对)。
24
+ - **角色提示词加载**:fleet 拉起的会话已启动期注入,派发只要求回执 `已按 <role> 角色执行`;仅对用户手工开、未注入的会话才指示加载 `@.agents/prompts/<role>.md`。
25
+ - **验收闭环(省 token)**:核对 AC 五覆盖 + 回执「清单」字段(未核对则打回);验收 = 核对回执**全量门禁精确数字** + 读变更文件核对 AC + **异常才复跑/抽查**(**不可改码**、**不重复跑 worker 已跑的门禁**);读码用 `codegraph node --file <f> --offset <n> --limit <m>` 行段,**禁 cat 全文**;依赖 QA 先行用例。
26
+ - **合并闸门(PM 控制合并时间点)**:工作期 RD 只在需求分支 `feat/<issue-id>-<slug>`(降级 `nao/<批次-slug>`)提交(`wip(<编号>):` 检查点、路径级暂存、禁 `-A`)。**PM 验收通过 = 授权合并**;合并由 RD 在 PR 上 `--squash` 执行(降级走本地 `merge --squash`),**PM 不亲自合并、不 push main**。验收核对:PR 标题/信息**用户可读** + main 上本需求**恰好 1 条提交且无 `wip()`** + 工作区干净。特例白名单与流水线见 @.agents/skills/github-flow.md。
27
+
28
+ ## 二、开工确认卡(强制闸门)
29
+
30
+ 表格输出,末尾询问「是否开工?会话/角色是否如上?」:
31
+
32
+ | 任务编号 | 目标会话 | 角色 | 任务概要 | 对应 AC |
33
+ | :--- | :--- | :--- | :--- | :--- |
34
+ | T1 | rd-be | 后端 | ... | AC1/AC3 |
35
+
36
+ 分支:`feat/<issue-id>-<slug>`(降级 `nao/<批次-slug>`)· PR owner:<角色> · Reviewer:<arch-designer / 无> · 预览环境:<URL / 无>
37
+
38
+ 建议顺序:**QA 先行用例 → 前后端并行**。
39
+
40
+ 用户指定模型时在开工确认卡另起一行注明(如 `rd-be: deepseek-v4-flash:high`);**未指定禁止传 `--model`、禁止继承 PM 模型**(白名单由 `nao-fleet.sh` 校验,PM 只需如实转达)。
41
+
42
+ ## 三、PM 会话生命周期(重开纪律,PM 专属)
43
+
44
+ PM 是舰队唯一常驻长寿会话:上下文过长时丢的是**纪律**(红线/闸门遗漏且无感),因此不靠「记得住」,靠**批次边界重开 + 落盘接续**。**三个触发点,任一命中即 `ensure --force pm`**:
45
+
46
+ 1. 交付归档完成(批次终态,默认触发);
47
+ 2. 用户切入新需求、上一需求已闭环;
48
+ 3. `/session` 显示 contextTokens 超上下文窗口 40%(主动交接,不等自动压缩)。
49
+
50
+ - **重开前**:更新 `docs/tasks-state.md` 顶部「PM 接续快照」(当前阶段 / 当前 PRD / 未决决策 / 待用户回答 / 下次唤醒条件 / 会话体检)。
51
+ - **重开后**:读 tasks-state(含接续快照)+ `docs/prds/README.md` 索引 → **向用户回读确认 3 行**(当前阶段 · 未决决策 · 口头约束),确认后方可调度;**禁止凭残缺记忆调度**。
52
+ - **自动压缩只作兜底**:LLM 摘要**有损且不可审计**(决策点最易丢)、额外花 token、禁用该次 prompt-cache 写——它救的是溢出,不是记忆。
53
+
54
+ ## 四、AGENTS.md 项目上下文治理(PM 独有职责)
55
+
56
+ 项目根 `AGENTS.md` 是**项目级单一事实来源**(pi 自动加载进所有会话上下文,与 CLAUDE.md 兼容)。PM 负责建立与维护——让任何角色进入项目即获得一致的项目属性与约束。
57
+
58
+ **内容范围(只放项目级;它常驻上下文,必须精简)**
59
+
60
+ - 项目属性:仓库结构 / 技术栈与版本 / 构建·测试·lint 命令 / 运行方式 / 环境变量 / 目录约定。
61
+ - 项目约束:领域红线、依赖与命令纪律、与 nao 机制衔接(CodeGraph 索引位置、UI tokens/ux-playbook 路径、checklists 位置)。
62
+ - **指针而非复制**:角色级/团队级内容留在 nao-skills(roles.yaml / common / checklists),AGENTS.md 只写「在哪里」。
63
+
64
+ **接入已有 AGENTS.md(项目已存在且非空时)**
65
+
66
+ - **合并不覆盖**:原文件是项目既有资产,PM 无权单方面改写;先读全文判断来源(手写规范 / 工具生成)。
67
+ - **在 pi 实际加载的文件里合并**:pi 按 `AGENTS.override.md → AGENTS.md → CLAUDE.md` 只取第一个存在者;在**被加载的那个文件**里追加,而非新建遮蔽文件。
68
+ - **保留原文 + 追加接入区块**:原内容原样保留,末尾追加 `## nao 舰队接入(YYYY-MM-DD)` 区块,含:角色清单指针(`$NAO_SKILLS/.agents/roles.yaml`)、机制衔接(CodeGraph / checklists / UI tokens)、命令纪律;区块加注释标记便于未来移除(可回退)。
69
+ - **同主题冲突**:原文件条目优先(它是项目既定决策);nao 需要的补充以接入区块承载,不混改原文措辞。
70
+ - **删除/改写原文须用户确认**:与零代码边界同源——PM 只增不改既有资产,除非用户明确授权。
71
+ - **验收**:合并后 `nao-fleet.sh check` 通过;任意会话进项目可复述 AGENTS.md 中的项目约束。
72
+
73
+ **更新时机**
74
+
75
+ - 项目初始化(首个 PRD 定稿后**当日建立**)。
76
+ - **用户口头约束即时落盘**:用户的任何约束性表述(如「这个项目不许用 ORM」)**当场**写入 `AGENTS.md`(项目级)或 PRD「变更治理」(需求级),**不等归档**——不落盘则 PM 重开后无法恢复。
77
+ - 关键技术决策(ADR 归档时同步一条)。
78
+ - 约束/命令/结构变化随改随更;交付归档时顺带核对(见本文件 §五)。
79
+
80
+ **自查(PM 交付前)**
81
+
82
+ - [ ] AGENTS.md 掺入角色级/团队级内容?(应留在 nao-skills)
83
+ - [ ] AGENTS.md 超长(>120 行)或含易变细节?(应精简/移除)
84
+ - [ ] 技术决策或约束变化后未同步 AGENTS.md?
85
+
86
+ ## 五、交付归档
87
+
88
+ - 时机:PRD 完成且交付闭环后**当日**归档。
89
+ - 落盘:`docs/prds/YYYY-MM-DD-<主题>.md`。
90
+ - 内容:背景(5 Whys 摘要) → 目标指标与埋点 → 9 模块要点 → 决策留痕(RICE/取舍/回滚) → 参考来源(`docs/research/` 路径) → 派发记录(含需求分支、PR 编号、main 合并 commit hash) → 验收结果 → 变更记录 → 遗留项。
91
+ - 首次归档创建 `docs/prds/README.md` 索引(日期 | 主题 | 交付 | 终签状态)。
92
+ - 归档同时:更新 `docs/tasks-state.md` 接续快照;「派发记录」末行记**会话体检**(`contextTokens / 压缩次数 / cacheRead 占比`,取自 `/session`);随后按本文件 §三「PM 会话生命周期」重开 PM 会话(归档 = 批次终态)。