@minonl/dia 0.0.0-stage → 0.0.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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +253 -2
  3. package/bin/dia.js +5 -0
  4. package/dist/cli.js +7408 -0
  5. package/docs/acp-integration.md +136 -0
  6. package/docs/architecture.md +166 -0
  7. package/docs/cli.md +117 -0
  8. package/docs/dependency-registry.md +105 -0
  9. package/docs/deps.jsonl +7 -0
  10. package/docs/ecosystem-integration.md +155 -0
  11. package/docs/ecosystem-positioning.md +95 -0
  12. package/docs/entrypoints.md +83 -0
  13. package/docs/evolution-process.md +298 -0
  14. package/docs/history/framework-bootstrap.md +109 -0
  15. package/docs/host-control-surface.md +213 -0
  16. package/docs/index.md +80 -0
  17. package/docs/kit-install.md +339 -0
  18. package/docs/migrations/kb-golden.md +32 -0
  19. package/docs/migrations/llm-migration.md +49 -0
  20. package/docs/migrations/no-python.md +52 -0
  21. package/docs/migrations/yan-agent-current.md +52 -0
  22. package/docs/migrations/yan-agent-next.md +168 -0
  23. package/docs/migrations/yan-kb-parity.md +159 -0
  24. package/docs/migrations/yan-kb.md +120 -0
  25. package/docs/model-baseline-sidecar.md +220 -0
  26. package/docs/model-behavior.md +229 -0
  27. package/docs/monorepo.md +83 -0
  28. package/docs/overview.md +126 -0
  29. package/docs/patching.md +91 -0
  30. package/docs/persona-install.md +264 -0
  31. package/docs/platforms/index.md +66 -0
  32. package/docs/platforms/registry.json +54 -0
  33. package/docs/pricing.md +43 -0
  34. package/docs/proposals.md +76 -0
  35. package/docs/research/benchmarks-survey.md +96 -0
  36. package/docs/research/collaboration-theory.md +85 -0
  37. package/docs/research/dsh-superagent.md +97 -0
  38. package/docs/research/frameworks-survey-2.md +75 -0
  39. package/docs/research/h0/report.md +135 -0
  40. package/docs/research/h0/sample.json +34 -0
  41. package/docs/research/model-boundary-patterns.md +99 -0
  42. package/docs/research/opencode-session-scoping.md +84 -0
  43. package/docs/review/reuse-review.md +105 -0
  44. package/docs/review/verification-2026-10-28.md +49 -0
  45. package/docs/security/audits/2026-11-04.json +44 -0
  46. package/docs/security/credentials.md +95 -0
  47. package/docs/security/public-release.md +108 -0
  48. package/docs/security-sandbox.md +157 -0
  49. package/docs/status.md +157 -0
  50. package/docs/team-automation.md +169 -0
  51. package/docs/team.md +112 -0
  52. package/docs/versioning.md +226 -0
  53. package/docs/vision.md +161 -0
  54. package/package.json +45 -4
  55. package/packages/job/README.md +98 -0
  56. package/packages/job/bin/dia-job +1283 -0
  57. package/packages/job/config.example +5 -0
  58. package/packages/job/install.sh +37 -0
  59. package/packages/job/lib/config.sh +18 -0
  60. package/packages/job/lib/json.mjs +130 -0
  61. package/packages/job/lib/policy.sh +103 -0
  62. package/packages/job/package.json +10 -0
  63. package/packages/job/shim/yan-task +24 -0
  64. package/packages/job/tests/bootstrap.test.sh +115 -0
  65. package/packages/job/tests/install.test.sh +30 -0
  66. package/packages/job/tests/linux.test.sh +130 -0
  67. package/packages/job/tests/p2.test.sh +46 -0
  68. package/packages/job/tests/policy.test.sh +38 -0
  69. package/packages/wt/bin/dia-wt +1148 -0
  70. package/packages/wt/tests/check.test.sh +126 -0
  71. package/packages/wt/tests/clean.test.sh +126 -0
  72. package/packages/wt/tests/prune-merged.test.sh +169 -0
  73. package/packages/wt/tests/route.test.sh +112 -0
  74. package/packages/wt/tests/scope.test.sh +69 -0
@@ -0,0 +1,1283 @@
1
+ #!/usr/bin/env bash
2
+ # dia-job —— 通用后台 / 定时任务入口(P2:根/前缀可配;兼容 yan-task)
3
+ # 设计:docs/compose/spec/yan-task-design.md(三型任务 + 四后端 + 边界自愈)
4
+ # 本增量:**只读**(list / status / doctor)。不写任何调度条目 —— 写入在下一增量(bootstrap/install)。
5
+ #
6
+ # ── 退出码契约(2026-10-13 补记;此前**完全未记载**,属接口事实)──────────────
7
+ # 0 成功(含「无需操作」类良性结果,如 list 为空、logs 无日志)
8
+ # 1 校验/操作**失败**(doctor 有 ✗ 项、selfcheck 有缺失、plist 语法错、launchctl 失败)
9
+ # 2 **用法/未知参数/未知子命令**(拼错命令、缺必填参数)
10
+ # 3 平台不支持(非 macOS 走 install 等写入类)
11
+ # 4 门控**拒绝**:路径不稳定(worktree)/ 未加逃生舱 / --takeover 缺 --desc
12
+ # 5 门控**拒绝**:目标是 adopted 任务(plist 由对方项目自管,重建会丢字段)
13
+ # ⚠️ 与 `yan-worktree` **刻意不同**:后者只有 0/1/2(简单三档);本脚本因含**路径与接管门控**,
14
+ # 额外用 3/4/5 表达「为什么拒绝」—— 调用方据此可分辨「我参数错了(2)」与
15
+ # 「被安全门控拦下(4/5,多为**正确行为**,别当 bug 重试)」。合并成两档会**丢失该信息**。
16
+ # ⚠️ 未知子命令两脚本**一致为 2**(2026-10-13 实测核对)。
17
+ set -uo pipefail
18
+
19
+ # ── SIGPIPE 加固(T8,2026-10-13 实测)─────────────────────────────────────────
20
+ # 现象:`yan-task doctor | head -2` 以 **141**(128+SIGPIPE)退出、输出被截断 ——
21
+ # 下游(head/less/grep -q)提前退出关管道后,本脚本仍在 printf。
22
+ # 属**用户真实用法**(翻看 doctor 长输出必用 `| less` / `| head`)。
23
+ # 修法:忽略 SIGPIPE ⇒ 管道下游截断为预期行为,退出码归 0。同 yan-worktree。
24
+ trap '' PIPE
25
+
26
+
27
+ # ── 测试隔离开关:YAN_TASK_NO_LAUNCHD=1 ⇒ **不做任何 launchd 写入**(plist/registry 照常)──
28
+ # 为什么需要(2026-10-16 实测事故):e2e 用**沙箱 HOME** 跑 install/uninstall,但 `launchctl
29
+ # bootstrap/bootout` 作用在**真实用户域** `gui/$UID`(无法沙箱化)⇒ **跑一次 e2e 就会 bootout
30
+ # 掉本机真实的 yan 定时任务**(现场:四个自有任务反复「未加载」,根因即当天多次跑 e2e)。
31
+ # ⇒ 测试侧设此开关;生产/会话侧**不设**(照旧真实加载)。
32
+ DIA_JOB_NO_LAUNCHD="${DIA_JOB_NO_LAUNCHD:-${DIA_JOB_NO_SCHED:-${YAN_TASK_NO_LAUNCHD:-0}}}"
33
+ YAN_TASK_NO_LAUNCHD="$DIA_JOB_NO_LAUNCHD"
34
+ _launchd_off() { [ "$YAN_TASK_NO_LAUNCHD" = 1 ]; }
35
+
36
+ # ── 平台库(**唯一实现**:平台差异只在那里表达)────────────────────────
37
+ # T1 起复用:dia_job_main_checkout_of / dia_job_path_stability / dia_job_extract_target
38
+ # ── 解析真实脚本路径(symlink 安全;macOS readlink 无 -f)────────────────
39
+ _SRC="${BASH_SOURCE[0]:-$0}"
40
+ while [ -L "$_SRC" ]; do
41
+ _D="$(cd -P "$(dirname "$_SRC")" >/dev/null 2>&1 && pwd)"
42
+ _SRC="$(readlink "$_SRC")"
43
+ case "$_SRC" in /*) ;; *) _SRC="$_D/$_SRC" ;; esac
44
+ done
45
+ SELF_DIR="$(cd -P "$(dirname "$_SRC")" >/dev/null 2>&1 && pwd)"
46
+ ROOT_DIR="$(cd "$SELF_DIR/.." >/dev/null 2>&1 && pwd)"
47
+ NODE_BIN="${DIA_JOB_NODE:-$(command -v node 2>/dev/null || command -v bun 2>/dev/null || true)}"
48
+ JSON_HELPER="$SELF_DIR/../lib/json.mjs"
49
+ json() { [ -n "$NODE_BIN" ] || { echo "dia-job: 找不到 node/bun(JSON 适配层需要)" >&2; return 3; }; "$NODE_BIN" "$JSON_HELPER" "$@"; }
50
+ # shellcheck source=lib/config.sh
51
+ . "$SELF_DIR/../lib/config.sh"
52
+ dia_job_load_config
53
+ # 数据根:DIA_JOB_ROOT 优先;未显式设置时兼容旧 YAN_DATA_ROOT;最后才用新默认。
54
+ DIA_JOB_ROOT="${DIA_JOB_ROOT:-${YAN_DATA_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/dia-job}}"
55
+ if [ ! -d "$DIA_JOB_ROOT" ]; then DIA_JOB_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}/dia-job"; fi
56
+ YAN_DATA_ROOT="$DIA_JOB_ROOT" # 内部兼容:旧函数/旧任务仍读这个变量
57
+ TASKS_DIR="$DIA_JOB_ROOT/tasks"
58
+ DIA_JOB_LABEL_PREFIX="${DIA_JOB_LABEL_PREFIX:-local.dia-job-}"
59
+ UID_NUM="$(id -u)"
60
+ # shellcheck source=lib/policy.sh
61
+ . "$SELF_DIR/../lib/policy.sh"
62
+
63
+ # ── 工具函数 ───────────────────────────────────────────────────────
64
+ # 数「非空行数」——**唯一实现**,用于一切 `[ N -gt X ]` 数值判定。
65
+ # ⚠️ **为什么不用 `grep -c . || echo 0`**(2026-10-13 T0 修,实测):
66
+ # `grep -c` 无匹配时**既输出 `0` 又返回退出码 1** → `|| echo 0` 再补一个 `0`
67
+ # ⇒ 变量成为**两行字符串 `"0\n0"`** ⇒ 数值比较报
68
+ # `[: 0\n0: integer expected`,且该分支判定**静默失效**(doctor 会照常打印结论,
69
+ # 但这一项的判定结果不可信 —— 属"工具自身在骗人",比崩掉更坏)。
70
+ # 纯 bash 实现,无子进程退出码歧义,零多行风险。
71
+ count_lines() {
72
+ local s="${1-}" n=0 line
73
+ while IFS= read -r line; do
74
+ [ -n "$line" ] && n=$((n+1))
75
+ done <<< "$s"
76
+ printf '%s' "$n"
77
+ }
78
+
79
+ # ── 平台探测(设计 §4.1)────────────────────────────────────────────
80
+ detect_platform() {
81
+ case "$(uname -s)" in
82
+ Darwin) echo macos ;;
83
+ Linux)
84
+ if grep -qi microsoft /proc/version 2>/dev/null; then echo wsl; else echo linux; fi ;;
85
+ MINGW*|MSYS*|CYGWIN*) echo windows-native ;;
86
+ *) echo unknown ;;
87
+ esac
88
+ }
89
+ PLATFORM="$(detect_platform)"
90
+
91
+ # ── 调度/服务后端(设计 §4:macOS=launchd;Linux+WSL=periodic 走 cron、service 走 systemd --user)──
92
+ # 为什么 Linux 要分两个后端(2026-10-30 用户拍板 A 方案):
93
+ # cron 表达不了「常驻 + 挂了重拉」,systemd 表达不了「每 N 分钟跑一次」而不额外写 timer 单元;
94
+ # 两者都在**本用户作用域**(`systemctl --user` / `crontab` 自己的表),不需要 root。
95
+ # ⇒ periodic=cron、service=systemd,各自用对的工具,任务 manifest/wrapper 完全不感知平台。
96
+ CRONTAB_BIN="${DIA_JOB_CRONTAB_BIN:-$(command -v crontab 2>/dev/null || true)}"
97
+ SYSTEMCTL_BIN="${DIA_JOB_SYSTEMCTL_BIN:-$(command -v systemctl 2>/dev/null || true)}"
98
+ # cron 的最小环境:wrapper/任务里用的 node/bun/dia 多在 ~/.local/bin ⇒ 行内显式给 PATH
99
+ DIA_JOB_CRON_PATH="${DIA_JOB_CRON_PATH:-$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin}"
100
+ _cron_marker() { printf '# dia-job:%s' "$1"; }
101
+ _unit_dir() { printf '%s' "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"; }
102
+ unit_path() { printf '%s/%s.service' "$(_unit_dir)" "$(label_for "$1")"; }
103
+ _have_systemd() { [ -n "$SYSTEMCTL_BIN" ] && "$SYSTEMCTL_BIN" --user show-environment >/dev/null 2>&1; }
104
+
105
+ # ── 跨平台 mtime(GNU/BSD 顺序**不能反**)─────────────────────────────────────
106
+ # ⚠️ 2026-10-30 实测:Linux 上 `stat -f %m FILE` **不报错**(那是「看文件系统」)⇒
107
+ # `stat -f … || stat -c …` 的兜底永远不触发,age 由垃圾值算出 ⇒ 心跳/日志时间全是假的。
108
+ # ⇒ GNU 语法(Linux)优先,BSD(macOS)兜底。
109
+ _mtime_secs() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; }
110
+ _mtime_human() { # → "YYYY-MM-DD HH:MM"
111
+ local d
112
+ if d="$(stat -c '%y' "$1" 2>/dev/null)"; then printf '%s' "$d" | cut -c1-16
113
+ else stat -f '%Sm' -t '%m-%d %H:%M' "$1" 2>/dev/null; fi
114
+ }
115
+
116
+ # ── 发现已登记任务 ─────────────────────────────────────────────────
117
+ # ⚠️ **registry 是唯一列表**(2026-10-12 修,用户指出的一致性缺陷):
118
+ # 原实现用 `^<前缀>` **正则扫目录**来「发现」,导致两个事实源:
119
+ # ① `list` 扫目录(隐含「只认特定前缀」的约束,非该前缀的任务**看不见**);
120
+ # ② `status` 读 registry —— 两者**可能不一致**(登记了却看不见 / 目录有却查不到)。
121
+ # 改为 **registry 优先**;仅当 registry 缺失/为空时才回退扫目录(保持初始行为,零破坏)。
122
+ # ⇒ 前缀回归「纯偏好」(见设计 §9.0),任意 label 都能纳入管理。
123
+ discover() {
124
+ # 1) registry 优先:登记表就是唯一列表
125
+ if [ -f "$REG" ]; then
126
+ local labels
127
+ labels="$(json labels "$REG" "$DIA_JOB_LABEL_PREFIX" 2>/dev/null)"
128
+ if [ -n "$labels" ]; then printf '%s\n' "$labels"; return 0; fi
129
+ fi
130
+ # 2) 回退:registry 缺失/为空时扫真实载体(顺序与历史一致)
131
+ case "$PLATFORM" in
132
+ macos)
133
+ ls -1 "$HOME/Library/LaunchAgents" 2>/dev/null | grep -E "^$(printf '%s' "$DIA_JOB_LABEL_PREFIX" | sed 's/\./\\./g')" | sed 's/\.plist$//' | sort -u ;;
134
+ linux|wsl)
135
+ _cron_read | grep -oE '# (dia-job|yan-task):[a-z0-9._-]+' | sed -E 's/# (dia-job|yan-task)://'
136
+ # systemd --user unit(service 型):按 label 前缀扫 unit 目录
137
+ ls -1 "$(_unit_dir)" 2>/dev/null | grep -E "^$(printf '%s' "$DIA_JOB_LABEL_PREFIX" | sed 's/\./\\./g')" | sed -e 's/\.service$//' -e "s/^${DIA_JOB_LABEL_PREFIX}//" || true
138
+ ;;
139
+ *) : ;;
140
+ esac
141
+ }
142
+
143
+ cmd_list() {
144
+ echo "平台: $PLATFORM 数据根: $YAN_DATA_ROOT"
145
+ local found=0
146
+ while read -r t; do
147
+ [ -n "$t" ] || continue
148
+ found=1; echo " · $t"
149
+ done < <(discover)
150
+ [ "$found" = 1 ] || echo " (未发现已登记任务;现有 session-diary / review 尚未纳入统一命名 ✓ 属预期)"
151
+ }
152
+
153
+ cmd_status() {
154
+ echo "平台: $PLATFORM 数据根: $YAN_DATA_ROOT"
155
+ # registry 里有 label 的按 label 查;其余按 discovered 名字拼
156
+ local entries
157
+ if [ -f "$REG" ]; then
158
+ entries="$(json entries "$REG" "$YAN_DATA_ROOT" "$DIA_JOB_LABEL_PREFIX" 2>/dev/null)"
159
+ else
160
+ entries="$(discover | sed 's/$/|periodic|-|$YAN_DATA_ROOT\/tasks\/x.log/')"
161
+ fi
162
+ [ -n "$entries" ] || { echo " (未登记任何任务)"; return 0; }
163
+ while IFS='|' read -r name label type sched log; do
164
+ [ -n "$name" ] || continue
165
+ printf ' %-9s %-9s %s\n' "$name" "$type" "$label"
166
+ local st="" pid="" last=""
167
+ local backend=""
168
+ [ -f "$REG" ] && backend="$(json field "$REG" "$name" backend 2>/dev/null || true)"
169
+ if [ "$PLATFORM" = macos ]; then
170
+ local info; info="$(launchctl print "gui/$UID_NUM/$label" 2>/dev/null)"
171
+ if [ -n "$info" ]; then
172
+ st="$(printf '%s' "$info" | awk -F' = ' '/^\tstate = /{print $2; exit}')"
173
+ pid="$(printf '%s' "$info" | awk -F' = ' '/^\tpid = /{print $2; exit}')"
174
+ last="$(printf '%s' "$info" | awk -F' = ' '/last exit code = /{print $2; exit}')"
175
+ else st="absent"; fi
176
+ elif [ "$backend" = systemd ]; then
177
+ # systemd --user:状态与 MainPID(≈ launchctl print 的 state/pid)
178
+ local s; s="$(systemd_state "$name")"
179
+ st="${s%% *}"; case "$s" in *PID=*) pid="${s##*PID=}" ;; esac
180
+ elif [ "${backend:-cron}" = cron ]; then
181
+ # cron:**没有进程状态**(一次性进程),只能看条目是否在 + state.jsonl 的上次结论
182
+ if cron_present "$name"; then st="scheduled"; else st="absent"; fi
183
+ fi
184
+ printf ' 调度=%-10s 状态=%-10s%s\n' "$sched" "${st:-?}" "${pid:+ PID=$pid}"
185
+ local sf="$YAN_DATA_ROOT/tasks/$name.state.jsonl"
186
+ if [ -f "$sf" ]; then
187
+ json state-last "$sf" 2>/dev/null
188
+ else
189
+ printf ' 上次运行: (无 state;adopted 任务由其项目自管)\n'
190
+ fi
191
+ [ -f "$log" ] && printf ' 日志: %s(%s · %s)\n' "${log##*tasks/}" "$(wc -c < "$log" | tr -d ' ')B" "$(_mtime_human "$log")"
192
+ done <<< "$entries"
193
+ }
194
+
195
+ cmd_logs() { # logs <name> [-n N]:看某个任务最近在干什么(最直观)
196
+ local name="${1:-}" n=30
197
+ shift || true
198
+ [ "${1:-}" = "-n" ] && { n="$2"; shift 2; }
199
+ [ -n "$name" ] || { echo "用法:yan-task logs <name> [-n N]" >&2; return 2; }
200
+ local log="$YAN_DATA_ROOT/tasks/$name.log"
201
+ if [ ! -f "$log" ] && [ -f "$REG" ]; then
202
+ log="$(json log "$REG" "$name" "$YAN_DATA_ROOT/tasks/$name.log" 2>/dev/null)"
203
+ fi
204
+ [ -f "$log" ] || { echo "无日志:${log}(任务可能还没跑过)"; return 0; }
205
+ echo "── ${log}(尾 $n 行)──"; tail -n "$n" "$log"
206
+ }
207
+
208
+ # ── doctor:只读体检(把 2026-10-10 的两类坑变成可检测)──────────────
209
+ cmd_doctor() {
210
+ local bad=0 warn=0
211
+ echo "══ dia-job doctor(${PLATFORM})══"
212
+
213
+ # Linux/WSL:后端 = cron(periodic) + systemd(service) —— 检查项换成这一类(macOS 的
214
+ # GUI App / launchd pid 判定在 Linux 上无意义,硬跑只会制造假阳性)。
215
+ if [ "$PLATFORM" != macos ]; then
216
+ if [ "$PLATFORM" = linux ] || [ "$PLATFORM" = wsl ]; then
217
+ # 1) crontab 可用性(periodic 后端的前提)
218
+ if [ -n "$CRONTAB_BIN" ]; then
219
+ echo " ✓ crontab 可用(${CRONTAB_BIN})"
220
+ else
221
+ echo " ✗ 找不到 crontab —— periodic 任务装不了(apt install cron / dnf install cronie)"; bad=$((bad+1))
222
+ fi
223
+ # 2) cron 守护进程是否在跑(装了但没跑 = 静默不执行,最坑)
224
+ if command -v systemctl >/dev/null 2>&1; then
225
+ if systemctl is-active --quiet cron 2>/dev/null || systemctl is-active --quiet crond 2>/dev/null; then
226
+ echo " ✓ cron 守护进程在跑"
227
+ else
228
+ echo " ⚠️ cron 守护进程未运行(systemctl status cron)—— 条目在也不会触发"; warn=$((warn+1))
229
+ fi
230
+ fi
231
+ # 3) systemd --user(service 后端的前提)+ linger(不 linger 的话退出登录即停服务)
232
+ if _have_systemd; then
233
+ echo " ✓ systemctl --user 可用"
234
+ local linger
235
+ linger="$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null || true)"
236
+ if [ "$linger" = yes ]; then echo " ✓ linger 已开启(登出后服务仍跑)"
237
+ else
238
+ echo " ⚠️ linger 未开启 —— 注销后 user service 会停(永久服务:loginctl enable-linger $(id -un))"; warn=$((warn+1))
239
+ fi
240
+ else
241
+ echo " ⚠️ systemctl --user 不可用 —— service 型任务装不了"; warn=$((warn+1))
242
+ fi
243
+ # 4) 登记的条目是否都在场(按后端分别判)
244
+ if [ -f "$REG" ]; then
245
+ local _n=0
246
+ while IFS=$'\t' read -r short _label _adopted; do
247
+ [ -n "$short" ] || continue
248
+ _n=$((_n+1))
249
+ local _be; _be="$(json field "$REG" "$short" backend 2>/dev/null || true)"
250
+ case "${_be:-cron}" in
251
+ systemd) "$SYSTEMCTL_BIN" --user is-enabled "$(label_for "$short").service" >/dev/null 2>&1 \
252
+ && echo " ✓ ${short}(systemd 已启用)" || { echo " ✗ ${short}(systemd unit 未启用)"; bad=$((bad+1)); } ;;
253
+ *) cron_present "$short" && echo " ✓ ${short}(crontab 有条目)" || { echo " ✗ ${short}(crontab 缺条目)"; bad=$((bad+1)); } ;;
254
+ esac
255
+ done < <(json selfcheck-rows "$REG" "$DIA_JOB_LABEL_PREFIX")
256
+ [ "$_n" -gt 0 ] || echo " · registry 为空"
257
+ else
258
+ echo " · 无 registry(未装任何任务)"
259
+ fi
260
+ echo "── 结论:$([ "$bad" = 0 ] && echo '✅ 通过' || echo "✗ ${bad} 项失败")${warn:+ · $warn 警告 ──}"
261
+ [ "$bad" = 0 ] || return 1
262
+ return 0
263
+ fi
264
+ echo " ⚠️ 平台 ${PLATFORM} 未实现后端(设计 §4.1)"; return 3
265
+ fi
266
+
267
+
268
+ # 1) GUI App 多实例(当天的血泪:旧实例用旧代码托管会话)
269
+ # ⚠️ 判据经过两轮实测修正(2026-10-12):
270
+ # 《坑 A》**必须用 `ps`,不能用 `pgrep`** —— 实测 `pgrep -f "MacOS/DeepSeek Harness"`
271
+ # **漏掉了 GUI 主进程**(14851,ps 可见、kill -0 存活,但 pgrep 不返回)⇒ 数出 0 个 ✗。
272
+ # 《坑 B》**不能用 `--expose-internals` 判「是不是服务」** —— GUI App 的 CLI 子进程
273
+ # (14872,即人当前会话)也带该参数 ⇒ 会被误判成服务 ✗。
274
+ # 《坑 C》**不能数 `ppid==1`** —— GUI App 主进程与 launchd 服务**都必然 ppid=1**
275
+ # (实测 14851 ppid=1、18046 ppid=1)⇒ 正常即 ≥2,必误报 ✗。
276
+ # ⇒ 正确判据:**GUI App 主进程** = 命令行形如 `<App>/Contents/MacOS/DeepSeek Harness`
277
+ # **且不带** `--expose-internals`(区分纯 App 与 CLI 子进程)。
278
+ # 正常应为 **1 个**;>1 = 残留实例(真问题)。Helper/Renderer 一律不计(它们是子进程)。
279
+ local gui_n gui_list
280
+ gui_list="$(ps -eo pid,command 2>/dev/null | grep -E "MacOS/DeepSeek Harness" | grep -v grep \
281
+ | grep -vE "/Frameworks/" \
282
+ | grep -v -- "--expose-internals" \
283
+ | awk '{print $1}')"
284
+ # ⚠️ **不能写 `grep -c . || echo 0`**(2026-10-13 修,实测报错):
285
+ # `grep -c` 在**无匹配**时既输出 `0` **又**返回退出码 1 → 触发 `|| echo 0`
286
+ # ⇒ 变量变成**两行的 `"0\n0"`** ⇒ `[ "0\n0" -gt 1 ]` 报
287
+ # `scripts/yan-task: line 146: [: 0\n0: integer expected`,
288
+ # 该分支判定**完全失效**(doctor 的这项检查形同虚设)。
289
+ # ⇒ 统一走 `count_lines`(纯 bash、无 `||` 兜底、零多行风险)。
290
+ gui_n="$(count_lines "$gui_list")"
291
+ if [ "${gui_n:-0}" -gt 1 ]; then
292
+ echo " ✗ 有 $gui_n 个 GUI App 主进程(正常 1)—— 多实例会让旧代码继续托管会话"
293
+ printf '%s\n' $gui_list | sed 's/^/ pid=/'
294
+ bad=$((bad+1))
295
+ elif [ "${gui_n:-0}" -eq 1 ]; then
296
+ echo " ✓ GUI App 主进程 1 个(正常;Helper/Renderer 为其子进程,不计)"
297
+ else
298
+ echo " ⚠️ 未发现 GUI App 主进程(可能 App 未运行;CLI/服务进程不计)"; warn=$((warn+1))
299
+ fi
300
+ # 2) 无限期监听(--duration 0 会把 App 拖常驻)
301
+ # ⚠️ 判据必须区分「**受控**子进程」与「**野生**进程」(2026-10-12 修,用户指出):
302
+ # 原实现只 grep `--duration 0`,不分宿主 → 常驻服务(launchd → 服务 → dws)也报错 ✗,
303
+ # 属**假阳性**(狼来了:真出问题时会被忽略)。
304
+ # 实情:`--duration 0` 的危害是「**无人管生命周期**的野生进程泄漏」——
305
+ # 进程被 launchd 托管、PPID=1、关窗杀不到、旧实例继续用旧代码应答(2026-10-10 事故)。
306
+ # 而**服务托管的**监听是服务子进程:服务重启 → 随之重建,**不存在泄漏**。
307
+ # ⇒ 判据改为:**PPID 能追到 launchd 已加载任务的** = 受控(不报);
308
+ # 其余(尤其 PPID=1 或父进程已消失)= 野生 → 报警。
309
+ # 实现:`--duration 0` 的监听进程,逐个查其父进程是否在 `launchctl list` 的存活 pid 里。
310
+ local inf zero_pids
311
+ inf="$(ps -eo pid,command 2>/dev/null | grep -E "dws event \+listen-im" | grep -v grep | wc -l | tr -d ' ')"
312
+ if [ "${inf:-0}" -gt 0 ]; then
313
+ # 取所有 --duration 0 的监听 pid
314
+ zero_pids="$(ps -eo pid,command 2>/dev/null | grep -E "dws event \+listen-im" | grep -v grep \
315
+ | grep -E "\-\-duration 0\b" | awk '{print $1}')"
316
+ if [ -z "$zero_pids" ]; then
317
+ echo " ⚠️ 有 $inf 个钉钉监听进程(有界则正常)"; warn=$((warn+1))
318
+ else
319
+ # launchd 已加载任务的全部存活 pid(受控进程的锚点)
320
+ local live_pids; live_pids="$(launchctl list 2>/dev/null | awk '$1 ~ /^[0-9]+$/ {print $1}')"
321
+ local wild="" wp ppid_ok
322
+ for wp in $zero_pids; do
323
+ local pp; pp="$(ps -o ppid= -p "$wp" 2>/dev/null | tr -d ' ')"
324
+ ppid_ok=0
325
+ # 父进程是 launchd 托管的存活任务 → 受控
326
+ if [ -n "$pp" ] && printf '%s\n' "$live_pids" | grep -qx "$pp"; then ppid_ok=1; fi
327
+ # 退化判断:父进程链也允许「父进程是某个存活任务的子进程」(如 run-service.sh → dsh)
328
+ if [ "$ppid_ok" = 0 ] && [ -n "$pp" ] && [ "$pp" != "1" ]; then
329
+ local gp; gp="$(ps -o ppid= -p "$pp" 2>/dev/null | tr -d ' ')"
330
+ if [ -n "$gp" ] && printf '%s\n' "$live_pids" | grep -qx "$gp"; then ppid_ok=1; fi
331
+ fi
332
+ [ "$ppid_ok" = 0 ] && wild="$wild $wp"
333
+ done
334
+ if [ -n "$wild" ]; then
335
+ echo " ✗ 存在**无人托管**的无限期监听(--duration 0):${wild}"
336
+ echo " → 危害:进程泄漏 / 旧实例继续用旧代码应答(2026-10-10 事故)。先退干净再启。"
337
+ bad=$((bad+1))
338
+ else
339
+ echo " ✓ --duration 0 监听均**由 launchd 托管**(服务子进程随服务重建,无泄漏风险)"
340
+ fi
341
+ fi
342
+ else
343
+ echo " ✓ 无后台钉钉监听进程"
344
+ fi
345
+
346
+ # 3) plist 体检(坑 1:注释里不能有 `--`;plutil 校验语法)
347
+ if [ "$PLATFORM" = macos ]; then
348
+ local p badplist=0
349
+ for p in "$HOME/Library/LaunchAgents"/local.dia-job.*.plist; do
350
+ [ -f "$p" ] || continue
351
+ plutil -lint "$p" >/dev/null 2>&1 || { echo " ✗ plist 语法错误:$(basename "$p")"; bad=$((bad+1)); badplist=1; }
352
+ grep -nE '^\s*<!--.*--.*-->' "$p" >/dev/null 2>&1 && { echo " ✗ plist 注释含 '--'(坑 1,高危):$(basename "$p")"; bad=$((bad+1)); badplist=1; }
353
+ done
354
+ [ "$badplist" = 0 ] && echo " ✓ launchd plist 语法与注释检查通过"
355
+ fi
356
+
357
+ # 4) cron 的显式 PATH(cron 是最小环境;wrapper 必须自带 PATH)
358
+ if [ "$PLATFORM" = linux ] || [ "$PLATFORM" = wsl ]; then
359
+ if crontab -l 2>/dev/null | grep -vE '^\s*#' | grep -qvE 'PATH=|^\s*$'; then
360
+ echo " ⚠️ crontab 有条目未显式设置 PATH(cron 是最小环境,易静默失败)"; warn=$((warn+1))
361
+ else
362
+ echo " ✓ crontab PATH 检查通过(或无条目)"
363
+ fi
364
+ fi
365
+
366
+ # 5) 心跳过期(判活着看心跳,不扫日志全文 —— 坑 6)
367
+ if [ -d "$TASKS_DIR" ]; then
368
+ local f age stale=0
369
+ for f in "$TASKS_DIR"/*.heartbeat; do
370
+ [ -f "$f" ] || continue
371
+ age=$(( $(date +%s) - $(_mtime_secs "$f") ))
372
+ [ "$age" -gt 600 ] && { echo " ⚠️ 心跳过期 $((age/60)) 分钟:$(basename "$f" .heartbeat)"; warn=$((warn+1)); stale=1; }
373
+ done
374
+ [ "$stale" = 0 ] && echo " ✓ 心跳检查通过(或暂无任务)"
375
+ else
376
+ echo " · 尚无 ${TASKS_DIR}(任务 state/心跳目录,随 install 建立)"
377
+ fi
378
+
379
+ # 6) registry ↔ 实际载体 一致性(2026-10-12 加:防止两个事实源再次漂移)
380
+ # 背景:`discover` 曾用正则扫目录「发现」任务,与 registry 形成两个事实源 →
381
+ # 登记了却看不见 / 目录有却查不到。现已改 registry 优先,此处再加体检项兜底。
382
+ if [ -f "$REG" ]; then
383
+ local reglabels loadlabels
384
+ reglabels="$(json labels "$REG" "$DIA_JOB_LABEL_PREFIX" 2>/dev/null | sort)"
385
+ loadlabels="$(launchctl list 2>/dev/null | awk '{print $3}' | grep -E '^com\.' | sort)"
386
+ local onlyreg onlyload
387
+ onlyreg="$(comm -23 <(printf '%s\n' "$reglabels") <(printf '%s\n' "$loadlabels") 2>/dev/null | grep -v '^$')"
388
+ # 只报**我们前缀**的(local.dia-job.* / com.example.*)—— 第三方 App(ssh-agent/Oray/深信服…)
389
+ # 未登记属正常,不该告警(2026-10-12 实测:不加范围会刷一堆无关项)。
390
+ local pfx_re
391
+ pfx_re="$(printf '%s' "$DIA_JOB_LABEL_PREFIX" | sed 's/\./\\./g')"
392
+ onlyload="$(comm -13 <(printf '%s\n' "$reglabels") <(printf '%s\n' "$loadlabels") 2>/dev/null | grep -v '^$' | grep -E "^${pfx_re}" | head -10)"
393
+ if [ -n "$onlyreg" ]; then
394
+ echo " ⚠️ 已登记但未加载(selfcheck 会重建):"; printf '%s\n' "$onlyreg" | sed 's/^/ /'; warn=$((warn+1))
395
+ fi
396
+ if [ -n "$onlyload" ]; then
397
+ echo " ⚠️ 已加载但未登记(不纳管 → list 看不见):"; printf '%s\n' "$onlyload" | sed 's/^/ /'; warn=$((warn+1))
398
+ fi
399
+ [ -z "$onlyreg$onlyload" ] && echo " ✓ registry 与已加载任务一致"
400
+ fi
401
+
402
+ # 6) 注册命令的**目标可解析性**(T3 核心,INV-5)
403
+ # 为什么不能只比 registry.cmd 与 plist.ProgramArguments(设计修正 2,实测):
404
+ # `install` 会生成 `tasks/<name>.sh` 包装脚本 —— plist 指向**包装脚本**,
405
+ # registry 存**原始 cmd** ⇒ 二者**天然不同**,逐字比对必然误报。
406
+ # 真判据 = 「**沿调用链能否解析到实际存在的执行体**」。
407
+ if [ -f "$REG" ]; then
408
+ local missing_n=0 cls_bad=0 name c
409
+ while IFS=$'\t' read -r name c; do
410
+ [ -n "$name" ] || continue
411
+ dia_job_cmd_path_class "$c"
412
+ # 空 cmd(adopt 的任务)→ 无可判,跳过(设计 E16/第 5 态)
413
+ [ "$YP_CLASS" = "none" ] && continue
414
+ if [ -z "$YP_CMD_TARGET" ]; then
415
+ echo " ⚠️ 任务「${name}」的命令中未提取到绝对路径:$c"; warn=$((warn+1)); continue
416
+ fi
417
+ # 逐段复核最内层目标是否存在(含包装脚本链路:plist → tasks/<name>.sh → 真实 cmd)
418
+ if [ ! -e "$YP_CMD_TARGET" ]; then
419
+ echo " ✗ 任务「${name}」的命令目标不存在:${YP_CMD_TARGET}(服务会 exit=127)"
420
+ missing_n=$((missing_n+1))
421
+ fi
422
+ # 7) path_class 一致性 + 「登记了安装器」语义可疑(E5)
423
+ local rec; rec="$(json field "$REG" "$name" path_class 2>/dev/null)"
424
+ if [ -n "$rec" ] && [ "$rec" != "$YP_CLASS" ]; then
425
+ echo " ⚠️ 任务「${name}」path_class 漂移:记录=$rec 实况=$YP_CLASS"
426
+ warn=$((warn+1)); cls_bad=$((cls_bad+1))
427
+ elif [ -z "$rec" ]; then
428
+ echo " · 任务「${name}」尚无 path_class(跑 'yan-task backfill' 补判)"
429
+ fi
430
+ case "$(basename "$YP_CMD_TARGET")" in
431
+ *install*|*setup*) echo " ⚠️ 任务「${name}」的命令像**安装器**而非服务本体:$YP_CMD_TARGET"; warn=$((warn+1)) ;;
432
+ esac
433
+ done < <(json rows "$REG")
434
+ [ "$missing_n" = 0 ] && [ "$cls_bad" = 0 ] && echo " ✓ 各任务命令目标均存在且 path_class 一致"
435
+ bad=$((bad+missing_n))
436
+ fi
437
+
438
+ # 8) **失败可见性**(T3,INV-6/7)—— 只读 verdict,**不扫日志**(设计 D3 ⚠️)
439
+ # 判据:最近一轮 exit=0 但 verdict != ok ⇒ 「**假成功**」。
440
+ # ⚠️ **绝不用 grep 扫日志**:launchd 的 StandardOutPath 是追加式的 →
441
+ # 历史错误行永久留存 ⇒ 已修好的任务**永久误报**(坑 6 假阳性)。
442
+ # 这正是 state.jsonl 要扩 verdict 字段的原因(T2b)。
443
+ if [ -d "$TASKS_DIR" ]; then
444
+ local fake=0 legacy=0 sf last
445
+ for sf in "$TASKS_DIR"/*.state.jsonl; do
446
+ [ -f "$sf" ] || continue
447
+ [ -s "$sf" ] || continue
448
+ last="$(tail -1 "$sf")"
449
+ case "$last" in
450
+ *'"verdict"'*)
451
+ local vex vvd
452
+ vex="$(printf '%s' "$last" | sed -n 's/.*"exit"[[:space:]]*:[[:space:]]*\([0-9-]*\).*/\1/p')"
453
+ vvd="$(printf '%s' "$last" | sed -n 's/.*"verdict"[[:space:]]*:[[:space:]]*"\([a-z]*\)".*/\1/p')"
454
+ if [ "$vex" = "0" ] && [ -n "$vvd" ] && [ "$vvd" != "ok" ]; then
455
+ echo " ✗ 「$(basename "$sf" .state.jsonl)」最近一轮 exit=0 但 verdict=$vvd ⇒ **假成功**(没干成活却报成功)"
456
+ fake=$((fake+1))
457
+ fi ;;
458
+ *) legacy=$((legacy+1)) ;; # 旧 schema:跳过(E6b 向后兼容,不误报)
459
+ esac
460
+ done
461
+ if [ "$fake" = 0 ]; then
462
+ echo " ✓ 无「假成功」"
463
+ [ "$legacy" -gt 0 ] && echo " · $legacy 个任务仍为旧 state schema(待下次运行产生 verdict,非故障)"
464
+ fi
465
+ bad=$((bad+fake))
466
+ fi
467
+
468
+ # 9) **生效指针(active-ref)**(2026-10-16 加,规则 R56)
469
+ # 背景:yan-agent 主 worktree 是**实际生效配置 ≈ 生产**;2026-10-16 事故是"另一会话用
470
+ # mixed reset 把 27 个交付文件在它视角里变成已删除,再走一步 git add -A 就会回退生产"。
471
+ # 判据:调 `yan-ref check`(0 干净 / 1 警告 / 2 阻断);**未部署该工具时优雅跳过**(不误报)。
472
+ local yref=""
473
+ for c in "${YAN_DATA_ROOT:-$HOME/.yan-agent}/bin/yan-ref" "$SELF_DIR/yan-ref"; do
474
+ [ -x "$c" ] && yref="$c" && break
475
+ done
476
+ if [ -z "$yref" ]; then
477
+ echo " · 生效指针(active-ref):未部署 yan-ref,跳过(部署:core/dws-hub/service/install.sh 或 scripts/yan-ref)"
478
+ else
479
+ local refout refcode=0
480
+ refout="$("$yref" check 2>&1)" || refcode=$?
481
+ refout="$(printf '%s' "$refout" | tail -n +2)" # 去掉首行标题,doctor 自己排版
482
+ case "$refcode" in
483
+ 0) echo " ✓ 生效指针一致(主 checkout 干净、指针与生效对象相符)" ;;
484
+ 1) printf '%s\n' "$refout" | sed 's/^/ /'
485
+ echo " ⚠️ 生效指针有警告(见上)"; warn=$((warn+1)) ;;
486
+ *) printf '%s\n' "$refout" | sed 's/^/ /'
487
+ echo " ✗ 生效指针阻断:主 checkout 有「已删除」等高风险在制改动 —— **提交前务必 git status 看清,别 git add -A**(规则 R56)"
488
+ bad=$((bad+1)) ;;
489
+ esac
490
+ fi
491
+
492
+ echo "── 结论:✗ $bad 项 · ⚠️ $warn 项 ──"
493
+ [ "$bad" = 0 ] || return 1
494
+ return 0
495
+ }
496
+
497
+
498
+ # ── 写入类:install / uninstall / bootstrap(macOS 后端;设计 §4/§6.1)────
499
+ REG="$YAN_DATA_ROOT/tasks/registry.json"
500
+
501
+ reg_get() { [ -f "$REG" ] && json get "$REG" "$1" 2>/dev/null || echo '{}'; }
502
+ reg_set() { mkdir -p "$YAN_DATA_ROOT/tasks"; json set "$REG" "$1" "$2"; }
503
+ reg_del() { json del "$REG" "$1"; }
504
+
505
+ label_of() { echo "${DIA_JOB_LABEL_PREFIX}${1#yan-}"; } # 归一化:yan-hello → local.dia-job-hello(不叠 yan)
506
+
507
+ # ── T2:registry 存量补判(backfill)──────────────────────────────────────────
508
+ # 语义(设计 S6 迁移 1/2 + E16):存量条目**缺 path_class/layer 也能跑**(向后兼容);
509
+ # 本命令**就地补判并回写**,幂等(已有值不覆盖,除非 --force)。
510
+ # 约束(设计确认):
511
+ # · **保持 {"tasks": {…}} 嵌套**与既有缩进(该文件人工可读、手工维护)
512
+ # · `cmd` 为空 → path_class=none(**跳过路径判定**,不是 unknown)
513
+ # · 判出 ephemeral(存量不该有)→ **只报告、不自动改**(避免误改他人登记)
514
+ # 用 layer 推断(D7):按 cmd 路径所属仓判 yan-agent / personal-agent / project
515
+ cmd_backfill() {
516
+ local force=0 dry=0
517
+ while [ $# -gt 0 ]; do case "$1" in --force) force=1; shift ;; --dry-run) dry=1; shift ;; *) shift ;; esac; done
518
+ [ -f "$REG" ] || { echo "无 registry(${REG})"; return 0; }
519
+ local flags=""
520
+ [ "$dry" = 1 ] && flags="$flags · dry-run"
521
+ [ "$force" = 1 ] && flags="$flags · force"
522
+ echo "══ yan-task backfill($PLATFORM${flags})══"
523
+
524
+ # 逐条判定 → 输出 TSV 给 python 回写(避免在 python 里 source bash 库)
525
+ local rows="" name cmd cls layer changed=0 skipped=0
526
+ while IFS=$'\t' read -r name cmd; do
527
+ [ -n "$name" ] || continue
528
+ dia_job_cmd_path_class "$cmd"
529
+ cls="$YP_CLASS"
530
+ # layer 推断(设计 D7):按**目标所属仓的主 checkout**判,而非 yan_repo_dir()
531
+ # ⚠️ **不能用 `yan_repo_dir()` 比对**(2026-10-13 实测踩坑):
532
+ # 它受 `YAN_DATA_ROOT` 影响 —— 测试/非标准安装时返回 `<其他数据根>/repos/yan-agent`,
533
+ # 于是真实路径永远匹配不上 ⇒ 全部落到 default `external`(**静默误判**,无报错)。
534
+ # 改为用 `dia_job_main_checkout_of` 解析目标所属仓,再看该仓**目录名**。
535
+ layer=""
536
+ if [ "$cls" = "none" ]; then
537
+ layer="external"
538
+ elif [ -n "$YP_CMD_TARGET" ]; then
539
+ local tdir tmc tbase
540
+ tdir="$(dirname "$YP_CMD_TARGET")"
541
+ tmc="$(dia_job_main_checkout_of "$tdir" 2>/dev/null || true)"
542
+ tbase="$(basename "${tmc:-}")"
543
+ case "$tbase" in
544
+ yan-agent) layer="yan-agent" ;;
545
+ personal-agent) layer="personal-agent" ;;
546
+ "") layer="external" ;; # 不在 git 仓内
547
+ *) layer="project" ;; # 其他仓(example-repo 等)= 项目层
548
+ esac
549
+ fi
550
+ rows="${rows}${name}"$'\t'"${cls}"$'\t'"${layer}"$'\n'
551
+ done < <(json rows "$REG")
552
+
553
+ # 用临时文件传 TSV;JSON 适配层只读该文件,不与 stdin 抢管道。
554
+ local tsv; tsv="$(mktemp "${TMPDIR:-/tmp}/yan-task-backfill.XXXXXX")"
555
+ printf '%s' "$rows" > "$tsv"
556
+
557
+ json backfill "$REG" "$dry" "$force" "$tsv"
558
+ rm -f "$tsv" # R29:不留临时产物
559
+ }
560
+
561
+ # —— label 覆盖(2026-10-12 修)——
562
+ # 为什么需要:`adopt` 登记的是**已存在的** label(可能是任意前缀,如个人仓的 com.example.*)。
563
+ # 但 `selfcheck` 重建时走 `cmd_install <short>`,而 cmd_install 用 `label_of` **重新推导** →
564
+ # 推成 `local.dia-job-<short>`,**与 registry 里记的 label 不符** → 重建后 selfcheck 又找不到
565
+ # → **死循环**(且违反「个人仓保留 com.example.*」的命名决定)。
566
+ # 修法:install/write_plist 支持 `--label` 显式覆盖;selfcheck 重建时把 registry 的 label 传下来。
567
+ LABEL_OVERRIDE=""
568
+ label_for() { # 有覆盖用覆盖,否则按默认规则推导
569
+ if [ -n "$LABEL_OVERRIDE" ]; then printf '%s' "$LABEL_OVERRIDE"; else label_of "$1"; fi
570
+ }
571
+ plist_path() { echo "$HOME/Library/LaunchAgents/$(label_for "$1").plist"; }
572
+
573
+ # ── 星期限定(2026-10-21 新增,需求:工作日 18:30)───────────────────────────
574
+ # `--weekdays 1-5`(周一~周五)或 `1,3,5`;launchd 的 Weekday 0/7=周日、1=周一。
575
+ # 为什么必须有:`--calendar` 只产出单个 {Hour,Minute},**表达不了「工作日」**;
576
+ # 而 launchd 的 StartCalendarInterval 可以是**数组**,每条 dict 各带 Weekday ⇒ 命中任一条即触发。
577
+ expand_weekdays() { # expand_weekdays <spec> → 输出空格分隔的星期数字;非法 rc=1
578
+ local spec="$1" out="" part a b i
579
+ local -a parts
580
+ case "$spec" in ''|-) return 0 ;; esac
581
+ IFS=',' read -ra parts <<< "$spec"
582
+ for part in "${parts[@]}"; do
583
+ case "$part" in
584
+ [0-7]) out="$out $part" ;;
585
+ [0-7]-[0-7])
586
+ a="${part%-*}"; b="${part#*-}"
587
+ [ "$a" -le "$b" ] || return 1
588
+ for ((i=a; i<=b; i++)); do out="$out $i"; done ;;
589
+ *) return 1 ;;
590
+ esac
591
+ done
592
+ printf '%s' "${out# }"
593
+ }
594
+
595
+ # ⚠️ 坑 1:plist 里不写 XML 注释(注释中含 `--` 会让 launchd 解析失败,高危)
596
+ # ── 任务包装脚本(**唯一实现**:launchd/cron/systemd 三个后端共用)────────────
597
+ # 每任务一个 wrapper:执行任务 → 记 state → 透传退出码。返回 wrapper 路径。
598
+ write_wrapper() { # write_wrapper <name> <cmd>
599
+ local name="$1" cmd="$2"
600
+ local wrapper="$YAN_DATA_ROOT/tasks/$name.sh"
601
+ mkdir -p "$(dirname "$wrapper")"
602
+ cat > "$wrapper" <<WRAP
603
+ #!/bin/bash
604
+ # 数据根:由 dia-job 统一导出(唯一事实源 = 平台库的 dia_job_root)——
605
+ # 任务/服务无需自行推导,避免各自写死平台默认值导致分叉(2026-10-21 灰度服务事故)。
606
+ export DIA_JOB_ROOT="$DIA_JOB_ROOT"
607
+ export YAN_DATA_ROOT="$YAN_DATA_ROOT"
608
+ # dia-job 包装(由 dia-job install 生成,勿手改):执行任务并记录 state(设计 §7 / T2b)
609
+ #
610
+ # state 记录 = **结构化结论**,供 doctor 检查 8 判「假成功」而**不必扫日志**:
611
+ # {"ts":…,"exit":N,"verdict":"ok|degraded|failed|skipped","errors":N}
612
+ # 为什么不能靠扫日志(2026-10-13 实测,设计 D3 ⚠️):
613
+ # launchd 的 StandardOutPath 是**追加**的 → 历史错误行永久留存;
614
+ # 全文件 grep 会让**已修好的任务永久报错**(坑 6 的假阳性),
615
+ # 而 state.jsonl 原本只有 {ts,exit} —— 无从知道某次运行对应日志的哪一段。
616
+ #
617
+ # verdict 的三种来源(优先级从高到低):
618
+ # ① runner 主动写 \${YAN_VERDICT_FILE}(最准确:区分"真没数据"与"没拉到数据")
619
+ # ② 本包装脚本按退出码兜底映射(见下)
620
+ # ③ 都没有 → 按 exit 判(0=ok,其余=failed)
621
+ YAN_VERDICT_FILE="$YAN_DATA_ROOT/tasks/$name.verdict"
622
+ export YAN_VERDICT_FILE # ⚠️ 必须 export:runner 是子进程,未 export 时读不到(2026-10-29 实测:verdict 恒为空)
623
+ rm -f "\$YAN_VERDICT_FILE" # 清上一轮残留,防陈旧结论被误用
624
+ # ⚠️ **任务本体必须在子 shell 里跑**(2026-10-13 实测修,既有潜伏缺陷):
625
+ # 原模板把 cmd 直接放进脚本体 —— 若 cmd 含 \`exit\`(如 \`exit 2\`),
626
+ # 会**直接终止整个包装脚本** ⇒ 后面的 state 记录**完全没执行**(静默无记录)。
627
+ # 现网 cmd 多为 \`bash '<path>'\` 形式故侥幸未爆;此处一并根治。
628
+ ( $cmd )
629
+ ec=\$?
630
+ verdict=""
631
+ errors=0
632
+ # ① runner 主动给出结构化结论
633
+ if [ -s "\$YAN_VERDICT_FILE" ]; then
634
+ verdict="\$(sed -n 's/.*"verdict"[[:space:]]*:[[:space:]]*"\([a-z]*\)".*/\1/p' "\$YAN_VERDICT_FILE" | head -1)"
635
+ errors="\$(sed -n 's/.*"errors"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p' "\$YAN_VERDICT_FILE" | head -1)"
636
+ fi
637
+ [ -n "\$errors" ] || errors=0
638
+ # ② 退出码兜底映射(D4 语义:2=可重试失败,3=不可重试失败)
639
+ if [ -z "\$verdict" ]; then
640
+ case "\$ec" in
641
+ 0) verdict="ok" ;;
642
+ 2) verdict="degraded" ;; # 可重试类失败:本轮确实没干成活
643
+ 3) verdict="failed" ;;
644
+ *) verdict="failed" ;;
645
+ esac
646
+ fi
647
+ printf '{"ts":%s,"exit":%s,"verdict":"%s","errors":%s}\\n' "\$(date +%s)" "\$ec" "\$verdict" "\$errors" >> "$YAN_DATA_ROOT/tasks/$name.state.jsonl"
648
+ exit \$ec
649
+ WRAP
650
+ chmod +x "$wrapper"
651
+ printf '%s' "$wrapper"
652
+ }
653
+
654
+ write_plist() { # write_plist <name> <cmd> <interval|-> <calendar|-> <log> [<weekdays|- >]
655
+ local name="$1" cmd="$2" interval="$3" calendar="$4" log="$5" weekdays="${6:--}"
656
+ local f; f="$(plist_path "$name")"
657
+ mkdir -p "$(dirname "$f")" "$(dirname "$log")"
658
+ {
659
+ printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>' \
660
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
661
+ '<plist version="1.0"><dict>'
662
+ printf ' <key>Label</key><string>%s</string>\n' "$(label_for "$name")"
663
+ # 每任务一个包装脚本:执行任务 → 记 state → 透传退出码
664
+ #(⚠️ 不在 plist 里内联命令:XML 转义 + 引号嵌套极易写坏,实测踩过 ✗)
665
+ local wrapper
666
+ wrapper="$(write_wrapper "$name" "$cmd")" || return 1
667
+ printf ' <key>ProgramArguments</key><array><string>/bin/bash</string><string>%s</string></array>\n' "$wrapper"
668
+ if [ "$interval" != "-" ]; then printf ' <key>StartInterval</key><integer>%s</integer>\n' "$interval"; fi
669
+ if [ "$calendar" != "-" ]; then
670
+ local hour="${calendar%%:*}" minute="${calendar##*:}"
671
+ if [ -n "${weekdays:--}" ] && [ "${weekdays:--}" != "-" ]; then
672
+ # 工作日/指定星期:数组形式(每条一个 Weekday),launchd 命中任一条即触发
673
+ printf ' <key>StartCalendarInterval</key><array>\n'
674
+ local wd
675
+ for wd in $weekdays; do
676
+ printf ' <dict><key>Weekday</key><integer>%s</integer><key>Hour</key><integer>%s</integer><key>Minute</key><integer>%s</integer></dict>\n' "$wd" "$hour" "$minute"
677
+ done
678
+ printf ' </array>\n'
679
+ else
680
+ printf ' <key>StartCalendarInterval</key><dict><key>Hour</key><integer>%s</integer><key>Minute</key><integer>%s</integer></dict>\n' "$hour" "$minute"
681
+ fi
682
+ fi
683
+ printf ' <key>RunAtLoad</key><true/>\n'
684
+ printf ' <key>StandardOutPath</key><string>%s</string>\n' "$log"
685
+ printf ' <key>StandardErrorPath</key><string>%s</string>\n' "$log"
686
+ printf ' <key>EnvironmentVariables</key><dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:%s/.bun/bin</string></dict>\n' "$HOME"
687
+ printf '%s\n' '</dict></plist>'
688
+ } > "$f"
689
+ plutil -lint "$f" >/dev/null 2>&1 || { echo " ✗ 生成的 plist 语法错误:$f" >&2; return 1; }
690
+ echo "$f"
691
+ }
692
+
693
+ # ═══════════════════════════════════════════════════════════════════════════
694
+ # own-tasks —— **yan-agent 自身任务清单(单一事实源)**
695
+ #
696
+ # 为什么需要(2026-10-16 实测漏网):
697
+ # install.sh 装 **4 个**任务(selfcheck / yan-update / failed-task-report / nm-check),
698
+ # 但 uninstall.sh 只移除 2 个 —— `failed-task-report` 被**误归入「其他项目」**保留,
699
+ # 卸载后留下**孤儿 launchd 任务**(数据根已删、registry 已没,无法再 uninstall)。
700
+ # 根因:**清单在两处各自硬编码**,加任务时漏同步(反向工程代码才能发现)。
701
+ # ⇒ 清单收敛到本函数(唯一实现),install/uninstall **都调它**。
702
+ #
703
+ # 用法:
704
+ # yan-task own-tasks # 列出(供人/脚本查阅)
705
+ # yan-task own-tasks --check <n> # 判定某任务是否属本仓(退出码 0=是/1=否)
706
+ # yan-task own-tasks --remove # 移除全部自身任务(uninstall.sh 调用)
707
+ # yan-task own-tasks --json # 机器可读
708
+ #
709
+ # ⚠️ **新增 yan-agent 自身任务时,只改这里**(install.sh 装完仍自行登记,但清单以本函数为准)。
710
+ # ═══════════════════════════════════════════════════════════════════════════
711
+ OWN_TASKS=()
712
+ for _t in ${DIA_JOB_OWN_TASKS:-}; do OWN_TASKS+=("$_t"); done
713
+
714
+ # ═══════════════════════════════════════════════════════════════════════════
715
+ # Linux 后端(2026-10-30):periodic → crontab(带 `# dia-job:<name>` 标记)
716
+ # service → systemd --user unit(Restart=always)
717
+ # 为什么 service 不用 cron:cron 表达不了「常驻 + 挂了重拉」(设计 §4 平台矩阵)。
718
+ # 为什么 periodic 不用 systemd timer:得额外维护 .timer 单元与 OnCalendar 语法,
719
+ # 而 cron 一行就够,且 WSL/容器里 cron 更常见(沿用设计 §4 的既定口径)。
720
+ # ═══════════════════════════════════════════════════════════════════════════
721
+ cron_spec() { # cron_spec <interval|-> <calendar|-> [<weekdays|- >] → 5 段表达式;rc=2 参数错 / 3 表达不了
722
+ local interval="${1:--}" calendar="${2:--}" weekdays="${3:--}" m h dow
723
+ if [ "$interval" != "-" ] && [ -n "$interval" ]; then
724
+ case "$interval" in ''|*[!0-9]*) return 2 ;; esac
725
+ # cron 最小粒度 = 1 分钟;非整分/小于 1 分钟都表达不了 ⇒ **明确拒绝**(别装成"看着跑")
726
+ [ "$interval" -ge 60 ] || return 3
727
+ [ $((interval % 60)) -eq 0 ] || return 3
728
+ local mins=$((interval / 60))
729
+ if [ "$mins" -lt 60 ]; then printf '*/%s * * * *' "$mins"; return 0; fi
730
+ [ $((mins % 60)) -eq 0 ] || return 3
731
+ local hrs=$((mins / 60))
732
+ { [ "$hrs" -le 23 ] && [ $((24 % hrs)) -eq 0 ]; } || return 3
733
+ printf '0 */%s * * *' "$hrs"
734
+ return 0
735
+ fi
736
+ # 既无 interval 也无 calendar:macOS 上等于「仅 RunAtLoad 跑一次」⇒ Linux 用 cron 的 @reboot
737
+ # 对齐(开机/重载时跑一次),而不是拒绝 —— 否则同样的 registry 在两端语义不同。
738
+ [ "$calendar" != "-" ] || { printf '@reboot'; return 0; }
739
+ m="${calendar##*:}"; h="${calendar%%:*}"
740
+ m=$((10#$m)); h=$((10#$h))
741
+ dow="*"
742
+ if [ "$weekdays" != "-" ] && [ -n "$weekdays" ]; then dow="$(printf '%s' "$weekdays" | tr ' ' ',')"; fi
743
+ printf '%s %s * * %s' "$m" "$h" "$dow"
744
+ }
745
+
746
+ _cron_read() { [ -n "$CRONTAB_BIN" ] || return 0; "$CRONTAB_BIN" -l 2>/dev/null || true; }
747
+ _cron_drop() { # _cron_drop <content> <name> → 去掉该任务条目(保留他人条目)
748
+ local content="$1" marker="$2"
749
+ printf '%s\n' "$content" | grep -v -F "$marker" | sed -e '/^[[:space:]]*$/d'
750
+ }
751
+ cron_present() { _cron_read | grep -qF "$(_cron_marker "$1")"; }
752
+
753
+ cron_install() { # cron_install <name> <wrapper> <log> <spec> → 打印写入的 crontab 行
754
+ local name="$1" wrapper="$2" log="$3" spec="$4" marker line cur kept
755
+ [ -n "$CRONTAB_BIN" ] || { echo " ✗ 找不到 crontab(先装 cron:apt install cron / dnf install cronie;或设 DIA_JOB_CRONTAB_BIN)" >&2; return 3; }
756
+ mkdir -p "$(dirname "$log")"
757
+ marker="$(_cron_marker "$name")"
758
+ # cron 环境极简 ⇒ 行内显式给 PATH(node/bun/dia 多在 ~/.local/bin)
759
+ line="$spec PATH=$DIA_JOB_CRON_PATH /bin/bash \"$wrapper\" >> \"$log\" 2>&1 $marker"
760
+ cur="$(_cron_read)"
761
+ kept="$(_cron_drop "$cur" "$marker")"
762
+ if [ -n "$kept" ]; then printf '%s\n%s\n' "$kept" "$line"; else printf '%s\n' "$line"; fi | "$CRONTAB_BIN" - || {
763
+ echo " ✗ 写 crontab 失败(权限?)" >&2; return 1; }
764
+ cron_present "$name" || { echo " ✗ crontab 写入后未检出标记 ${marker}" >&2; return 1; }
765
+ printf '%s' "$line"
766
+ }
767
+
768
+ cron_remove() { # cron_remove <name>
769
+ local name="$1" marker cur kept
770
+ marker="$(_cron_marker "$name")"
771
+ [ -n "$CRONTAB_BIN" ] || { echo " · 无 crontab(跳过)"; return 0; }
772
+ cur="$(_cron_read)"
773
+ kept="$(_cron_drop "$cur" "$marker")"
774
+ [ "$kept" = "$cur" ] && { echo " · crontab 无该条目"; return 0; }
775
+ if [ -n "$kept" ]; then printf '%s\n' "$kept" | "$CRONTAB_BIN" -; else printf '' | "$CRONTAB_BIN" -; fi || return 1
776
+ echo " · 已从 crontab 移除"
777
+ }
778
+
779
+ write_unit() { # write_unit <name> <wrapper> <log> [<desc>] → 打印 unit 路径
780
+ local name="$1" wrapper="$2" log="$3" desc="${4:-dia-job 任务 $1}"
781
+ local f; f="$(unit_path "$name")"
782
+ mkdir -p "$(dirname "$f")" "$(dirname "$log")"
783
+ cat > "$f" <<UNIT
784
+ [Unit]
785
+ Description=$desc
786
+
787
+ [Service]
788
+ Type=simple
789
+ ExecStart=/bin/bash "$wrapper"
790
+ Restart=always
791
+ RestartSec=10
792
+ StandardOutput=append:$log
793
+ StandardError=append:$log
794
+
795
+ [Install]
796
+ WantedBy=default.target
797
+ UNIT
798
+ printf '%s' "$f"
799
+ }
800
+
801
+ systemd_install() { # systemd_install <name>
802
+ local name="$1" unit
803
+ unit="$(label_for "$name").service"
804
+ _have_systemd || { echo " ✗ systemctl --user 不可用(无 user manager?容器/无 session 的机器请改用 periodic 或手工 systemd)" >&2; return 3; }
805
+ "$SYSTEMCTL_BIN" --user daemon-reload >/dev/null 2>&1 || { echo " ✗ systemctl --user daemon-reload 失败" >&2; return 1; }
806
+ "$SYSTEMCTL_BIN" --user enable "$unit" >/dev/null 2>&1 || { echo " ✗ systemctl --user enable $unit 失败" >&2; return 1; }
807
+ if "$SYSTEMCTL_BIN" --user is-active --quiet "$unit"; then
808
+ "$SYSTEMCTL_BIN" --user restart "$unit" >/dev/null 2>&1 || { echo " ✗ systemctl --user restart $unit 失败" >&2; return 1; }
809
+ else
810
+ "$SYSTEMCTL_BIN" --user start "$unit" >/dev/null 2>&1 || { echo " ✗ systemctl --user start $unit 失败(systemctl --user status $unit 看原因)" >&2; return 1; }
811
+ fi
812
+ "$SYSTEMCTL_BIN" --user is-active --quiet "$unit" || { echo " ✗ $unit 未处于 active(别当装好了)" >&2; return 1; }
813
+ # ⚠️ `activating` 期间 is-active 也返回 0 ⇒ 再等一下拿真实状态(R54:断言要运行时证据)
814
+ local st=""
815
+ for _i in $(seq 1 20); do
816
+ st="$("$SYSTEMCTL_BIN" --user is-active "$unit" 2>/dev/null || true)"
817
+ [ "$st" = active ] && break
818
+ sleep 0.5
819
+ done
820
+ [ "$st" = active ] || { echo " ✗ $unit 状态=${st:-unknown}(10s 内未 active;systemctl --user status $unit 看原因)" >&2; return 1; }
821
+ }
822
+
823
+ systemd_remove() { # systemd_remove <name>
824
+ local name="$1" unit f
825
+ unit="$(label_for "$name").service"; f="$(unit_path "$name")"
826
+ [ -n "$SYSTEMCTL_BIN" ] && "$SYSTEMCTL_BIN" --user disable --now "$unit" >/dev/null 2>&1 || true
827
+ if [ -f "$f" ]; then rm -f "$f" && echo " · 已删 unit:$f"; fi
828
+ [ -n "$SYSTEMCTL_BIN" ] && "$SYSTEMCTL_BIN" --user daemon-reload >/dev/null 2>&1 || true
829
+ }
830
+
831
+ systemd_state() { # systemd_state <name> → "active|inactive|failed|unknown[ PID=n]"
832
+ local name="$1" unit act pid
833
+ unit="$(label_for "$name").service"
834
+ if [ -n "$SYSTEMCTL_BIN" ] && _have_systemd; then
835
+ act="$("$SYSTEMCTL_BIN" --user is-active "$unit" 2>/dev/null || true)"
836
+ pid="$("$SYSTEMCTL_BIN" --user show -p MainPID --value "$unit" 2>/dev/null || true)"
837
+ [ -n "$act" ] || act=unknown
838
+ case "${pid:-0}" in ''|0) printf '%s' "$act" ;; *) printf '%s PID=%s' "$act" "$pid" ;; esac
839
+ else
840
+ printf 'unknown'
841
+ fi
842
+ }
843
+
844
+ cmd_own_tasks() { case "${1:-}" in
845
+ ''|--list)
846
+ echo "本仓自身任务(卸载时应移除):"
847
+ for t in "${OWN_TASKS[@]}"; do echo " · $t"; done
848
+ echo ""
849
+ echo "(其他项目的任务:卸载时**保留**并提示,归各自项目管)"
850
+ ;;
851
+ --json)
852
+ json own-json "${OWN_TASKS[@]}"
853
+ ;;
854
+ --check)
855
+ local n="${2:-}"
856
+ [ -n "$n" ] || { echo "用法:yan-task own-tasks --check <name>" >&2; return 2; }
857
+ for t in "${OWN_TASKS[@]}"; do [ "$t" = "$n" ] && return 0; done
858
+ return 1
859
+ ;;
860
+ --remove)
861
+ local removed=0 failed=0
862
+ for t in "${OWN_TASKS[@]}"; do
863
+ if [ -f "$(plist_path "$t")" ]; then
864
+ if cmd_uninstall "$t" >/dev/null 2>&1; then
865
+ echo " ✓ 已移除本仓任务:$t"
866
+ removed=$((removed+1))
867
+ else
868
+ echo " ⚠️ 移除 $t 失败(可手工:yan-task uninstall ${t})" >&2
869
+ failed=$((failed+1))
870
+ fi
871
+ fi
872
+ done
873
+ echo "OWN_TASKS_REMOVE removed=$removed failed=$failed"
874
+ [ "$failed" -eq 0 ]
875
+ ;;
876
+ *)
877
+ echo "用法:yan-task own-tasks [--list|--json|--check <name>|--remove]" >&2
878
+ return 2
879
+ ;;
880
+ esac
881
+ }
882
+
883
+ cmd_install() {
884
+ local name="${1:-}" cmd="" interval="-" calendar="-" weekdays="-" desc="" type="periodic" allow_unstable=0 takeover=0
885
+ shift || true
886
+ while [ $# -gt 0 ]; do
887
+ case "$1" in
888
+ --cmd) cmd="$2"; shift 2 ;;
889
+ --interval) interval="$2"; shift 2 ;;
890
+ --calendar) calendar="$2"; shift 2 ;;
891
+ --weekdays) weekdays="$2"; shift 2 ;;
892
+ --desc) desc="$2"; shift 2 ;;
893
+ --type) type="$2"; shift 2 ;;
894
+ --allow-unstable) allow_unstable=1; shift ;; # T1 逃生舱:放行非稳定路径(需 --desc 说明)
895
+ --takeover) takeover=1; shift ;; # 逃生舱:接管 adopted 任务(会重写其 plist,需 --desc 说明)
896
+ --label) LABEL_OVERRIDE="$2"; shift 2 ;; # 保留原 label(adopt 场景重建时用)
897
+ *) echo "未知参数:$1" >&2; return 2 ;;
898
+ esac
899
+ done
900
+ [ -n "$name" ] && [ -n "$cmd" ] || { echo "用法:yan-task install <name> --cmd '<shell>' [--interval 300 | --calendar 09:30 [--weekdays 1-5]] [--desc 说明] [--allow-unstable]" >&2; return 2; }
901
+ if [ "$PLATFORM" = macos ]; then
902
+ : # launchd 后端(下面的分支照旧)
903
+ elif [ "$PLATFORM" = linux ] || [ "$PLATFORM" = wsl ]; then
904
+ # 后端可用性前置检查(宁可装的时候炸,也别装完发现根本不跑)
905
+ if [ "$type" = service ]; then
906
+ _have_systemd || { echo " ✗ 本机 systemctl --user 不可用(无 user manager;容器/无 session 环境请改用 periodic,或手工 systemd unit)" >&2; return 3; }
907
+ else
908
+ [ -n "$CRONTAB_BIN" ] || { echo " ✗ 找不到 crontab(先装 cron:apt install cron / dnf install cronie;或设 DIA_JOB_CRONTAB_BIN)" >&2; return 3; }
909
+ fi
910
+ else
911
+ echo "本平台(${PLATFORM})暂不支持 install(设计 §4.1:Windows native 待实现)" >&2; return 3
912
+ fi
913
+
914
+ # ── 调度参数校验(2026-10-21 补,事故驱动)──────────────────────────────
915
+ # ⚠️ 此前 `--calendar 09`(漏写分钟)被**静默接受**:`${calendar##*:}` 取不到 `:` 就返回整个
916
+ # "09" ⇒ 4 个反馈台账任务全变成 09:09/12:12/15:15/18:18(差 9 分钟),**跑了两周没人发现**。
917
+ # ⇒ 现在强制 HH:MM,坏值直接拒绝(宁可在装的时候炸,也别在调度时静默跑错)。
918
+ if [ "$calendar" != "-" ]; then
919
+ # 兼容历史写法:`--calendar 09`(只有小时)**按 09:00 解释**并告警。
920
+ # ⚠️ 曾经的实现把 "09" 同时当 Hour 和 Minute ⇒ 静默跑成 09:09(2026-10-21 发现,已跑两周)。
921
+ case "$calendar" in
922
+ [0-9]|[0-9][0-9])
923
+ echo " ⚠️ --calendar '${calendar}' 未写分钟,按 ${calendar}:00 处理(历史上会被误解析为 ${calendar}:${calendar})" >&2
924
+ # ⚠️ 不能用 printf '%02d' "09":bash 把前导 0 当**八进制** ⇒ 09 非法 ⇒ 输出 00(实测踩到)。
925
+ calendar="$(printf '%02d:00' "$((10#$calendar))")" ;;
926
+ [0-9]:[0-9][0-9]|[0-9][0-9]:[0-9][0-9]) ;;
927
+ *) echo " ✗ --calendar 必须为 HH:MM(收到:${calendar})" >&2; return 2 ;;
928
+ esac
929
+ local _h="${calendar%%:*}" _m="${calendar##*:}"
930
+ _h=$((10#$_h)); _m=$((10#$_m))
931
+ { [ "$_h" -le 23 ] && [ "$_m" -le 59 ]; } || { echo " ✗ --calendar 时间越界:${calendar}" >&2; return 2; }
932
+ fi
933
+ local wd_expanded=""
934
+ if [ "$weekdays" != "-" ] && [ -n "$weekdays" ]; then
935
+ [ "$calendar" != "-" ] || { echo " ✗ --weekdays 必须与 --calendar 同用" >&2; return 2; }
936
+ wd_expanded="$(expand_weekdays "$weekdays")" || { echo " ✗ --weekdays 非法(应为 1-5 / 1,3,5;0 或 7 = 周日):${weekdays}" >&2; return 2; }
937
+ [ -n "$wd_expanded" ] || { echo " ✗ --weekdays 为空" >&2; return 2; }
938
+ fi
939
+
940
+ # ── adopted 任务重建门控(2026-10-13 实测,**防毁服务**)──────────────────
941
+ # 为什么必须拦:`install` 会**用自己的模板重写整个 plist**,而 adopted 任务的 plist
942
+ # 由**对方项目自己维护**(含本模板不产出的字段)⇒ 重建 = **静默降级甚至弄坏服务**。
943
+ # 实测(yan-reviewer,2026-10-13)重建会丢:
944
+ # · `WorkingDirectory` —— yan-task 模板**根本不产出该键**
945
+ # · `REVIEW_STATE_DIR` / `REVIEW_DRY_RUN` 等 env —— 模板只写 `PATH`
946
+ # · 日志路径被改指 `tasks/<name>.log`(原为服务自己的 logs/)
947
+ # ⇒ 服务可能「仍 running 但行为已变」(少读配置/写错目录),且**不易察觉**。
948
+ # 逃生舱:确需接管 → --takeover(须同时 --desc 说明理由),与 T1 的 --allow-unstable 同风格。
949
+ if [ "$takeover" != 1 ] && [ -f "$REG" ]; then
950
+ local adopt_rec
951
+ adopt_rec="$(json field "$REG" "$name" adopted 2>/dev/null)"
952
+ if [ "$adopt_rec" = true ]; then
953
+ echo " ✗ 拒绝重建:任务「${name}」是 **adopted**(plist 由对方项目自管,不归 yan-task 生成)" >&2
954
+ echo " yan-task 会用自己的模板**整体重写** plist ⇒ 丢失本模板不产出的字段:" >&2
955
+ echo " · WorkingDirectory(模板不产出)· 自定义 env(模板只写 PATH)· 原日志路径" >&2
956
+ echo " → 若只想改登记信息(如 cmd 写错了):**直接改 registry.json**(不影响运行中的服务)" >&2
957
+ echo " → 若确要移交 yan-task 托管:加 --takeover --desc '<理由>'(会重写 plist,先备份)" >&2
958
+ return 5
959
+ fi
960
+ fi
961
+ # --takeover 必须写明理由(与 --allow-unstable 同规矩:逃生舱要留痕)
962
+ if [ "$takeover" = 1 ] && [ -z "$desc" ]; then
963
+ echo " ✗ --takeover 必须同时提供 --desc 说明理由(会重写 adopted 任务的 plist,需留痕)" >&2
964
+ return 4
965
+ fi
966
+
967
+ # ── T1 路径稳定性门控(INV-4)────────────────────────────────────────────
968
+ # 为什么在**写时**拦(设计 D2):一次拒绝的成本 << 一次 exit=127 静默失败。
969
+ # 实测事故:在 worktree 里 install → 路径写死 → 清理 worktree 后服务全线 exit=127。
970
+ dia_job_cmd_path_class "$cmd"
971
+ case "$YP_CLASS" in
972
+ ephemeral)
973
+ echo " ✗ 拒绝注册:命令指向 **worktree(临时目录)**" >&2
974
+ echo " 目标:$YP_CMD_TARGET" >&2
975
+ local mc; mc="$(dia_job_main_checkout_of "$(dirname "$YP_CMD_TARGET")" 2>/dev/null)"
976
+ [ -n "$mc" ] && echo " → 请改用主 checkout 路径(该仓主 checkout:${mc})" >&2
977
+ echo " (worktree 清理后引用必悬空 ⇒ exit=127 静默失败;见 launchd.md 坑 5b)" >&2
978
+ return 4 ;;
979
+ non-repo-abs|unknown)
980
+ echo " ⚠️ 警告:命令目标不是已知 git 主 checkout(class=${YP_CLASS})" >&2
981
+ echo " 目标:${YP_CMD_TARGET:-(未提取到绝对路径)}|${YP_REASON}" >&2
982
+ if [ "$allow_unstable" != 1 ]; then
983
+ echo " → 若确认该路径稳定(非临时/非 worktree),加 --allow-unstable 并写明 --desc" >&2
984
+ return 4
985
+ fi
986
+ [ -n "$desc" ] || { echo " ✗ --allow-unstable 必须同时提供 --desc 说明理由" >&2; return 4; }
987
+ echo " · 已按 --allow-unstable 放行(desc: ${desc})" >&2 ;;
988
+ esac
989
+
990
+ local log="$YAN_DATA_ROOT/tasks/$name.log" f backend="launchd"
991
+ if [ "$PLATFORM" = macos ]; then
992
+ f="$(write_plist "$name" "$cmd" "$interval" "$calendar" "$log" "${wd_expanded:--}")" || return 1
993
+ if _launchd_off; then
994
+ echo " · 已跳过 launchd 加载(YAN_TASK_NO_LAUNCHD=1;测试隔离)"
995
+ else
996
+ launchctl bootout "gui/$UID_NUM/$(label_for "$name")" 2>/dev/null || true # 幂等:先卸
997
+ launchctl bootstrap "gui/$UID_NUM" "$f" 2>/dev/null || { echo " ✗ launchctl bootstrap 失败" >&2; return 1; }
998
+ fi
999
+ elif [ "$type" = service ]; then
1000
+ # ── Linux service:systemd --user(Restart=always ≈ launchd KeepAlive)──
1001
+ backend=systemd
1002
+ local wrapper; wrapper="$(write_wrapper "$name" "$cmd")" || return 1
1003
+ f="$(write_unit "$name" "$wrapper" "$log" "$desc")" || return 1
1004
+ if [ "$DIA_JOB_NO_LAUNCHD" = 1 ]; then
1005
+ echo " · 已跳过 systemd 加载(DIA_JOB_NO_LAUNCHD=1;测试隔离)"
1006
+ else
1007
+ systemd_install "$name" || return 1
1008
+ fi
1009
+ else
1010
+ # ── Linux periodic:crontab(带标记,幂等增删)──
1011
+ backend=cron
1012
+ local wrapper spec rc
1013
+ spec="$(cron_spec "$interval" "$calendar" "${wd_expanded:--}")"; rc=$?
1014
+ if [ "$rc" = 2 ]; then
1015
+ echo " ✗ 调度参数不合法(见用法)" >&2; return 2
1016
+ elif [ "$rc" = 3 ]; then
1017
+ echo " ✗ 该调度在 cron 表达不了:--interval 必须是 ≥60 秒的整分(且 ≤23h、24 可整除),或改用 --calendar HH:MM" >&2; return 2
1018
+ fi
1019
+ wrapper="$(write_wrapper "$name" "$cmd")" || return 1
1020
+ if [ "$DIA_JOB_NO_LAUNCHD" = 1 ]; then
1021
+ echo " · 已跳过 crontab 写入(DIA_JOB_NO_LAUNCHD=1;测试隔离)"
1022
+ f="$wrapper"
1023
+ else
1024
+ f="$(cron_install "$name" "$wrapper" "$log" "$spec")" || return 1
1025
+ fi
1026
+ fi
1027
+ # ⚠️ **必须把 label 写进 registry**(2026-10-12 修):install 此前不记 label →
1028
+ # selfcheck 读不到 → 回退推导(若与 label_of 不一致)→ 判「缺失」→ **重建出重复条目**
1029
+ # (实测:selfcheck 把自己复制成 local.dia-job-yan-task.selfcheck ⇒ 双跑)。
1030
+ # 写入 label 后,selfcheck/status/bootstrap 一律以 registry 为准,不再推导。
1031
+ local real_label; real_label="$(label_for "$name")"
1032
+ # T2:同时固化 path_class(install 时判定一次,此后迭代**不需要改注册信息** —— 用户 D4)
1033
+ reg_set "$name" "$(json json type="$type" backend="$backend" label="$real_label" cmd="$cmd" interval="$interval" calendar="$calendar" weekdays="$weekdays" desc="$desc" log="$log" path_class="$YP_CLASS")"
1034
+ echo "INSTALLED ${name}(${f})"
1035
+ echo " · path_class: $YP_CLASS"
1036
+ echo " · backend: $backend"
1037
+ if [ "$PLATFORM" = macos ]; then
1038
+ if _launchd_off; then echo " · launchd 加载已跳过(测试隔离)";
1039
+ elif launchctl print "gui/$UID_NUM/$(label_for "$name")" >/dev/null 2>&1; then echo " ✓ launchd 已加载";
1040
+ else echo " ⚠️ launchd 未确认加载"; fi
1041
+ elif [ "$backend" = systemd ]; then
1042
+ if [ "$DIA_JOB_NO_LAUNCHD" = 1 ]; then echo " · systemd 加载已跳过(测试隔离)"
1043
+ else echo " ✓ systemd --user 已启用并 active($(systemd_state "$name"))"; fi
1044
+ else
1045
+ if [ "$DIA_JOB_NO_LAUNCHD" = 1 ]; then echo " · crontab 写入已跳过(测试隔离)"
1046
+ elif cron_present "$name"; then echo " ✓ crontab 已登记($(cron_present_spec "$name"))"; else echo " ⚠️ crontab 未确认登记"; fi
1047
+ fi
1048
+ }
1049
+
1050
+ cron_present_spec() { _cron_read | grep -F "$(_cron_marker "$1")" | head -1 | cut -d' ' -f1-5; }
1051
+
1052
+ cmd_uninstall() {
1053
+ local name="${1:-}"
1054
+ [ -n "$name" ] || { echo "用法:yan-task uninstall <name>" >&2; return 2; }
1055
+ local backend=""
1056
+ [ -f "$REG" ] && backend="$(json field "$REG" "$name" backend 2>/dev/null || true)"
1057
+ if [ "$PLATFORM" = macos ] || [ "$backend" = launchd ]; then
1058
+ local f; f="$(plist_path "$name")"
1059
+ _launchd_off || launchctl bootout "gui/$UID_NUM/$(label_for "$name")" 2>/dev/null || true # ⚠️ 守卫必须在**这一行**(先前误命中 install 的同名行 ⇒ uninstall 未隔离,实测踩到)
1060
+ [ -f "$f" ] && rm -f "$f" && echo " · 已删 plist"
1061
+ elif [ "$backend" = systemd ]; then
1062
+ [ "$DIA_JOB_NO_LAUNCHD" = 1 ] || systemd_remove "$name"
1063
+ [ "$DIA_JOB_NO_LAUNCHD" = 1 ] && echo " · 已跳过 systemd 卸载(测试隔离)"
1064
+ else
1065
+ [ "$DIA_JOB_NO_LAUNCHD" = 1 ] || cron_remove "$name"
1066
+ [ "$DIA_JOB_NO_LAUNCHD" = 1 ] && echo " · 已跳过 crontab 移除(测试隔离)"
1067
+ fi
1068
+ [ -f "$YAN_DATA_ROOT/tasks/$name.sh" ] && rm -f "$YAN_DATA_ROOT/tasks/$name.sh" && echo " · 已删包装脚本"
1069
+ reg_del "$name"
1070
+ echo "REMOVED ${name}(state/日志保留在 $YAN_DATA_ROOT/tasks/)"
1071
+ }
1072
+
1073
+
1074
+ cmd_adopt() {
1075
+ local label="${1:-}"; shift || true
1076
+ local desc="" type="periodic" cmd=""
1077
+ while [ $# -gt 0 ]; do case "$1" in --desc) desc="$2"; shift 2 ;; --type) type="$2"; shift 2 ;; --cmd) cmd="$2"; shift 2 ;; *) shift ;; esac; done
1078
+ [ -n "$label" ] || { echo "用法:yan-task adopt <launchd-label> [--desc 说明] [--type periodic|service]" >&2; return 2; }
1079
+ if [ "$PLATFORM" = macos ] && ! launchctl print "gui/$UID_NUM/$label" >/dev/null 2>&1; then
1080
+ echo " ⚠️ $label 当前未加载(仍登记,便于 status/bootstrap 可见)"
1081
+ fi
1082
+ local short="${label##*.}" backend="launchd"
1083
+ case "$PLATFORM" in
1084
+ linux|wsl) if [ "$type" = service ]; then backend=systemd; else backend=cron; fi ;;
1085
+ esac
1086
+ if [ "$backend" = systemd ] && [ ! -f "$(unit_path "$short")" ]; then
1087
+ echo " ⚠️ 未找到 unit:$(unit_path "$short")(仍登记,便于 status/selfcheck 可见)"
1088
+ elif [ "$backend" = cron ] && ! cron_present "$short"; then
1089
+ echo " ⚠️ crontab 里没有 $(_cron_marker "$short") 条目(仍登记;adopted 条目由对方维护)"
1090
+ fi
1091
+ reg_set "$short" "$(json json type="$type" backend="$backend" label="$label" adopted=true desc="$desc" cmd="$cmd")"
1092
+ echo "ADOPTED ${short}(label=${label},backend=${backend})"
1093
+ }
1094
+
1095
+
1096
+
1097
+ cmd_gc() { # 清理 <数据根>/tasks 下超过 N 天的 state/日志/心跳(Q2:默认 30 天)
1098
+ local days=30 dry=0
1099
+ while [ $# -gt 0 ]; do case "$1" in --days) days="$2"; shift 2 ;; --dry-run) dry=1; shift ;; *) shift ;; esac; done
1100
+ local dir="$YAN_DATA_ROOT/tasks"
1101
+ [ -d "$dir" ] || { echo "无 $dir"; return 0; }
1102
+ local n=0 f
1103
+ while IFS= read -r f; do
1104
+ n=$((n+1))
1105
+ if [ "$dry" = 1 ]; then echo " (dry-run) 将删 $(basename "$f")"
1106
+ else rm -f "$f" && echo " · 已删 $(basename "$f")"; fi
1107
+ done < <(find "$dir" -maxdepth 1 -type f \( -name '*.log' -o -name '*.state.json' -o -name '*.heartbeat' \) -mtime "+$days" 2>/dev/null)
1108
+ echo "GC 完成:${dry:+dry-run · }超过 ${days} 天的文件 $n 个(registry.json 永不清理 ✓)"
1109
+ }
1110
+
1111
+ cmd_selfcheck() { # 体检 + 自愈本边界缺失条目(设计 §6.1;--dry-run 只报不改)
1112
+ local dry=0; [ "${1:-}" = "--dry-run" ] && dry=1
1113
+ local _dry_banner=""; [ "$dry" = 1 ] && _dry_banner=" · dry-run"
1114
+ echo "══ yan-task selfcheck(边界=$PLATFORM${_dry_banner})══"
1115
+ local fixed=0 missing=0
1116
+ if [ -f "$REG" ]; then
1117
+ while IFS=$'\t' read -r short label adopted; do
1118
+ [ -n "$short" ] || continue
1119
+ # ⚠️ **adopted 任务不计入「缺失」**(2026-10-16 修):它们的 plist 由对方项目自管,
1120
+ # yan-task 既不该也不该能重建它们 ⇒ 原先一律计 missing ⇒ **selfcheck 永久红**
1121
+ # (实测:personal-agent 的 yan-self-update 长期未加载 ⇒ 每 5 分钟一次 failed)。
1122
+ # 正解:**提示**它未加载 + 指向其项目脚本,不进 missing 计数。
1123
+ local _adopted=0; [ "${adopted:-}" = "1" ] && _adopted=1
1124
+ # 本条目在其后端是否"在场"(launchd 已加载 / cron 有条目 / systemd 已启用且 active)
1125
+ local _present=0 _why=""
1126
+ if [ "$PLATFORM" = macos ]; then
1127
+ launchctl print "gui/$UID_NUM/$label" >/dev/null 2>&1 && _present=1
1128
+ _why="$label 未加载"
1129
+ else
1130
+ local _be; _be="$(json field "$REG" "$short" backend 2>/dev/null || true)"
1131
+ case "${_be:-cron}" in
1132
+ systemd)
1133
+ if "$SYSTEMCTL_BIN" --user is-enabled "$label.service" >/dev/null 2>&1; then _present=1; fi
1134
+ _why="$label.service 未启用/未 active" ;;
1135
+ *)
1136
+ cron_present "$short" && _present=1
1137
+ _why="crontab 缺 $(_cron_marker "$short") 条目" ;;
1138
+ esac
1139
+ fi
1140
+ if [ "$_present" = 1 ]; then
1141
+ echo " ✓ ${short}($label 在场)"
1142
+ else
1143
+ if [ "$_adopted" = 1 ]; then
1144
+ echo " · ${short}:不在场(**adopted**:${_why} —— 由对方项目自管,不计缺失)"
1145
+ else
1146
+ missing=$((missing+1))
1147
+ if [ "$dry" = 1 ]; then echo " ⚠️ ${short}:${_why}(dry-run 不修)"
1148
+ else
1149
+ local cmd iv cal wd type pcls dsc
1150
+ # ⚠️ 重建时**必须把调度字段一并带回来**(2026-10-21 修):原先只传 --cmd,
1151
+ # plist 会丢 StartCalendarInterval ⇒ selfcheck 一跑,任务就变成「只在 RunAtLoad 跑一次」。
1152
+ # 2026-10-30:type 也要带回(Linux 上 service→systemd / periodic→cron 靠它分派)。
1153
+ # 2026-10-30:path_class 非 main-checkout 的(如 `echo hi` 这种无路径 cmd)重建时要带
1154
+ # `--allow-unstable --desc`,否则被 T1 门控挡下 ⇒ **selfcheck 永远修不好**(实测)。
1155
+ IFS=$'\t' read -r cmd iv cal wd type < <(json fields "$REG" "$short")
1156
+ pcls="$(json field "$REG" "$short" path_class 2>/dev/null || true)"
1157
+ dsc="$(json field "$REG" "$short" desc 2>/dev/null || true)"
1158
+ local -a _extra=()
1159
+ case "${pcls:-}" in
1160
+ ephemeral) ;; # 临时 worktree 路径:不自动重建(会引回悬空引用)
1161
+ non-repo-abs|unknown) _extra+=(--allow-unstable) ;;
1162
+ esac
1163
+ [ -n "$dsc" ] || dsc="selfcheck 重建(原 desc 缺失)"
1164
+ if [ -n "$cmd" ]; then cmd_install "$short" --cmd "$cmd" --label "$label" --interval "$iv" --calendar "$cal" --weekdays "$wd" --type "${type:-periodic}" --desc "$dsc" "${_extra[@]}" >/dev/null 2>&1 && { echo " ✚ $short 已重建"; fixed=$((fixed+1)); }
1165
+ else echo " ⚠️ ${short}:不在场且 registry 无 cmd(adopted 任务请按其项目脚本重装)"; fi
1166
+ fi
1167
+ fi
1168
+ fi
1169
+ done < <(json selfcheck-rows "$REG" "$DIA_JOB_LABEL_PREFIX")
1170
+ else
1171
+ echo " · 无 registry"
1172
+ fi
1173
+ # 生效指针(R56/R57)——**断链类**计入缺失(消费方会硬失败:服务起不来、任务跑不了);
1174
+ # **污染/漂移类**只记录一行(那属"仓库卫生",日常会真有在制改动 ⇒ 计失败会天天误报)。
1175
+ if [ ! -f "$YAN_DATA_ROOT/active-ref.json" ]; then
1176
+ echo " · 生效指针:未部署(跳过)"
1177
+ else
1178
+ local yref=""
1179
+ for c in "${YAN_DATA_ROOT:-$HOME/.yan-agent}/bin/yan-ref" "$SELF_DIR/yan-ref"; do
1180
+ [ -x "$c" ] && yref="$c" && break
1181
+ done
1182
+ if [ -z "$yref" ]; then
1183
+ echo " · 生效指针:未部署 yan-ref(跳过;部署见 core/dws-hub/service/install.sh)"
1184
+ else
1185
+ local _refj _refbroken _refeff _refcode
1186
+ _refj="$("$yref" check --json 2>/dev/null || true)"
1187
+ _refbroken="$(printf '%s' "$_refj" | grep -cE '"code":[[:space:]]*"(ref_missing|ref_corrupt|ref_target_missing)"' || true)"
1188
+ _refbroken="${_refbroken:-0}"
1189
+ _refeff="$("$yref" resolve 2>/dev/null || true)"
1190
+ if [ "${_refbroken:-0}" -gt 0 ]; then
1191
+ echo " ✗ 生效指针**断链**(ref_missing / ref_corrupt / ref_target_missing)—— 消费方会硬失败"
1192
+ missing=$((missing+1))
1193
+ else
1194
+ _refcode="$(printf '%s' "$_refj" | grep -oE '"code":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+' || true)"
1195
+ echo " ✓ 生效指针:${_refeff:-?}(check 码 ${_refcode:-0}:0 干净 / 1 警告 / 2 阻断 —— 污染/漂移类只记录不误报)"
1196
+ fi
1197
+ fi
1198
+ fi
1199
+
1200
+ # ── 阶段1a:kb-review heartbeat(每日 06:40;>26h = 可能长期未跑)──
1201
+ local hb="$YAN_DATA_ROOT/observability/kb-review-heartbeat.json"
1202
+ if [ -f "$hb" ]; then
1203
+ local _mtime _age
1204
+ _mtime="$(_mtime_secs "$hb")"
1205
+ _age=$(( $(date +%s) - _mtime ))
1206
+ if [ "$_age" -gt $((26*3600)) ]; then
1207
+ echo " ✗ kb-review 心跳过期($((_age/3600))h > 26h)—— review 可能长期未跑"
1208
+ missing=$((missing+1))
1209
+ else
1210
+ echo " ✓ kb-review 心跳($((_age/3600))h 前)"
1211
+ fi
1212
+ else
1213
+ echo " · kb-review 心跳尚无(首次/未运行;不阻断)"
1214
+ fi
1215
+
1216
+ # ── 阶段1a:expected 任务清单(repo 发布;缺失=跳过,不误报)──
1217
+ local manifest="${DIA_JOB_EXPECTED:-}"
1218
+ if [ -f "$manifest" ] && [ -f "$REG" ]; then
1219
+ local _miss
1220
+ _miss="$(json expected "$manifest" "$REG" "$PLATFORM")"
1221
+ if [ -n "$_miss" ]; then
1222
+ echo " ✗ registry 缺少 expected 任务:$_miss"
1223
+ missing=$((missing+1))
1224
+ else
1225
+ echo " ✓ expected 清单齐全"
1226
+ fi
1227
+ else
1228
+ echo " · expected 清单缺失(跳过)"
1229
+ fi
1230
+
1231
+ echo "── 缺失 $missing · 已重建 $fixed ──"
1232
+ [ "$missing" = 0 ] || return 1
1233
+ }
1234
+
1235
+ cmd_bootstrap() { # 幂等重建本边界全部条目(设计 §6.1 兜底入口);--dry-run 只打印解析结果
1236
+ local dry=0; [ "${1:-}" = "--dry-run" ] && dry=1
1237
+ [ -f "$REG" ] || { echo "无 registry(${REG})—— 没有可重建的任务"; return 0; }
1238
+ json bootstrap-rows "$REG" | \
1239
+ while IFS=$'\t' read -r n c i cal dsc wd type pcls; do
1240
+ [ -n "$n" ] || continue
1241
+ if [ "$dry" = 1 ]; then printf 'DRYRUN\t%s\t%s\t%s\t%s\n' "$n" "$c" "$i" "$cal"; continue; fi
1242
+ # ⚠️ **字段必须制表符分隔**(2026-10-16 实测修):原先 `print(n, cmd, …)` + `IFS=' '` —— 而 cmd 里
1243
+ # **含空格**(macOS 数据根 `Application Support`!)+ desc 也含空格 ⇒ 字段错位 → cmd 被截断成 `bash`
1244
+ # ⇒ 门控判 unknown ⇒ **每个任务都 rc=4**,bootstrap(设计的兜底恢复入口)在 mac 上**完全不可用**。
1245
+ # ⚠️ 退出码必须**显式捕获**:`if cmd …; then` 之后 `$?` 已被条件判定覆盖,
1246
+ # 直接读会拿到 0/1 而非真实 rc(2026-10-13 自查发现)⇒ 先执行、再判 rc。
1247
+ # 2026-10-30:带上 type / path_class —— 否则 Linux 上 service 会被建成 periodic,
1248
+ # 而 `echo hi` 这类无绝对路径的 cmd 会撞 T1 门控(rc=4)⇒ bootstrap 修不好自己。
1249
+ local -a _extra=()
1250
+ case "${pcls:-}" in ephemeral) ;; non-repo-abs|unknown) _extra+=(--allow-unstable) ;; esac
1251
+ [ -n "$dsc" ] || dsc="bootstrap 重建(原 desc 缺失)"
1252
+ cmd_install "$n" --cmd "$c" --interval "$i" --calendar "$cal" --weekdays "$wd" --desc "$dsc" --type "${type:-periodic}" "${_extra[@]}" >/dev/null 2>&1
1253
+ local rc=$?
1254
+ if [ "$rc" = 0 ]; then
1255
+ echo " ✓ 重建 $n"
1256
+ elif [ "$rc" = 5 ]; then
1257
+ # ⚠️ 不再静默(2026-10-13):adopted 任务被门控挡下(rc=5),若这里不吭声,
1258
+ # 运维会以为「bootstrap 跑过了 = 全都重建了」⇒ 误判。
1259
+ echo " · 跳过 ${n}(adopted:plist 由对方项目自管,重建会丢字段;如确需接管用 install --takeover)"
1260
+ else
1261
+ echo " ⚠️ 未重建 ${n}(rc=${rc})"
1262
+ fi
1263
+ done
1264
+ echo "BOOTSTRAP 完成(本边界 = ${PLATFORM})"
1265
+ }
1266
+
1267
+ case "${1:-list}" in
1268
+ list) cmd_list ;;
1269
+ status) cmd_status ;;
1270
+ doctor) cmd_doctor ;;
1271
+ own-tasks) shift; cmd_own_tasks "$@" ;;
1272
+ install) shift; cmd_install "$@" ;;
1273
+ uninstall) shift; cmd_uninstall "$@" ;;
1274
+ bootstrap) shift; cmd_bootstrap "$@" ;; # ⚠️ 必须 shift + 透传(2026-10-16 修:原为 `cmd_bootstrap`,导致 --dry-run 永远进不去)
1275
+ registry) [ -f "$REG" ] && cat "$REG" || echo "(无 registry)" ;;
1276
+ adopt) shift; cmd_adopt "$@" ;;
1277
+ selfcheck) shift; cmd_selfcheck "$@" ;;
1278
+ backfill) shift; cmd_backfill "$@" ;;
1279
+ gc) shift; cmd_gc "$@" ;;
1280
+ logs) shift; cmd_logs "$@" ;;
1281
+ platform) echo "$PLATFORM" ;;
1282
+ *) echo "用法: yan-task {list|status|doctor|platform|install|uninstall|adopt|selfcheck|backfill|bootstrap|registry|gc|logs}" >&2; exit 2 ;;
1283
+ esac