@nathan33/nao-skill 0.6.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.
@@ -3,12 +3,15 @@
3
3
  # nao-fleet.sh — 按角色一键拉起 pi 会话窗口(nao 团队工具箱)
4
4
  #
5
5
  # 用法
6
- # nao-fleet.sh check [--strict] 静态体检:roles.yaml/角色卡/交叉引用/白名单/布局
7
- # nao-fleet.sh status 角色会话在线状态(权威名单见 intercom list)
6
+ # nao-fleet.sh check [--strict] [-v] 静态体检:roles.yaml/缩进/EOL/角色卡/交叉引用/PR 模板/白名单/布局
7
+ # 默认单行摘要(含 warn 计数);-v 展开完整报告;失败始终展开
8
+ # nao-fleet.sh status 角色会话在线状态(按终端标题/名册判定;权威名单见 intercom list)
8
9
  # nao-fleet.sh ensure <别名>[@<repo>] [更多...] 拉起角色窗口(默认工作区=roles.yaml workspace)
9
10
  # nao-fleet.sh ensure -m <model> <别名>... 显式指定模型(须命中白名单)
10
11
  # nao-fleet.sh ensure --task <编号> <别名>[@<repo>] 任务派生会话:--name <别名>-<编号>(并行隔离,避免同名冲突)
11
12
  # nao-fleet.sh ensure --force <别名>... 忽略"已在运行"判重
13
+ # nao-fleet.sh close <别名|会话名> [--task <编号>] [--force]
14
+ # 回收已完成会话(闸门:在跑 turn / tasks-state 未推进 → 拒绝,--force 跳过)
12
15
  #
13
16
  # 角色别名 → 角色卡:见 .agents/roles.yaml(单一事实来源)
14
17
  # 当前:pm / arch-designer(arch) / rd-fe / rd-be / qa
@@ -16,9 +19,18 @@
16
19
  # 环境变量
17
20
  # NAO_TERMINAL=ghostty|ptyxis|tmux|screen 强制宿主
18
21
  # NAO_TMUX_LAYOUT=main-row2|grid tmux 布局(默认 main-row2)
19
- # NAO_TMUX_MAIN_WIDTH=<10..90> main-row2 主 pane 宽度百分比(默认 50)
22
+ # NAO_TMUX_MAIN_WIDTH=<10..90> main-row2 主 pane 宽度百分比(默认 35)
20
23
  # NAO_SKILLS=<dir> 角色卡根目录(默认 <脚本>/../..)
21
24
  # NAO_MODEL_WHITELIST=<glob,...> -m 白名单(默认空=不校验,支持 glob)
25
+ # NAO_CLOSE_BUSY_PATTERN=<ERE> close 的在跑 turn 判定正则(默认内置 pi 状态行标记)
26
+ # NAO_TASKS_STATE=<path> 任务状态文件(默认 docs/tasks-state.md,供残留检测/回收闸门)
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)。
22
34
  #
23
35
  # tmux 宿主行为
24
36
  # - 已在 tmux 内($TMUX 存在):当前窗口分屏拉起,不新建窗口。
@@ -42,7 +54,15 @@ ROLE_ORDER=()
42
54
  declare -A ROLE_CARDS ROLE_WS ALIAS_ROLE
43
55
  MODEL_WHITELIST="${NAO_MODEL_WHITELIST:-}"
44
56
  TMUX_LAYOUT="${NAO_TMUX_LAYOUT:-main-row2}"
45
- TMUX_MAIN_WIDTH="${NAO_TMUX_MAIN_WIDTH:-50}"
57
+ # main-row2 主 pane 宽度百分比:默认值与非法值回退共用同一常量(防三处漂移)
58
+ TMUX_MAIN_WIDTH_DEFAULT=35
59
+ TMUX_MAIN_WIDTH="${NAO_TMUX_MAIN_WIDTH:-$TMUX_MAIN_WIDTH_DEFAULT}"
60
+ # check 输出契约:默认单行摘要(省 PM 上下文),-v 展开完整报告;失败始终展开
61
+ VERBOSE=false
62
+ # close 的在跑 turn 判定(pi 默认状态行:Working (esc to interrupt) / Thinking... / Retrying / Compacting)
63
+ CLOSE_BUSY_PATTERN="${NAO_CLOSE_BUSY_PATTERN:-Working \(|Thinking\.\.\.|to interrupt|to cancel|Retrying \(|Compacting|Summarizing branch}"
64
+ # 任务状态文件(相对当前目录;供残留检测与回收闸门)
65
+ TASKS_STATE="${NAO_TASKS_STATE:-docs/tasks-state.md}"
46
66
 
47
67
  log() { printf '\033[1;32m[fleet]\033[0m %s\n' "$*"; }
48
68
  warn() { printf '\033[1;33m[fleet]\033[0m %s\n' "$*" >&2; }
@@ -135,6 +155,43 @@ check_cross_refs() {
135
155
  return $rc
136
156
  }
137
157
 
158
+ # .agents/** 文本文件 EOL 契约:必须全为 LF(出现 CR 即 fail,列出文件)
159
+ # 排除 .nao-obsolete/(update 的旧版备份,EOL 不作为契约)
160
+ check_eol() {
161
+ local rc=0 n=0 f
162
+ local -a bad=()
163
+ while IFS= read -r -d '' f; do
164
+ n=$((n + 1))
165
+ grep -Iq $'\r' "$f" && bad+=("${f#"$SKILLS_DIR"/}")
166
+ done < <(find "$SKILLS_DIR/.agents" -type f ! -path '*/.nao-obsolete/*' -print0)
167
+ if (( ${#bad[@]} == 0 )); then
168
+ printf ' ✓ %d 个文本文件全 LF\n' "$n"
169
+ else
170
+ rc=1
171
+ printf ' ✗ %d/%d 个文件含 CR(须转 LF;CRLF 会破坏 fleet/awk 解析):\n' "${#bad[@]}" "$n"
172
+ printf ' %s\n' "${bad[@]}"
173
+ fi
174
+ return $rc
175
+ }
176
+
177
+ # roles.yaml 缩进契约:角色 id 2 空格 / 字段 4 空格 / 禁 Tab(与 load_manifest 解析器同契约,独立报行号)
178
+ check_roles_indent() {
179
+ awk '
180
+ BEGIN { in_roles=0; cur=0; bad=0 }
181
+ /^[[:space:]]*$/ { next }
182
+ /^[[:space:]]*#/ { next }
183
+ /\t/ { printf " ✗ 行 %d: 含 Tab(禁止 Tab 缩进)\n", FNR; bad=1; next }
184
+ !in_roles {
185
+ if ($0 ~ /^roles:[[:space:]]*$/) { in_roles=1; next }
186
+ printf " ✗ 行 %d: 顶层仅允许 roles:(得到: %s)\n", FNR, $0; bad=1; next
187
+ }
188
+ $0 ~ /^ [^ ][^:]*:[[:space:]]*$/ { cur=1; next }
189
+ cur && $0 ~ /^ [a-z_]+:[[:space:]]/ { next }
190
+ { printf " ✗ 行 %d: 缩进/位置非法(角色 id 须 2 空格、字段须 4 空格且挂在角色下): %s\n", FNR, $0; bad=1 }
191
+ END { exit (bad ? 1 : 0) }
192
+ ' "$MANIFEST"
193
+ }
194
+
138
195
  # CodeGraph 索引健康(ensure 拉起前 / check 用;缺失或过期仅 warn,不阻塞)
139
196
  check_codegraph() {
140
197
  local repo="$1" name st
@@ -173,7 +230,169 @@ detect_host() {
173
230
  echo screen
174
231
  }
175
232
 
176
- running() { pgrep -f -- "pi[[:space:]].*--name $1([[:space:]]|$)" >/dev/null 2>&1; }
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 者)
314
+ pi_pids_for() {
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
322
+ printf '%s\n' "$pid"
323
+ done
324
+ }
325
+
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
+ }
334
+
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]++'
355
+ }
356
+
357
+ # 该 pane 末 3 行是否显示在跑 turn(启发式:状态行标记;回执/产物以 PM 核对清单为准)
358
+ pane_busy() {
359
+ local pane="$1" tail3
360
+ tail3="$(tmux capture-pane -p -t "$pane" 2>/dev/null | grep -v '^[[:space:]]*$' | tail -3)"
361
+ [[ -n "$tail3" ]] || return 1
362
+ grep -qE "$CLOSE_BUSY_PATTERN" <<< "$tail3"
363
+ }
364
+
365
+ # 任务在 tasks-state 的归处:
366
+ # active(在表内进行态)/ closed(在归档区表内)/ mentioned(仅散文提及)
367
+ # / absent(全文无记录)/ nofile
368
+ # 归一处:表内单元格可选带反引号/**(常见写法 `T157` / **T157**)→ 去首尾空白与标记后比较。
369
+ # 标题级别不限(`##` 与 `###` 子表都算);归档区 = 标题含「已验收|已归档|归档」。
370
+ # 边界:先按非 [A-Za-z0-9_-] 切词再比较,避免 T15 命中 T155。
371
+ task_state_class() {
372
+ local id="$1"
373
+ [[ -f "$TASKS_STATE" ]] || { echo nofile; return; }
374
+ awk -v id="$id" '
375
+ function norm(s) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); gsub(/`/, "", s); gsub(/\*\*/, "", s); return s }
376
+ /^#+[[:space:]]/ { sec=$0; sub(/^#+[[:space:]]*/, "", sec); next }
377
+ {
378
+ n=split($0, cells, "|")
379
+ for (i=1;i<=n;i++) {
380
+ if (norm(cells[i]) != id) continue
381
+ if (sec ~ /已验收|已归档|归档/) arch=1; else cell=1
382
+ break
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 }
387
+ }
388
+ END {
389
+ if (arch) { print "closed"; exit }
390
+ if (cell) { print "active"; exit }
391
+ if (seen) { print "mentioned"; exit }
392
+ print "absent"
393
+ }
394
+ ' "$TASKS_STATE"
395
+ }
177
396
 
178
397
  # 白名单命中返回 0,否则返回 1;未设白名单=放行
179
398
  check_model() {
@@ -296,7 +515,7 @@ apply_tmux_layout() {
296
515
  (( ${#pane_ids[@]} >= 1 )) || return 0
297
516
 
298
517
  local pct="$TMUX_MAIN_WIDTH"
299
- [[ "$pct" =~ ^[0-9]+$ ]] || pct=50
518
+ [[ "$pct" =~ ^[0-9]+$ ]] || pct="$TMUX_MAIN_WIDTH_DEFAULT"
300
519
  (( pct < 10 )) && pct=10
301
520
  (( pct > 90 )) && pct=90
302
521
 
@@ -375,7 +594,20 @@ cmd_check() {
375
594
  else printf ' ✗ 缺失: %s\n' "$d"; rc=1; fi
376
595
  done
377
596
 
597
+ echo "== 文本契约(roles.yaml 缩进 2/4 · .agents/** EOL 全 LF)=="
598
+ if [[ -f "$MANIFEST" ]]; then
599
+ if check_roles_indent; then
600
+ printf ' ✓ roles.yaml 缩进契约(角色 id 2 空格 / 字段 4 空格 / 无 Tab)\n'
601
+ else
602
+ rc=1
603
+ fi
604
+ else
605
+ printf ' ✗ roles.yaml 缺失: %s\n' "$MANIFEST"; rc=1
606
+ fi
607
+ check_eol || rc=1
608
+
378
609
  echo "== 角色清单 roles.yaml =="
610
+ load_manifest
379
611
  printf ' ✓ 解析成功,%d 个角色: %s\n' "${#ROLE_ORDER[@]}" "${ROLE_ORDER[*]}"
380
612
 
381
613
  echo "== 常驻角色卡(阈值 ${CARD_MAX_LINES} 行,frontmatter 校验)=="
@@ -424,6 +656,33 @@ cmd_check() {
424
656
  else printf ' ✗ %s 缺失\n' "$f"; rc=1; fi
425
657
  done
426
658
 
659
+ echo "== PR 模板(GitHub Flow)=="
660
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
661
+ echo ' · 非 git 仓库(跳过)'
662
+ else
663
+ local remote_url prtpl="" cand
664
+ remote_url="$(git config --get remote.origin.url 2>/dev/null || true)"
665
+ if [[ -z "$remote_url" ]]; then
666
+ echo ' · 无 remote.origin(跳过)'
667
+ elif [[ "$remote_url" != *github.com* ]]; then
668
+ echo ' · 远端非 GitHub(跳过;PR 模板不适用)'
669
+ else
670
+ shopt -s nullglob
671
+ for cand in .github/pull_request_template.md .github/PULL_REQUEST_TEMPLATE.md \
672
+ .github/PULL_REQUEST_TEMPLATE \
673
+ pull_request_template.md PULL_REQUEST_TEMPLATE.md \
674
+ docs/pull_request_template.md docs/PULL_REQUEST_TEMPLATE.md; do
675
+ [[ -e "$cand" ]] && { prtpl="$cand"; break; }
676
+ done
677
+ shopt -u nullglob
678
+ if [[ -n "$prtpl" ]]; then
679
+ printf ' ✓ PR 模板已就位:%s\n' "$prtpl"
680
+ else
681
+ echo ' ! 未找到 PR 模板(GitHub 远端):PM 立项时从 .agents/templates/github/pull_request_template.md.example 复制为 .github/pull_request_template.md(见 skills/github-flow.md)'
682
+ fi
683
+ fi
684
+ fi
685
+
427
686
  echo "== 按需技能 =="
428
687
  if [[ -d "$SKILLS_SUB" ]]; then
429
688
  shopt -s nullglob
@@ -453,6 +712,18 @@ cmd_check() {
453
712
  echo ' · codegraph 未安装(回退 grep 属预期行为)'
454
713
  fi
455
714
 
715
+ echo "== pi 插件(业界调研能力)=="
716
+ local piset="$HOME/.pi/agent/settings.json"
717
+ if [[ -f "$piset" ]]; then
718
+ if grep -q 'pi-web-access' "$piset" 2>/dev/null; then
719
+ echo ' · pi-web-access ✓ 已装(PM/arch 业界调研可用)'
720
+ else
721
+ echo ' ! pi-web-access 未装(业界调研不可用;nao-skill plugins install web-access)'
722
+ fi
723
+ else
724
+ echo " · 未找到 $piset(跳过插件检测)"
725
+ fi
726
+
456
727
  echo "== 模型白名单 =="
457
728
  if [[ -z "$MODEL_WHITELIST" ]]; then
458
729
  echo " (未设置 NAO_MODEL_WHITELIST,-m 不校验)"
@@ -498,7 +769,7 @@ cmd_check() {
498
769
  if [[ "$TMUX_MAIN_WIDTH" =~ ^[0-9]+$ ]] && (( TMUX_MAIN_WIDTH >= 10 && TMUX_MAIN_WIDTH <= 90 )); then
499
770
  printf ' ✓ NAO_TMUX_MAIN_WIDTH=%-3s%% 合法\n' "$TMUX_MAIN_WIDTH"
500
771
  else
501
- printf ' ! NAO_TMUX_MAIN_WIDTH=%-3s 非法(10..90),运行时会回退 50\n' "$TMUX_MAIN_WIDTH"
772
+ printf ' ! NAO_TMUX_MAIN_WIDTH=%-3s 非法(10..90):越界夹取到 10/90,非数字回退 %s\n' "$TMUX_MAIN_WIDTH" "$TMUX_MAIN_WIDTH_DEFAULT"
502
773
  fi
503
774
  fi
504
775
 
@@ -507,26 +778,114 @@ cmd_check() {
507
778
  rc=1
508
779
  fi
509
780
 
510
- exit $rc
781
+ return $rc
511
782
  }
512
783
 
513
784
  # ---------------------------------------------------------------------------
514
785
  cmd_status() {
515
786
  local a
516
- echo "== 角色会话在线状态(权威名单以 intercom({action:'list'}) 为准)=="
787
+ intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存(后续子 shell 复用)
788
+ echo "== 角色会话在线状态(权威名单见 intercom({action:'list'});本表按终端标题/名册判定)=="
517
789
  for a in "${ROLE_ORDER[@]}"; do
518
- if running "$a"; then
790
+ if running "$a" "${ROLE_WS[$a]:-}"; then
519
791
  printf ' ✓ %-14s 在线(--name %s;卡片 %s)\n' "$a" "$a" "${ROLE_CARDS[$a]}"
520
792
  else
521
793
  printf ' · %-14s 未运行(ensure 拉起)\n' "$a"
522
794
  fi
523
795
  done
524
796
  echo "== 任务派生会话(--task 拉起,如 rd-be-T1)=="
525
- local found=0 line
526
- while IFS= read -r line; do
527
- [[ -n "$line" ]] && { printf ' · %s\n' "$line"; found=1; }
528
- done < <(pgrep -af "pi[[:space:]].*--name (pm|arch-designer|rd-fe|rd-be|qa)-[A-Za-z0-9_-]+" 2>/dev/null | head -10)
797
+ local found=0 dname role id cls tag
798
+ while IFS= read -r dname; do
799
+ [[ -n "$dname" ]] || continue
800
+ id=""
801
+ for role in "${ROLE_ORDER[@]}"; do [[ "$dname" == "$role-"* ]] && { id="${dname#"$role"-}"; break; }; done
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
811
+ printf ' · %s%s\n' "$dname" "$tag"
812
+ found=1
813
+ done < <(visible_session_names)
529
814
  (( found )) || echo ' (无)'
815
+ [[ -f "$TASKS_STATE" ]] || echo " ($TASKS_STATE 不存在,残留判定已跳过)"
816
+ }
817
+
818
+ # ---------------------------------------------------------------------------
819
+ # 回收已完成会话:闸门(tasks-state 已推进 + 无在跑 turn)+ 落地(pane/会话/screen/进程)
820
+ cmd_close() {
821
+ local force="$1" task="$2" target="$3"
822
+ [[ -n "$target" ]] || die "close 需要目标:角色别名或派生会话名(如 rd-be / rd-be-T1)"
823
+ local name repo cls bus pane pids r ok
824
+ intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存
825
+ if [[ -n "$task" ]]; then
826
+ resolve_role "$target"
827
+ name="${NAME}-${task}"
828
+ repo="${ROLE_WS[$NAME]:-$PWD}"
829
+ # 闸门①:tasks-state 未推进 → 拒绝(验收未过需原会话返工,回收会丢上下文)
830
+ cls="$(task_state_class "$task")"
831
+ if [[ "$cls" == "active" && "$force" != true ]]; then
832
+ warn "拒绝回收 $name:tasks-state 中 $task 仍在进行态(未验收/未归档)"
833
+ warn " 先把 $task 移入「已验收/已归档」再回收(或 --force:将丢失打回返工所需上下文)"
834
+ return 1
835
+ fi
836
+ [[ "$cls" == "absent" ]] && warn "tasks-state 全文无 $task 记录(仅按会话名回收)"
837
+ [[ "$cls" == "mentioned" ]] && log "tasks-state 仅散文提及 $task(非表内记录,不阻塞回收)"
838
+ [[ "$cls" == "nofile" ]] && log "未启用 $TASKS_STATE(跳过状态闸门)"
839
+ elif [[ -n "${ALIAS_ROLE[$target]:-}" ]]; then
840
+ name="${ALIAS_ROLE[$target]}"; repo="${ROLE_WS[$name]:-$PWD}"
841
+ warn "常驻会话 $name:任务闭环后应 ensure --force 重开,而非回收(仅在本批不再需要该角色时回收)"
842
+ else
843
+ name="$target"
844
+ [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || die "非法会话名: $name"
845
+ # 非别名的目标必须是 <已知角色>-<编号>,否则视为拼错(防静默 no-op)
846
+ ok=0
847
+ for r in "${ROLE_ORDER[@]}"; do [[ "$name" == "$r-"* ]] && ok=1; done
848
+ (( ok )) || die "未知角色或派生会话名: $name(可用: ${ROLE_ORDER[*]};派生名形如 rd-be-T1)"
849
+ repo="$PWD"
850
+ fi
851
+
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
861
+
862
+ # 闸门②:在跑 turn(tmux 可判;非 tmux 宿主无法判 → 需 --force)
863
+ pane="$(find_pane_for "$name" "$repo")"
864
+ if [[ -n "$pane" ]]; then
865
+ pane_busy "$pane" && bus="tmux pane $pane 末行显示在跑 turn"
866
+ else
867
+ bus="非 tmux 宿主,无法确认是否在跑 turn"
868
+ fi
869
+ if [[ -n "$bus" && "$force" != true ]]; then
870
+ warn "拒绝回收 $name:$bus"
871
+ warn " 等它停;回执与产物核对见 checklists/pm.md「会话回收 / 收窗核对」(或 --force 强制)"
872
+ return 1
873
+ fi
874
+
875
+ if [[ -n "$pane" ]] && tmux kill-pane -t "$pane" 2>/dev/null; then
876
+ log "已回收 $name(tmux pane $pane)@ $repo"; return 0
877
+ fi
878
+ 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
879
+ log "已回收 $name(tmux 会话 nao-$name)@ $repo"; return 0
880
+ fi
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
882
+ log "已回收 $name(screen 会话 nao-$name)@ $repo"; return 0
883
+ fi
884
+ pids="$(pi_pids_for "$name" "$repo" | tr '\n' ' ')"
885
+ if [[ -n "$pids" ]] && kill $pids 2>/dev/null; then
886
+ log "已回收 $name(结束进程: $pids)@ $repo"; return 0
887
+ fi
888
+ warn "未找到 $name 的 pane/会话/进程(可能刚好退出)"; return 1
530
889
  }
531
890
 
532
891
  # ---------------------------------------------------------------------------
@@ -550,8 +909,8 @@ cmd_ensure() {
550
909
  key="$repo"; seen=0
551
910
  for k in "${cg_done[@]:-}"; do [[ "$k" == "$key" ]] && seen=1; done
552
911
  if (( ! seen )); then check_codegraph "$repo"; cg_done+=("$key"); fi
553
- if [[ "$force" != "true" ]] && running "$disp"; then
554
- warn "$disp 已在运行(--name 识别),跳过;确需重开请加 --force"
912
+ if [[ "$force" != "true" ]] && running "$disp" "$repo"; then
913
+ warn "$disp 已在运行(终端标题/名册命中),跳过;确需重开请加 --force"
555
914
  continue
556
915
  fi
557
916
  spawn_one "$disp" "$repo" "$model"
@@ -573,6 +932,7 @@ while [[ $# -gt 0 ]]; do
573
932
  case "$1" in
574
933
  check) CMD="check"; shift ;;
575
934
  status) CMD="status"; shift ;;
935
+ close) CMD="close"; shift ;;
576
936
  ensure) CMD="ensure"; shift ;;
577
937
  -m|--model)
578
938
  MODEL="${2:-}"
@@ -582,18 +942,45 @@ while [[ $# -gt 0 ]]; do
582
942
  --force) FORCE=true; shift ;;
583
943
  --task) TASK="${2:-}"; [[ -n "$TASK" ]] || die "--task 需要任务编号(如 T1)"; [[ "$TASK" =~ ^[A-Za-z0-9_-]+$ ]] || die "--task 非法: $TASK(仅字母/数字/-/_)"; shift 2 ;;
584
944
  --strict) STRICT=true; shift ;;
945
+ -v|--verbose) VERBOSE=true; shift ;;
585
946
  -h|--help) usage ;;
586
947
  *) TARGETS+=("$1"); shift ;;
587
948
  esac
588
949
  done
589
950
 
590
951
  case "$CMD" in
591
- check|ensure|status) load_manifest ;;
952
+ check) : ;; # cmd_check 自行先做文本契约体检,再 load_manifest(缩进违例时也能先出报告)
953
+ ensure|status|close) load_manifest ;;
592
954
  esac
593
955
 
594
956
  case "$CMD" in
595
- check) cmd_check "$STRICT" ;;
957
+ check)
958
+ # 默认单行摘要(派发前自检只需 exit code + 计数;完整报告 30+ 行不进 PM 上下文);-v 或失败时展开
959
+ # 子 shell 隔离:cmd_check 内部 die/exit 不得吞掉报告(计数从报告解析,不靠子 shell 内变量)
960
+ REPFILE="$(mktemp)"
961
+ set +e
962
+ ( cmd_check "$STRICT" ) >"$REPFILE" 2>&1
963
+ CHECK_RC=$?
964
+ set -e
965
+ if (( CHECK_RC == 0 )) && ! $VERBOSE; then
966
+ WARNS="$(grep -c '^ !' "$REPFILE" || true)"
967
+ ROLES_N="$(grep -oE '解析成功,[0-9]+ 个角色' "$REPFILE" | grep -oE '[0-9]+' | head -1 || true)"
968
+ FILES_N="$(grep -oE '[0-9]+ 个文本文件全 LF' "$REPFILE" | grep -oE '^[0-9]+' | head -1 || true)"
969
+ if (( WARNS > 0 )); then
970
+ printf 'check: OK · roles=%s · files=%s · layout=%s · warn=%d(-v 看详情)\n' \
971
+ "${ROLES_N:-?}" "${FILES_N:-?}" "$TMUX_LAYOUT" "$WARNS"
972
+ else
973
+ printf 'check: OK · roles=%s · files=%s · layout=%s\n' \
974
+ "${ROLES_N:-?}" "${FILES_N:-?}" "$TMUX_LAYOUT"
975
+ fi
976
+ else
977
+ cat "$REPFILE"
978
+ fi
979
+ rm -f "$REPFILE"
980
+ exit $CHECK_RC
981
+ ;;
596
982
  status) cmd_status ;;
983
+ close) cmd_close "$FORCE" "$TASK" "${TARGETS[0]:-}" ;;
597
984
  ensure) cmd_ensure "$FORCE" "$MODEL" "$TASK" "${TARGETS[@]}" ;;
598
985
  *) usage ;;
599
986
  esac
@@ -2,14 +2,30 @@
2
2
  description: 按需技能——架构模式速查 + ADR 模板
3
3
  ---
4
4
 
5
- # 架构模式速查
5
+ # 架构模式对照与 ADR
6
6
 
7
- | 场景 | 推荐架构 | 关键技术 |
8
- | :--- | :--- | :--- |
9
- | 高并发交易 | 微服务 + 事件驱动 | Kubernetes, Kafka, Redis |
10
- | 大数据处理 | Lambda 架构 | Spark, Flink, S3 |
11
- | 多租户 SaaS | 多租户 + 模块化 | PostgreSQL schemas, API Gateway |
12
- | 实时系统 | 事件溯源 + CQRS | Event Store, gRPC |
7
+ > 用法:先写清约束,再**对照**候选模式;禁止直接接表选型(同场景不同团队/规模,结论相反)。
8
+ > 检索与引用纪律见 @.agents/skills/research.md(报告落盘 `docs/research/`)。
9
+
10
+ ## 模式对照表(场景信号 → 候选 → 适用条件 → 代价 → 何时别用)
11
+
12
+ | 场景信号 | 候选模式 | 适用条件 | 代价 / 风险 | 何时别用 |
13
+ | :--- | :--- | :--- | :--- | :--- |
14
+ | 单团队、领域未定、读写同源 | 模块化单体 | 团队 <10 人、交付速度优先、边界待验证 | 后期拆分成本;模块纪律靠评审守 | 多团队并行交付、需独立扩缩容 |
15
+ | 读远多于写、查询形态多 | CQRS | 读写比悬殊、需要多种读模型 | 双模型同步、最终一致、运维面变大 | 以强一致读为主、数据量小 |
16
+ | 需完整审计与回放 | 事件溯源 | 合规/审计/时间旅行需求真实存在 | 事件版本迁移、查询复杂、删除权难处理 | 普通 CRUD、无审计需求 |
17
+ | 跨服务/跨库的事务 | Saga / Outbox | 无法用单库事务、需要可靠消息 | 补偿逻辑、幂等设计、调试难度 | 单库事务可覆盖 |
18
+ | 峰值差大、多方消费 | 消息队列 / 事件驱动 | 需削峰或解耦、消费方 ≥2 | 运维成本、顺序与幂等、可观测性 | 请求-响应强耦合、无峰值 |
19
+ | 多租户 SaaS | schema / RLS / 库级隔离 | 按租户数 + 隔离要求分档选 | 迁移复杂、噪声邻居、连接池压力 | 少数强隔离租户(该独立部署) |
20
+ | 多端体验差异大 | BFF | 端形态(Web/移动/第三方)差异成主要成本 | 多一层维护与部署 | 单端产品 |
21
+ | 依赖不稳的三方集成 | 网关 + 熔断 + 退避 | 外部依赖有抖动历史、影响主链路 | 复杂度、配置治理 | 内部可信调用 |
22
+
23
+ ## 对照四步法(写入 ADR 前必做)
24
+
25
+ 1. **写清约束**:NFR 基线、团队规模与熟悉度、预算与工期、迁移成本、既有约束(`AGENTS.md`)。
26
+ 2. **列 ≥2 个候选模式**(含"维持现状/最小改动"这个基线候选)。
27
+ 3. **逐条打分**:适用条件是否成立 → 代价能否承受 → 团队能否维护。
28
+ 4. **记录不采纳理由**:写进 ADR(不采纳的原因比采纳理由更有审计价值)。
13
29
 
14
30
  ## 最小示例
15
31
 
@@ -31,12 +47,16 @@ graph TD
31
47
  - 状态:提议 / 已接受 / 已废弃
32
48
  - 背景:<问题与约束>
33
49
  - 决策:<选择了什么>
34
- - 理由:<四步法分析>
50
+ - 理由:<对照四步法分析>
51
+ - 对照过的备选模式:<模式 → 不采纳原因(含"维持现状"基线)>
52
+ - 参考来源:<[标题](链接) · 访问 YYYY-MM-DD;无则写"未检索到">
35
53
  - 备选方案:<列表 + 否决理由>
36
54
  - 后果:<正面 / 负面 / 风险>
37
- - 相关:<PRD / 关联 ADR>
55
+ - 相关:<PRD / 关联 ADR / docs/research 路径>
38
56
  ```
39
57
 
58
+ > 长度上限 60 行、骨架与必写/可省/禁写见 @.agents/checklists/deliverable-docs.md §四。
59
+
40
60
  ## 误区
41
61
 
42
62
  - 过度设计 / 追新 → 务实、成熟稳定优先