@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.
- package/.agents/checklists/pm.md +8 -6
- package/.agents/checklists/rd-infra.md +50 -0
- package/.agents/common/intercom-protocol.md +9 -6
- package/.agents/prompts/infra-engineer.md +65 -0
- package/.agents/prompts/product-manager.md +29 -89
- package/.agents/roles.yaml +4 -0
- package/.agents/scripts/nao-fleet.sh +347 -78
- package/.agents/scripts/qq-notify +199 -0
- package/.agents/skills/pm-operations.md +92 -0
- package/.agents/skills/pm-routing.md +31 -0
- package/.agents/skills/qq-notify.md +87 -0
- package/.agents/skills/research.md +1 -1
- package/.agents/skills/test-design.md +9 -0
- package/README.md +19 -5
- package/bin/nao-skill.js +4 -4
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
22
|
-
# NAO_TMUX_MAIN_WIDTH=<10..90>
|
|
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
|
-
# -
|
|
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
|
-
#
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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() {
|
|
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
|
-
#
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 的归处:
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
if (
|
|
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 (
|
|
270
|
-
if (
|
|
271
|
-
print "
|
|
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
|
|
305
|
-
#
|
|
306
|
-
#
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
676
|
-
while IFS= read -r
|
|
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=""
|
|
938
|
+
id=""
|
|
681
939
|
for role in "${ROLE_ORDER[@]}"; do [[ "$dname" == "$role-"* ]] && { id="${dname#"$role"-}"; break; }; done
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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 < <(
|
|
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
|
|
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"
|
|
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
|
|
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"
|