@nathan33/nao-skill 0.12.0 → 0.12.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.
@@ -10,8 +10,9 @@
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 close <别名|会话名> [--task <编号>] [--force]
14
- # 回收已完成会话(闸门:在跑 turn / tasks-state 未推进 → 拒绝,--force 跳过)
13
+ # nao-fleet.sh close <别名|派生会话名> [--task <编号>] [--force]
14
+ # 回收已完成会话(派生名如 rd-be-T1;跨仓定位;定位失败/名册报在线但无句柄 ⇒ 非 0 + 诊断)
15
+ # (闸门:在跑 turn / tasks-state 未推进 → 拒绝,--force 跳过)
15
16
  #
16
17
  # 角色别名 → 角色卡:见 .agents/roles.yaml(单一事实来源)
17
18
  # 当前:pm / arch-designer(arch) / rd-fe / rd-be / qa / rd-infra(infra)
@@ -132,7 +133,7 @@ load_manifest() {
132
133
  while IFS=$'\t' read -r k v w rest; do
133
134
  case "$k" in
134
135
  ROLE) ROLE_ORDER+=("$v"); ROLE_CARDS["$v"]="$w"; ROLE_WS["$v"]="$rest" ;;
135
- ALIAS) ALIAS_ROLE["$v"]="$w" ;;
136
+ ALIAS) ALIAS_ROLE["$w"]="$v" ;; # 别名 → 角色 id(消费方 resolve_role/close 按此方向取值)
136
137
  esac
137
138
  done <<< "$out"
138
139
  }
@@ -206,6 +207,39 @@ check_roles_indent() {
206
207
  ' "$MANIFEST"
207
208
  }
208
209
 
210
+ # 别名解析守卫(#19):独立重解析 roles.yaml(不依赖可能被写反的 ALIAS_ROLE),校验
211
+ # ① 每个声明的别名可解析为「声明它的角色」(对 ALIAS_ROLE 键值方向敏感)
212
+ # ② 每个角色 canonical id 已登记于 aliases(BR2:canonical 须可解析为自身)
213
+ # ③ 别名不跨角色重名
214
+ check_alias_resolution() {
215
+ local rc=0 ga gr
216
+ local -A owner=()
217
+ while IFS=$'\t' read -r ga gr; do
218
+ [[ -n "$ga" && -n "$gr" ]] || continue
219
+ if [[ -n "${owner[$ga]:-}" && "${owner[$ga]}" != "$gr" ]]; then
220
+ printf ' ✗ 别名 %s 跨角色重名:%s 与 %s\n' "$ga" "${owner[$ga]}" "$gr"; rc=1
221
+ fi
222
+ owner["$ga"]="$gr"
223
+ if [[ "${ALIAS_ROLE[$ga]:-}" != "$gr" ]]; then
224
+ printf ' ✗ 别名 %s 解析异常:roles.yaml 声明属 %s,实际解析为 %s(ALIAS_ROLE 键值方向可能反写)\n' \
225
+ "$ga" "$gr" "${ALIAS_ROLE[$ga]:-<未登记>}"; rc=1
226
+ fi
227
+ done < <(awk -F'\t' '
228
+ /^ [^ ][^:]*:[[:space:]]*$/ { c=$0; sub(/^ /,"",c); sub(/:[[:space:]]*$/,"",c); next }
229
+ c!="" && /^ aliases:[[:space:]]*\[/ {
230
+ v=$0; sub(/^ aliases:[[:space:]]*\[/,"",v); sub(/\].*/,"",v)
231
+ n=split(v, a, /,/)
232
+ for (i=1;i<=n;i++){ gsub(/^[[:space:]]+|[[:space:]]+$/, "", a[i]); if(a[i]!="") print a[i] "\t" c }
233
+ }' "$MANIFEST")
234
+ for gr in "${ROLE_ORDER[@]}"; do
235
+ if [[ "${owner[$gr]:-}" != "$gr" ]]; then
236
+ printf ' ✗ 角色 %s 的 canonical id 未登记于 aliases(BR2:canonical 须可解析为自身)\n' "$gr"; rc=1
237
+ fi
238
+ done
239
+ (( rc == 0 )) && printf ' ✓ 别名解析守卫:全部别名可解析且无跨角色重名\n'
240
+ return $rc
241
+ }
242
+
209
243
  # qq-notify 主动推送器契约:存在 + 可执行 + EOL 全 LF + 无 Tab + node --check 语法
210
244
  check_qq_notify() {
211
245
  local rc=0 f="$SKILLS_DIR/.agents/scripts/qq-notify"
@@ -372,6 +406,15 @@ intercom_online() {
372
406
  return 1
373
407
  }
374
408
 
409
+ # 名册中该会话的 cwd(跨仓 close 需按目标仓定位;无记录则空)
410
+ intercom_cwd_for() {
411
+ local n c
412
+ while IFS=$'\t' read -r n c; do
413
+ [[ "$n" == "$1" ]] && { printf '%s\n' "$c"; return 0; }
414
+ done < <(intercom_roster)
415
+ return 0
416
+ }
417
+
375
418
  # 会话所在的 tmux pane。三层后备(标题可被外部改写,如 "pi:c"):
376
419
  # ① 终端标题契约 "π - <会话名> - <repo basename>"(pi 自设;最快)
377
420
  # ② pi-intercom 名册 tmuxPane(注册时读 $TMUX_PANE;与标题改名无关,权威)
@@ -484,6 +527,17 @@ find_pane_for() {
484
527
  return 0
485
528
  }
486
529
 
530
+ # 跨仓 pane 定位:不限定 repo(标题任意仓 basename / 名册 tmuxPane 不限 cwd)
531
+ # close 的目标可能在别的仓(roles.yaml workspace 恒为本仓)⇒ 不能用 find_pane_for 的 repo 约束。
532
+ find_pane_for_any_repo() {
533
+ local name="$1" pane=""
534
+ pane="$(find_pane_by_title "$name" "")"
535
+ [[ -n "$pane" ]] && { printf '%s\n' "$pane"; return 0; }
536
+ pane="$(intercom_pane_for "$name" "")"
537
+ if [[ -n "$pane" ]] && tmux_pane_live "$pane"; then printf '%s\n' "$pane"; return 0; fi
538
+ return 0
539
+ }
540
+
487
541
  # 该会话名对应的 pi 进程 PID(经 pane 句柄取 pane_pid 及其子进程中 comm=pi 者)
488
542
  pi_pids_for() {
489
543
  local pane root pid
@@ -882,6 +936,7 @@ cmd_check() {
882
936
  echo "== 角色清单 roles.yaml =="
883
937
  load_manifest
884
938
  printf ' ✓ 解析成功,%d 个角色: %s\n' "${#ROLE_ORDER[@]}" "${ROLE_ORDER[*]}"
939
+ check_alias_resolution || rc=1
885
940
 
886
941
  echo "== 常驻角色卡(阈值 ${CARD_MAX_LINES} 行,frontmatter 校验)=="
887
942
  local a role version updated
@@ -1106,14 +1161,39 @@ cmd_status() {
1106
1161
  # ---------------------------------------------------------------------------
1107
1162
  # 回收已完成会话:闸门(tasks-state 已推进 + 无在跑 turn)+ 落地(pane/会话/screen/进程)
1108
1163
  cmd_close() {
1109
- local force="$1" task="$2" target="$3"
1110
- [[ -n "$target" ]] || die "close 需要目标:角色别名或派生会话名(如 rd-be / rd-be-T1 或 rd-infra / rd-infra-T1)"
1111
- local name repo cls bus pane pids r ok st
1164
+ local force="$1" task="$2" target="${3:-}"
1165
+ [[ -n "$target" ]] || die "close 需要目标:角色别名或派生会话名(如 rd-be / rd-be-T1 / qa-T1)"
1166
+ local name repo cls bus pane pids st tgt a best=""
1112
1167
  intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存
1113
- if [[ -n "$task" ]]; then
1114
- resolve_role "$target"
1115
- name="${NAME}-${task}"
1116
- repo="${ROLE_WS[$NAME]:-$PWD}"
1168
+
1169
+ # ---- 目标解析:别名/canonical id | 派生会话名 <角色>-<编号> ----
1170
+ # 派生名(如 qa-T508)可直接作目标;也兼容 close --task T508 qa 与 close --task T508 qa-T508。
1171
+ tgt=""
1172
+ if [[ -n "${ALIAS_ROLE[$target]:-}" ]]; then
1173
+ tgt="$target"
1174
+ elif [[ -n "$task" && "$target" == *-"$task" && -n "${ALIAS_ROLE[${target%-"$task"}]:-}" ]]; then
1175
+ tgt="${target%-"$task"}"
1176
+ else
1177
+ # 未带 --task 的派生名:取最长可解析前缀为角色,余下为任务编号
1178
+ for a in "${!ALIAS_ROLE[@]}"; do
1179
+ [[ "$target" == "$a"-* ]] || continue
1180
+ if [[ -z "$best" || ${#a} -gt ${#best} ]]; then best="$a"; fi
1181
+ done
1182
+ if [[ -n "$best" ]]; then tgt="$best"; task="${target#"$best"-}"; fi
1183
+ fi
1184
+ if [[ -z "$tgt" ]]; then
1185
+ die "未知角色或派生会话名: $target(可用角色: ${ROLE_ORDER[*]};派生名形如 rd-be-T1 / qa-T1,亦可用 close --task <编号> <角色>)"
1186
+ fi
1187
+ resolve_role "$tgt"
1188
+ name="$NAME"
1189
+ [[ -n "$task" ]] && name="${NAME}-${task}"
1190
+ # 目标仓:名册 cwd 优先(跨仓会话真实仓),否则回退 roles.yaml workspace / 当前目录
1191
+ repo="$(intercom_cwd_for "$name")"
1192
+ [[ -n "$repo" ]] || repo="${ROLE_WS[$NAME]:-$PWD}"
1193
+
1194
+ if [[ -z "$task" ]]; then
1195
+ warn "常驻会话 $name:任务闭环后应 ensure --force 重开,而非回收(仅在本批不再需要该角色时回收)"
1196
+ else
1117
1197
  # 闸门①:tasks-state 未推进 → 拒绝(验收未过需原会话返工,回收会丢上下文)
1118
1198
  cls="$(task_state_class "$task")"
1119
1199
  if [[ "$cls" == "active" && "$force" != true ]]; then
@@ -1124,20 +1204,25 @@ cmd_close() {
1124
1204
  [[ "$cls" == "absent" ]] && warn "tasks-state 全文无 $task 记录(仅按会话名回收)"
1125
1205
  [[ "$cls" == "mentioned" ]] && log "tasks-state 仅散文提及 $task(非表内记录,不阻塞回收)"
1126
1206
  [[ "$cls" == "nofile" ]] && log "未启用 $TASKS_STATE(跳过状态闸门)"
1127
- elif [[ -n "${ALIAS_ROLE[$target]:-}" ]]; then
1128
- name="${ALIAS_ROLE[$target]}"; repo="${ROLE_WS[$name]:-$PWD}"
1129
- warn "常驻会话 $name:任务闭环后应 ensure --force 重开,而非回收(仅在本批不再需要该角色时回收)"
1130
- else
1131
- name="$target"
1132
- [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || die "非法会话名: $name"
1133
- # 非别名的目标必须是 <已知角色>-<编号>,否则视为拼错(防静默 no-op)
1134
- ok=0
1135
- for r in "${ROLE_ORDER[@]}"; do [[ "$name" == "$r-"* ]] && ok=1; done
1136
- (( ok )) || die "未知角色或派生会话名: $name(可用: ${ROLE_ORDER[*]};派生名形如 rd-be-T1 / rd-infra-T1)"
1137
- repo="$PWD"
1138
1207
  fi
1139
1208
 
1140
- if ! running "$name" "$repo"; then
1209
+ # ---- 宿主句柄定位(跨仓):pane 按标题任意仓 basename / 名册 tmuxPane 不限定 cwd ----
1210
+ pane="$(find_pane_for_any_repo "$name")"
1211
+ if [[ -z "$pane" ]]; then
1212
+ # 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
1215
+ fi
1216
+ if command -v screen >/dev/null 2>&1 && screen -ls 2>/dev/null | grep -q "nao-$name"; then
1217
+ if screen -S "nao-$name" -X quit 2>/dev/null; then log "已回收 $name(screen 会话 nao-$name)@ $repo"; return 0; fi
1218
+ fi
1219
+ # 无任何可杀句柄:名册若报在线 ⇒ 定位失败,必须非静默(禁止「未运行」而 pane 仍在)
1220
+ if intercom_online "$name"; then
1221
+ warn "无法回收 $name:pi-intercom 名册报在线(cwd=${repo})但未定位到存活 pane/会话"
1222
+ warn " 标题契约 / 名册 tmuxPane 均未命中;tmuxPane 可能滞后或未注册"
1223
+ warn " 请用 intercom({action:'list'}) 人工核对;确认已退出后忽略,或按 pane_id 手工处理"
1224
+ return 1
1225
+ fi
1141
1226
  if offline_verifiable; then
1142
1227
  log "$name 未运行(无需回收)"; return 0
1143
1228
  fi
@@ -1148,48 +1233,37 @@ cmd_close() {
1148
1233
  fi
1149
1234
 
1150
1235
  # 闸门②:在跑 turn(pane 标记 + roster 状态双重信号)
1151
- pane="$(find_pane_for "$name" "$repo")"
1152
1236
  bus=""
1153
1237
  # 显式双分支赋值:pane 空闲时 bus 必须落为 ""。
1154
1238
  # 曾用 `pane_busy "$pane" && bus=…`:pane_busy 为假时 && 短路,bus 在 set -u 下未绑定 → 行 1007 崩溃。
1155
- if [[ -n "$pane" ]] && pane_busy "$pane"; then
1239
+ if pane_busy "$pane"; then
1156
1240
  bus="tmux pane $pane 显示在跑 turn"
1157
1241
  fi
1158
- st="$(intercom_status_for "$name" "$repo")"
1242
+ st="$(intercom_status_for "$name")"
1159
1243
  if [[ -z "$bus" ]]; then
1160
1244
  case "$st" in
1161
1245
  ""|idle|\?) ;; # 无状态或空闲 → 不阻塞
1162
1246
  *) bus="pi-intercom 名册状态=$st(在跑 turn)" ;;
1163
1247
  esac
1164
1248
  fi
1165
- # 既无 pane 也无名册状态 ⇒ 确实无法判定 → 拒绝(不静默 no-op)
1166
- if [[ -z "$bus" && -z "$pane" && -z "$st" ]]; then
1167
- if command -v tmux >/dev/null 2>&1; then
1168
- bus="tmux 宿主但未定位到该会话的 pane(标题契约 / 名册 tmuxPane / 仓库兜底均未命中)"
1169
- else
1170
- bus="非 tmux 宿主,无法确认是否在跑 turn"
1171
- fi
1172
- fi
1173
1249
  if [[ -n "$bus" && "$force" != true ]]; then
1174
1250
  warn "拒绝回收 $name:$bus"
1175
1251
  warn " 等它停;回执与产物核对见 checklists/pm.md「会话回收 / 收窗核对」(或 --force 强制)"
1176
1252
  return 1
1177
1253
  fi
1178
1254
 
1179
- if [[ -n "$pane" ]] && tmux kill-pane -t "$pane" 2>/dev/null; then
1255
+ # 回收前以 pane 真实 cwd 校正日志中的仓(名册可能缺记录)
1256
+ local pcwd
1257
+ pcwd="$(tmux display-message -p -t "$pane" '#{pane_current_path}' 2>/dev/null || true)"
1258
+ [[ -n "$pcwd" ]] && repo="$pcwd"
1259
+ if tmux kill-pane -t "$pane" 2>/dev/null; then
1180
1260
  log "已回收 $name(tmux pane $pane)@ $repo"; return 0
1181
1261
  fi
1182
- if command -v tmux >/dev/null 2>&1 && tmux has-session -t "nao-$name" 2>/dev/null && tmux kill-session -t "nao-$name" 2>/dev/null; then
1183
- log "已回收 $name(tmux 会话 nao-$name)@ $repo"; return 0
1184
- fi
1185
- 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
1186
- log "已回收 $name(screen 会话 nao-$name)@ $repo"; return 0
1187
- fi
1188
1262
  pids="$(pi_pids_for "$name" "$repo" | tr '\n' ' ')"
1189
1263
  if [[ -n "$pids" ]] && kill $pids 2>/dev/null; then
1190
1264
  log "已回收 $name(结束进程: $pids)@ $repo"; return 0
1191
1265
  fi
1192
- warn "未找到 $name 的 pane/会话/进程(可能刚好退出)"; return 1
1266
+ warn "已定位到 $name 的 pane $pane 但回收失败(pane 可能刚好退出;核对 pane 已消失后可忽略)"; return 1
1193
1267
  }
1194
1268
 
1195
1269
  # ---------------------------------------------------------------------------
package/README.md CHANGED
@@ -49,6 +49,8 @@ npx @nathan33/nao-skill migrate # 旧文件备份到 .agents/.nao-obsolete/,
49
49
  .agents/scripts/nao-fleet.sh ensure pm arch rd-fe rd-be qa rd-infra # 拉起 6 个角色会话
50
50
  ```
51
51
 
52
+ 角色名可以写**别名**或**规范名**(两者等价):`arch` = `arch-designer`、`infra` = `rd-infra`;上例里两者混用也照常拉起。完整别名表见 `.agents/roles.yaml` 或 `nao-fleet.sh --help`。
53
+
52
54
  然后**在 PM 会话里说需求**就行,剩下的它自己流转。大概长这样:
53
55
 
54
56
  ```text
@@ -104,7 +106,7 @@ $NAO_SKILLS/.agents/scripts/qq-notify --dry-run "连通性自检" # 只取
104
106
 
105
107
  **它会在 GitHub 上开 PR 吗?** 会——每个需求一条分支、一个 PR(Draft 早开、CI 早跑),你确认验收后由研发角色 squash 合并,main 上只留 1 条可读提交。没有远端或 `gh` 时降级为本地分支,流程不变。
106
108
 
107
- **跑完的会话要留着吗?** 后台派生会话验收完可以回收:`nao-fleet.sh close --task T1 rd-be`;常驻角色留着复用。`nao-fleet.sh status` 会提示哪些是残留。
109
+ **跑完的会话要留着吗?** 后台派生会话验收完可以回收:`nao-fleet.sh close --task T1 rd-be`(等价写法 `nao-fleet.sh close rd-be-T1`,直接用派生会话名即可);常驻角色留着复用。`nao-fleet.sh status` 会提示哪些是残留。目标在别的项目里也能回收——`close` 会按名册/窗口标题跨项目定位;定位不到时会**非 0 退出并打印诊断**,不会假装已回收。
108
110
 
109
111
  **终端窗口太挤怎么办?** 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`。
110
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathan33/nao-skill",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "nao 多角色 AI 开发舰队:角色卡 / 技能 / 协作协议 / 工具链,一键接入项目(pi / Claude / 任意 Agent)",
5
5
  "type": "module",
6
6
  "bin": {