@nathan33/nao-skill 0.7.0 → 0.7.1

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.
@@ -26,7 +26,7 @@ description: pi-intercom 多会话协作协议(常驻引用)
26
26
 
27
27
  - **任务派生**:`ensure --task <编号> <别名>[@<repo>]` 创建 `<角色>-<编号>` 独立会话(如 `rd-be-T1`),并行隔离、互不排队/打断,避免多任务同名冲突;任务闭环即**回收**:`nao-fleet.sh close --task <编号> <别名>`(内置在跑 turn / tasks-state 闸门)。
28
28
 
29
- - **判重**:脚本以 `--name <别名>` 判在线;已运行则跳过并 warn。确需重开加 `--force`。
29
+ - **判重 / 在线判定**:脚本按 pi 自设的**终端标题**(`π - <会话名> - <仓库名>`)+ `pi-intercom` 名册判在线(**不用 `pgrep --name`**:pi 启动后 argv 被改写为 `pi`,会恒失配);已运行则跳过并 warn。确需重开加 `--force`。
30
30
  - **tmux 宿主**:`$TMUX` 存在时在当前窗口分屏拉起(默认布局 `main-row2`:
31
31
  首 pane 全高占左,后续每角色往右开列、每列上下 2 个);不在 tmux 内则创建
32
32
  detached 会话,需 `tmux attach -t nao-<别名>`。
@@ -69,7 +69,7 @@ bash .agents/scripts/nao-fleet.sh check
69
69
  - **离线检测与恢复**:`list` 发现已派发目标从在线变离线(会话死亡/关闭)→ 任务标记「挂起(离线)」入 tasks-state → `ensure --force` 重拉该角色 → `send` 询问进度或按挂起快照重派。
70
70
  - **队列唤醒(不单独轮询)**:每次收到回执/汇报/用户输入时,顺带 `list` 检查待派发队列目标是否 `idle`,是则派发——避免排队任务因无人唤醒而悬置。
71
71
  - **降级回流**:降级交付(intercom 不可用)的结果经用户转交后,PM 补登记 tasks-state 与归档(`docs/`),标注「降级回流」,恢复状态机一致性。
72
- - **单 PM 纪律**:同一项目同一时刻**仅一个 PM 调度会话**(fleet 按 `--name pm` 判重;手动会话请用别名 `pm`),避免双头调度互相覆盖 tasks-state。
72
+ - **单 PM 纪律**:同一项目同一时刻**仅一个 PM 调度会话**(fleet 按会话名 `pm` 判重;手动会话请用别名 `pm`),避免双头调度互相覆盖 tasks-state。
73
73
 
74
74
  ## 终态回执(硬性闸门,所有角色适用)
75
75
 
@@ -5,7 +5,7 @@
5
5
  # 用法
6
6
  # nao-fleet.sh check [--strict] [-v] 静态体检:roles.yaml/缩进/EOL/角色卡/交叉引用/PR 模板/白名单/布局
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 <别名>-<编号>(并行隔离,避免同名冲突)
@@ -25,6 +25,13 @@
25
25
  # NAO_CLOSE_BUSY_PATTERN=<ERE> close 的在跑 turn 判定正则(默认内置 pi 状态行标记)
26
26
  # NAO_TASKS_STATE=<path> 任务状态文件(默认 docs/tasks-state.md,供残留检测/回收闸门)
27
27
  #
28
+ # 在线判定(status / close / ensure 判重共用)
29
+ # pi 启动后用 OSC 0 把终端标题设为 "π - <会话名> - <cwd basename>" 并改写 argv
30
+ # (/proc/<pid>/cmdline 只剩 "pi")⇒ 判定一律面向「终端标题 / pi-intercom 名册 /
31
+ # tmux·screen 会话名」,不使用 `pgrep --name`(历史假阴性根因)。
32
+ # 名册经 $PI_AGENT_DIR/npm/node_modules/pi-intercom/cli.ts(一次调用、进程内缓存),
33
+ # 不可用时回退 tmux/screen;仍无法确认「不在线」时 close 不做静默 no-op(exit 非 0)。
34
+ #
28
35
  # tmux 宿主行为
29
36
  # - 已在 tmux 内($TMUX 存在):当前窗口分屏拉起,不新建窗口。
30
37
  # - 不在 tmux 内:创建 detached 会话 nao-<角色>,需 tmux attach -t nao-<角色>。
@@ -223,25 +230,128 @@ detect_host() {
223
230
  echo screen
224
231
  }
225
232
 
226
- # 该会话名对应的真实 pi 进程 PID(过滤 shell/tmux 等误匹配,避免误命中宿主命令行、误杀包装进程)
233
+ # ---------------------------------------------------------------------------
234
+ # 会话在线判定
235
+ #
236
+ # 历史假阴性根因:pi 启动后用 OSC 0 把终端标题设为
237
+ # "π - <会话名> - <cwd basename>"(pi 源码 updateTerminalTitle),
238
+ # 并同步改写 argv —— /proc/<pid>/cmdline 只剩 "pi",`pgrep -f --name` 恒失配,
239
+ # 于是 status/close 长期判「不在线」(close 退化为静默空转、exit 0)。
240
+ # 现改为按「终端标题契约」取句柄,argv 扫描一律不用:
241
+ # ① pi-intercom 名册(权威在线名单;跨 tmux/ghostty/ptyxis/screen 宿主)
242
+ # ② tmux pane_title(pi 自设;与 pane_start_command 是否为空无关)
243
+ # ③ tmux detached 会话 / screen 会话名 nao-<会话名>
244
+ # repo(可选):给出时要求标题 basename / 名册 cwd 一致,避免跨项目同名会话互串。
245
+
246
+ PI_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
247
+ PI_TITLE="π"
248
+ _INTERCOM_JSON=""; _INTERCOM_PROBED=0
249
+
250
+ repo_abspath() { ( cd "$1" >/dev/null 2>&1 && pwd ) 2>/dev/null || true; }
251
+ repo_basename() {
252
+ local abs; abs="$(repo_abspath "$1")"
253
+ [[ -n "$abs" ]] && printf '%s\n' "${abs##*/}"
254
+ return 0
255
+ }
256
+
257
+ # intercom 名册 JSON(一次调用、进程内缓存);不可用返回 1(不 die)
258
+ intercom_list_json() {
259
+ if (( _INTERCOM_PROBED )); then
260
+ [[ -n "$_INTERCOM_JSON" ]] && printf '%s' "$_INTERCOM_JSON"
261
+ return
262
+ fi
263
+ _INTERCOM_PROBED=1
264
+ local dir="$PI_AGENT_DIR/npm/node_modules/pi-intercom"
265
+ local tsx="$PI_AGENT_DIR/npm/node_modules/tsx/dist/cli.mjs"
266
+ [[ -f "$dir/cli.ts" && -f "$tsx" ]] || return 1
267
+ command -v node >/dev/null 2>&1 || return 1
268
+ local out
269
+ if command -v timeout >/dev/null 2>&1; then
270
+ out="$(timeout 15 node "$tsx" "$dir/cli.ts" list --json 2>/dev/null)" || return 1
271
+ else
272
+ out="$(node "$tsx" "$dir/cli.ts" list --json 2>/dev/null)" || return 1
273
+ fi
274
+ [[ -n "$out" ]] || return 1
275
+ _INTERCOM_JSON="$out"
276
+ printf '%s' "$out"
277
+ }
278
+
279
+ # 名册 → "会话名<TAB>cwd"(每行一条)
280
+ intercom_roster() {
281
+ local json; json="$(intercom_list_json)" || return 1
282
+ awk '
283
+ /"name":[[:space:]]*"/ { s=$0; sub(/.*"name":[[:space:]]*"/,"",s); sub(/".*/,"",s); n=s }
284
+ /"cwd":[[:space:]]*"/ { s=$0; sub(/.*"cwd":[[:space:]]*"/,"",s); sub(/".*/,"",s); print n "\t" s }
285
+ ' <<< "$json"
286
+ }
287
+
288
+ # 名册中是否在线;repo 给出时须 cwd 精确匹配
289
+ intercom_online() {
290
+ local name="$1" repo="${2:-}" abs="" n c
291
+ intercom_list_json >/dev/null 2>&1 || return 1
292
+ if [[ -n "$repo" ]]; then abs="$(repo_abspath "$repo")"; fi
293
+ while IFS=$'\t' read -r n c; do
294
+ [[ "$n" == "$name" ]] || continue
295
+ if [[ -z "$abs" || "$c" == "$abs" ]]; then return 0; fi
296
+ done < <(intercom_roster)
297
+ return 1
298
+ }
299
+
300
+ # 会话所在的 tmux pane(pi 自设标题 "π - <会话名> - <repo basename>")
301
+ find_pane_for() {
302
+ local name="$1" repo="${2:-}" base=""
303
+ command -v tmux >/dev/null 2>&1 || return 0
304
+ if [[ -n "$repo" ]]; then base="$(repo_basename "$repo")"; fi
305
+ tmux list-panes -a -F "#{pane_id}"$'\t'"#{pane_title}" 2>/dev/null \
306
+ | awk -F'\t' -v p="$PI_TITLE" -v n="$name" -v b="$base" '
307
+ { t=$2
308
+ if (b != "") { if (t == p " - " n " - " b) { print $1; exit } }
309
+ else if (index(t, p " - " n " - ") == 1) { print $1; exit }
310
+ }'
311
+ }
312
+
313
+ # 该会话名对应的 pi 进程 PID(经 pane 句柄取 pane_pid 及其子进程中 comm=pi 者)
227
314
  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
315
+ local pane root pid
316
+ pane="$(find_pane_for "$1" "${2:-}")"
317
+ [[ -n "$pane" ]] || return 0
318
+ root="$(tmux display-message -p -t "$pane" '#{pane_pid}' 2>/dev/null)"
319
+ [[ -n "$root" ]] || return 0
320
+ { printf '%s\n' "$root"; pgrep -P "$root" 2>/dev/null || true; } | while IFS= read -r pid; do
321
+ [[ "$(ps -o comm= -p "$pid" 2>/dev/null | tr -d ' ')" == "pi" ]] || continue
234
322
  printf '%s\n' "$pid"
235
323
  done
236
324
  }
237
325
 
238
- running() { [[ -n "$(pi_pids_for "$1")" ]]; }
326
+ running() {
327
+ local name="$1" repo="${2:-}"
328
+ if intercom_online "$name" "$repo"; then return 0; fi
329
+ if [[ -n "$(find_pane_for "$name" "$repo")" ]]; then return 0; fi
330
+ if command -v tmux >/dev/null 2>&1 && tmux has-session -t "nao-$name" 2>/dev/null; then return 0; fi
331
+ if command -v screen >/dev/null 2>&1 && screen -ls 2>/dev/null | grep -q "[0-9]\.nao-$name\b"; then return 0; fi
332
+ return 1
333
+ }
239
334
 
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 }'
335
+ # 能否可信地断言「不在线」:名册可用(完整),或本机就是 tmux/screen 宿主(句柄可枚举)
336
+ offline_verifiable() {
337
+ intercom_list_json >/dev/null 2>&1 && return 0
338
+ [[ -n "${TMUX:-}" ]] && return 0
339
+ case "${NAO_TERMINAL:-}" in tmux|screen) return 0 ;; esac
340
+ return 1
341
+ }
342
+
343
+ # 本机可见的会话名(tmux 标题 + tmux/screen 会话 + 名册),供 status 枚举;已去重
344
+ visible_session_names() {
345
+ { if command -v tmux >/dev/null 2>&1; then
346
+ tmux list-panes -a -F '#{pane_title}' 2>/dev/null \
347
+ | sed -n "s/^${PI_TITLE} - \([A-Za-z0-9_-]*\) - .*/\1/p"
348
+ tmux list-sessions -F '#{session_name}' 2>/dev/null | sed -n 's/^nao-//p'
349
+ fi
350
+ if command -v screen >/dev/null 2>&1; then
351
+ screen -ls 2>/dev/null | sed -n 's/.*[0-9]\.nao-\([A-Za-z0-9_-]*\).*/\1/p'
352
+ fi
353
+ intercom_roster 2>/dev/null | awk -F'\t' 'NF {print $1}'
354
+ } | awk '!seen[$0]++'
245
355
  }
246
356
 
247
357
  # 该 pane 末 3 行是否显示在跑 turn(启发式:状态行标记;回执/产物以 PM 核对清单为准)
@@ -252,23 +362,34 @@ pane_busy() {
252
362
  grep -qE "$CLOSE_BUSY_PATTERN" <<< "$tail3"
253
363
  }
254
364
 
255
- # 任务在 tasks-state 的归处:active(进行态)/ closed(已验收·已归档)/ absent(无记录)/ nofile
365
+ # 任务在 tasks-state 的归处:
366
+ # active(在表内进行态)/ closed(在归档区表内)/ mentioned(仅散文提及)
367
+ # / absent(全文无记录)/ nofile
368
+ # 归一处:表内单元格可选带反引号/**(常见写法 `T157` / **T157**)→ 去首尾空白与标记后比较。
369
+ # 标题级别不限(`##` 与 `###` 子表都算);归档区 = 标题含「已验收|已归档|归档」。
370
+ # 边界:先按非 [A-Za-z0-9_-] 切词再比较,避免 T15 命中 T155。
256
371
  task_state_class() {
257
372
  local id="$1"
258
373
  [[ -f "$TASKS_STATE" ]] || { echo nofile; return; }
259
374
  awk -v id="$id" '
260
- /^## / { sec=$0; sub(/^##[[:space:]]*/, "", sec); next }
375
+ function norm(s) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); gsub(/`/, "", s); gsub(/\*\*/, "", s); return s }
376
+ /^#+[[:space:]]/ { sec=$0; sub(/^#+[[:space:]]*/, "", sec); next }
261
377
  {
262
378
  n=split($0, cells, "|")
263
379
  for (i=1;i<=n;i++) {
264
- c=cells[i]; gsub(/^[[:space:]]+|[[:space:]]+$/, "", c)
265
- if (c==id) { hit=sec; break }
380
+ if (norm(cells[i]) != id) continue
381
+ if (sec ~ /已验收|已归档|归档/) arch=1; else cell=1
382
+ break
266
383
  }
384
+ line=$0; gsub(/[^A-Za-z0-9_-]/, " ", line)
385
+ m=split(line, toks, " ")
386
+ for (j=1;j<=m;j++) if (toks[j]==id) { seen=1; break }
267
387
  }
268
388
  END {
269
- if (hit=="") { print "absent"; exit }
270
- if (hit ~ /已验收|已归档/) { print "closed"; exit }
271
- print "active"
389
+ if (arch) { print "closed"; exit }
390
+ if (cell) { print "active"; exit }
391
+ if (seen) { print "mentioned"; exit }
392
+ print "absent"
272
393
  }
273
394
  ' "$TASKS_STATE"
274
395
  }
@@ -663,33 +784,33 @@ cmd_check() {
663
784
  # ---------------------------------------------------------------------------
664
785
  cmd_status() {
665
786
  local a
666
- echo "== 角色会话在线状态(权威名单以 intercom({action:'list'}) 为准)=="
787
+ intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存(后续子 shell 复用)
788
+ echo "== 角色会话在线状态(权威名单见 intercom({action:'list'});本表按终端标题/名册判定)=="
667
789
  for a in "${ROLE_ORDER[@]}"; do
668
- if running "$a"; then
790
+ if running "$a" "${ROLE_WS[$a]:-}"; then
669
791
  printf ' ✓ %-14s 在线(--name %s;卡片 %s)\n' "$a" "$a" "${ROLE_CARDS[$a]}"
670
792
  else
671
793
  printf ' · %-14s 未运行(ensure 拉起)\n' "$a"
672
794
  fi
673
795
  done
674
796
  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")"
797
+ local found=0 dname role id cls tag
798
+ while IFS= read -r dname; do
679
799
  [[ -n "$dname" ]] || continue
680
- id=""; tag=""
800
+ id=""
681
801
  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
802
+ [[ -n "$id" ]] || continue
803
+ cls="$(task_state_class "$id")"
804
+ tag=""
805
+ case "$cls" in
806
+ closed) tag=" ! 残留($id 已归档)→ close --task $id ${dname%-*}" ;;
807
+ active) tag=" · $id 进行态" ;;
808
+ mentioned) tag=" ? $id 仅散文提及(非表内记录)→ 核对后 close --task $id ${dname%-*}" ;;
809
+ absent) tag=" ! 残留(tasks-state 无 $id 记录)→ 核对后 close --task $id ${dname%-*}" ;;
810
+ esac
690
811
  printf ' · %s%s\n' "$dname" "$tag"
691
812
  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)
813
+ done < <(visible_session_names)
693
814
  (( found )) || echo ' (无)'
694
815
  [[ -f "$TASKS_STATE" ]] || echo " ($TASKS_STATE 不存在,残留判定已跳过)"
695
816
  }
@@ -700,6 +821,7 @@ cmd_close() {
700
821
  local force="$1" task="$2" target="$3"
701
822
  [[ -n "$target" ]] || die "close 需要目标:角色别名或派生会话名(如 rd-be / rd-be-T1)"
702
823
  local name repo cls bus pane pids r ok
824
+ intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存
703
825
  if [[ -n "$task" ]]; then
704
826
  resolve_role "$target"
705
827
  name="${NAME}-${task}"
@@ -711,7 +833,8 @@ cmd_close() {
711
833
  warn " 先把 $task 移入「已验收/已归档」再回收(或 --force:将丢失打回返工所需上下文)"
712
834
  return 1
713
835
  fi
714
- [[ "$cls" == "absent" ]] && warn "tasks-state 无 $task 记录(仅按会话名回收)"
836
+ [[ "$cls" == "absent" ]] && warn "tasks-state 全文无 $task 记录(仅按会话名回收)"
837
+ [[ "$cls" == "mentioned" ]] && log "tasks-state 仅散文提及 $task(非表内记录,不阻塞回收)"
715
838
  [[ "$cls" == "nofile" ]] && log "未启用 $TASKS_STATE(跳过状态闸门)"
716
839
  elif [[ -n "${ALIAS_ROLE[$target]:-}" ]]; then
717
840
  name="${ALIAS_ROLE[$target]}"; repo="${ROLE_WS[$name]:-$PWD}"
@@ -726,10 +849,18 @@ cmd_close() {
726
849
  repo="$PWD"
727
850
  fi
728
851
 
729
- running "$name" || { log "$name 未运行(无需回收)"; return 0; }
852
+ if ! running "$name" "$repo"; then
853
+ if offline_verifiable; then
854
+ log "$name 未运行(无需回收)"; return 0
855
+ fi
856
+ warn "无法确认 $name 在线状态:宿主无 tmux/screen 句柄,且 pi-intercom 名册不可用"
857
+ warn " pi 启动后 argv 被改写为 \"pi\",进程扫描不可信 ⇒ 不做静默 no-op(本命令 exit 非 0)"
858
+ warn " 请用 intercom list 人工核对;确认已退出后忽略本提示,或用 --force 跳过闸门"
859
+ return 1
860
+ fi
730
861
 
731
862
  # 闸门②:在跑 turn(tmux 可判;非 tmux 宿主无法判 → 需 --force)
732
- pane="$(find_pane_for "$name")"
863
+ pane="$(find_pane_for "$name" "$repo")"
733
864
  if [[ -n "$pane" ]]; then
734
865
  pane_busy "$pane" && bus="tmux pane $pane 末行显示在跑 turn"
735
866
  else
@@ -750,7 +881,7 @@ cmd_close() {
750
881
  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
882
  log "已回收 $name(screen 会话 nao-$name)@ $repo"; return 0
752
883
  fi
753
- pids="$(pi_pids_for "$name" | tr '\n' ' ')"
884
+ pids="$(pi_pids_for "$name" "$repo" | tr '\n' ' ')"
754
885
  if [[ -n "$pids" ]] && kill $pids 2>/dev/null; then
755
886
  log "已回收 $name(结束进程: $pids)@ $repo"; return 0
756
887
  fi
@@ -778,8 +909,8 @@ cmd_ensure() {
778
909
  key="$repo"; seen=0
779
910
  for k in "${cg_done[@]:-}"; do [[ "$k" == "$key" ]] && seen=1; done
780
911
  if (( ! seen )); then check_codegraph "$repo"; cg_done+=("$key"); fi
781
- if [[ "$force" != "true" ]] && running "$disp"; then
782
- warn "$disp 已在运行(--name 识别),跳过;确需重开请加 --force"
912
+ if [[ "$force" != "true" ]] && running "$disp" "$repo"; then
913
+ warn "$disp 已在运行(终端标题/名册命中),跳过;确需重开请加 --force"
783
914
  continue
784
915
  fi
785
916
  spawn_one "$disp" "$repo" "$model"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathan33/nao-skill",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "nao 多角色 AI 开发舰队:角色卡 / 技能 / 协作协议 / 工具链,一键接入项目(pi / Claude / 任意 Agent)",
5
5
  "type": "module",
6
6
  "bin": {