@minonl/dia 0.0.8 → 0.0.10

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.
@@ -64,3 +64,32 @@ dia graph --json --task t-xxx
64
64
  - `blocked_by` 只在 task 节点里做派生提示,不是边;
65
65
  - 任务依赖成环:`cycles` 非空,退出码 **3**(参数错仍是 2);
66
66
  - `--task` 只输出该任务的直接依赖/touches/claim 邻居,不做全图闭包。
67
+
68
+ ---
69
+
70
+ # 只读 TUI 图(M2 第一步)
71
+
72
+ ```bash
73
+ dia graph --tui # 打印一次(有环 exit 3)
74
+ dia graph --watch # 每 2s 刷新,Ctrl-C 退出
75
+ dia graph --tui --no-color # 关 ANSI 颜色
76
+ ```
77
+
78
+ - 按状态分组显示 task → deps / touches / blocked_by;claims 单列;
79
+ - 不启 HTTP/daemon,遵守框架“无网络模块”约束;
80
+ - `--task t-...` 只显示该任务的直接邻居。
81
+
82
+ ---
83
+
84
+ # handoff + claim 联动(M2)
85
+
86
+ ```bash
87
+ dia claim src/collab.ts --reason "正在重构"
88
+ dia out --to ses_xxx --kind handoff --subject "交接" --body-file handoff.md \
89
+ --touches src/collab.ts,docs/msg-collab-m1.md
90
+ ```
91
+
92
+ - `--touches a,b`(也接受重复 `--touch`)写入消息 `refs.touches`;
93
+ - 发送时如果有他人活跃 claim,输出 `⚠ claim overlap`(advisory,仍发送);
94
+ - 收件方 `dia in` 会在消息下显示 `⚠ claim: <path> 由 <holder> 持有`;
95
+ - 不自动排队/阻断;claim 仍是可见性工具。
package/docs/overview.md CHANGED
@@ -85,6 +85,12 @@ $ dia --persona yan kb scene S1 → 正常出场景卡
85
85
 
86
86
  ---
87
87
 
88
+ ## 3.1 CI 口径(见 README「CI 矩阵」)
89
+
90
+ - required = `test (ubuntu-latest)` + `test (macos-latest)`(分支保护,`enforce_admins`)
91
+ - `windows-smoke` 非阻塞:Windows 后端未实现(`dia job install` rc=3),落地后再升 required
92
+ - **docs-only 快通道**:只改 `docs/**` / 根 `*.md` / `llms*.txt` 时跳过测试矩阵
93
+
88
94
  ## 4. 已知缺口(按优先级)
89
95
 
90
96
  1. **yan 安装落后来源仓 1 个提交**(`83fbaa9` vs `8d10cc5`,内容是 dsh profile 根 `cordis.yml` 修复)。
@@ -1,8 +1,11 @@
1
1
  # 服务(service):可托管的执行体
2
2
 
3
- > 状态:提案(设计,未实现)。只做脚本型服务;healthcheck 按专用退出码域判定,旧结果超新鲜度窗口即报 stale。
3
+ > 状态:**已实现(v0.0.10,脚本型 + agent 型)**。脚本型走 `entry.cmd`,agent 型走 `entry.agent` + `healthcheck.kind="acp"`;
4
+ > healthcheck 按专用码域 `0/20/21` 判定(外加 `124`=timeout、信号=crash),旧结果超新鲜度窗口即报 `unknown (stale …)`。
5
+ > **实现状态逐节对照见 §15。**
4
6
  > 日期 2026-11-05(rev.2:按 [设计评审 B1–B5 / C1–C7 / D1–D4](review/service-noun-review-2026-11-05.md) 改稿;
5
- > **rev.3**:按复核意见改「去抖单一写入方 / health 码域 `0/20/21` / timeout 映射 / 手动检查落台账 / adopted 口径」,见 §13)。
7
+ > **rev.3**:按复核意见改「去抖单一写入方 / health 码域 `0/20/21` / timeout 映射 / 手动检查落台账 / adopted 口径」,见 §13;
8
+ > **rev.4**:回填实现状态(PR #49 / #54 / #60)与 §15 对照表)。
6
9
  > 关联:[`cli.md`](cli.md)(名词与动词矩阵 / 退出码)· [`persona-install.md`](persona-install.md)(`dia.json` schema)·
7
10
  > [`kit-install.md`](kit-install.md) · [`acp-integration.md`](acp-integration.md)(agent 会话型服务)· `packages/job`(生命周期、状态台账)
8
11
 
@@ -243,9 +246,11 @@ unknown(stale)**不单独返回**——`health` 是实时执行,不存在
243
246
 
244
247
  - `job` 仍是**唯一调度实现**(不新增第二套);健康检查任务本身就是一个 `job`(`periodic`)。
245
248
  - `service` 只多一层「声明 + 校验 + 健康检查」。
246
- - `dia job status` 的每行增加 `health=<ok|degraded|fail|unknown|n/a> (Ns ago)`(读 §4.5 缓存,不执行)。
249
+ - `dia job status` 的每行增加 `health=<ok|degraded|fail|unknown|n/a> (Ns ago)`(读 §4.5 缓存,不执行)。**✅ 已实现(PR #54)**:
250
+ 仅对 `local.dia-job-svc-<id>` 行追加,其它行原样(向后兼容)。
247
251
  - 对 `service` 型(常驻)任务:Linux 走 systemd `--user`,**常驻需 `loginctl enable-linger`**(`job doctor` 已提示,`dia service add` 一并转述);
248
252
  对 `periodic` 任务,cron 后端**没有进程状态**,health 是唯一判据 → 新鲜度条款(§4.6)尤其关键。
253
+ - **性能**:只读台账、零执行;实测 N=50 时 `3.1ms`(阈值 200ms),基准见 `tests/service-health-status.perf.test.ts`。
249
254
 
250
255
  ## 7. 边界与异常
251
256
 
@@ -352,3 +357,108 @@ unknown(stale)**不单独返回**——`health` 是实时执行,不存在
352
357
 
353
358
  1. 判据写成**可判定的检查**:`node scripts/docs-index.mjs --check && node scripts/docs-link-check.mjs`(+ 可选 checklist 计数);
354
359
  2. 或者把它当**评审工具**用:接受 `escalated` 是预期结果,只取 `verify.json` 的 findings 作为 grill 产出。
360
+
361
+ ## 14. P2:agent 型服务(entry = persona 会话)
362
+
363
+ > 状态:**已实现(PR #60)**。本节定义脚本型之外的 agent 型入口(`entry.agent` + `healthcheck.kind="acp"`);
364
+ > 实现与本节一致,差异见 §15。
365
+
366
+ ### 14.1 与脚本型的关系
367
+
368
+ ```mermaid
369
+ flowchart LR
370
+ S["service"] --> A["脚本型(已实现)<br/>entry = bin/*"]
371
+ S --> B["agent 型(P2)<br/>entry = ACP 会话"]
372
+ B --> J["job:把 ACP 端点拉起并保持<br/>(dsh --profile dia-acp / opencode acp)"]
373
+ B --> H["healthcheck:端点可应答 + 最近一轮成功<br/>(不判断“想得对不对”)"]
374
+ ```
375
+
376
+ **不变的**:`lifecycle`(job 托管)、`healthcheck` 契约(专用码域)、台账(`kind:"health"`)、`gen` 代际。
377
+ **不同的**:没有 `stdin` 事件契约 —— 输入是 ACP 的 `prompt`,输出是会话事件;所以 `contract` 换成 ACP 语义。
378
+
379
+ ### 14.2 清单
380
+
381
+ ```jsonc
382
+ {
383
+ "kind": "service",
384
+ "id": "example-agent",
385
+ "entry": {
386
+ "agent": {
387
+ "host": "dsh", // dsh | opencode
388
+ "profile": "dia-acp", // 由 persona/宿主提供;缺省用宿主默认
389
+ "persona": "yan" // 可选:仅做归属校验,不注入内容
390
+ }
391
+ },
392
+ "lifecycle": { "type": "service", "target": "local" },
393
+ "healthcheck": { "kind": "acp", "interval": 1800, "timeout": 60 },
394
+ "kits": []
395
+ }
396
+ ```
397
+
398
+ | 字段 | 语义 |
399
+ |---|---|
400
+ | `entry.agent.host` | 用哪套 ACP 实现(`dsh --profile <p>` 或 `opencode acp`) |
401
+ | `entry.agent.profile` | 宿主的 profile 名(不复制 persona 内容,只引用) |
402
+ | `entry.agent.persona` | 可选;`verify` 时校验「该 profile 是否属于此 persona」,防止挂错人 |
403
+ | `healthcheck.kind: "acp"` | 走 ACP 健康检查(见 §14.3),**不再要求 `cmd`** |
404
+
405
+ ### 14.3 健康检查(ACP 版)
406
+
407
+ | 检查 | 判据 | 失败归类 |
408
+ |---|---|---|
409
+ | 端点存活 | `initialize` 能返回 `protocolVersion`(JSON-RPC 1.0) | 连不上/超时 → `degraded` |
410
+ | 会话可用 | `session/new` 成功返回 `sessionId` | 返回错误 → `fail` |
411
+ | 最近一轮 | 台账里最近一次 `prompt` 的成功/失败(由调用方写事件) | 连续失败 `debounce` 次 → `degraded` |
412
+
413
+ **明确不做**:不判断「回答质量」、不重放 prompt、不在 healthcheck 里跑真实业务(成本 + 副作用)。
414
+
415
+ ### 14.4 与脚本型的复用
416
+
417
+ - `healthView` / `transition` / `gen` / 台账格式**完全复用**(`state.jsonl` + `kind:"health"`)
418
+ - 差异只在「怎么产生一次 judgement」:`runHealth()` 换成 `runAcpHealth()`(`initialize` + `session/new`)
419
+ - `dia service health <id>` 对 agent 型走 `runAcpHealth`,退出码沿用 §5
420
+
421
+ ### 14.5 边界
422
+
423
+ | 场景 | 行为 |
424
+ |---|---|
425
+ | 宿主未安装 / profile 不存在 | `add` 期 `E_BACKEND`(不装半套) |
426
+ | 端点活着但 `session/new` 失败 | `fail`(配置/鉴权问题,不是 infra 抖动) |
427
+ | 长任务(一轮 prompt 很久) | **不算失败**;只有「端点不可应答」才算 |
428
+ | 多实例 | `lifecycle.target` 声明归属;ACP 端点同机只允许一个 |
429
+ | 权限/凭据 | 由宿主 profile 自己管;`dia` 不读取、不注入 |
430
+
431
+ ### 14.6 验收(P2)
432
+
433
+ - [ ] 单测:清单校验(`entry.agent` 与 `entry.cmd` **互斥**,同时给 → `E_MANIFEST`)
434
+ - [ ] 单测:`runAcpHealth` 用 `tests/fixtures/fake-acp.mjs` 覆盖 `initialize` 失败 / `session/new` 失败 / 正常三态
435
+ - [ ] 单测:`healthcheck.kind` 缺省时按脚本型(向后兼容)
436
+ - [ ] e2e:装一个 agent 型 demo(fake ACP)→ `dia job status` 显示 `health=ok…`,杀掉端点后变 `degraded(stale)`
437
+
438
+ ## 15. 实现状态(2026-11-06 回填)
439
+
440
+ > 规则:**设计先改、代码同批**(R35)。本节是「设计 ↔ 实现」的对照表;后续改动必须同批更新本表。
441
+
442
+ | 设计小节 | 状态 | 实现落点 |
443
+ |---|---|---|
444
+ | §2 构成(entry/lifecycle/contract/healthcheck) | ✅ | `src/service.ts`:`ServiceManifest` / `validateServiceManifest`(PR #49) |
445
+ | §3 清单与校验(entry 规则、`E_MANIFEST`) | ✅ | `checkEntry` / `checkAgentEntry` / `resolveEntry`(#49、#60) |
446
+ | §4.1 码域 `0/20/21` + `124` + 信号 | ✅ | `judgeHealth` / `judgeSpawnResult`;`124` 与 `137/143` 分流(#49;无 `timeout(1)` 的兜底见 #49 后续修复 `29a4341`) |
447
+ | §4.2 执行环境 / `E_BACKEND` | ✅ | `healthShell`、`checkCronInterval`(#49) |
448
+ | §4.3 去抖 + 单一写入方 + `gen` | ✅ | `transition`(`gen` 不同的行不计数)(#49) |
449
+ | §4.4 崩溃语义(保持上次状态 + `last_error`) | ✅ | `judgeSpawnResult` + `transition`(#49) |
450
+ | §4.5 台账(`state.jsonl` + `kind:"health"`) | ✅ | `appendHealthRecord` / `findLastHealth` / `readHealthLines`(#49、#54) |
451
+ | §4.6 新鲜度(`interval × 1.5` → stale) | ✅ | `healthView` / `staleAfter`(#54) |
452
+ | §5 CLI(add/remove/list/show/verify/health) | ✅ | `packages/cli/src/index.ts`(#49);`health` 的 agent 分支(#60) |
453
+ | §6 `dia job status` 追列 health | ✅ | `decorateStatus` + `runJobCapture`(#54) |
454
+ | §7 边界(`E_UNSTABLE_ENTRY` / adopted / three-state) | ✅ | `verify` / `healthView`(#49、#54) |
455
+ | §14 agent 型(`entry.agent` + `kind:"acp"`) | ✅ | `acpAgentCommand` / `runAcpHealth` / `recordHealth`(#60) |
456
+ | §6 性能基准(N=50 ≤200ms) | ✅ | 实测 **3.1ms**(单服务 0.1ms,macOS / bun 1.4.4);`tests/service-health-status.perf.test.ts` 断言 ≤200ms 且不随 N 线性增长 |
457
+
458
+ ### 15.1 已知与设计的偏差
459
+
460
+ | 项 | 设计 | 实现 | 处置 |
461
+ |---|---|---|---|
462
+ | `healthcheck.kind` 缺省 | 未写 | 缺省 `script`(向后兼容) | 与 §14.6 一致,已写回 §14 |
463
+ | `dia service health` 的 agent 分支 | §5 未细化 | 先跑 ACP 探测再落 `source:"manual"` 行 | 与 §5/§14 语义一致,无需改设计 |
464
+ | `entry.agent` 与 `healthcheck.kind` 的强绑定 | §14.6 验收项 | 已实现为 `E_MANIFEST`(agent 型必须 acp;acp 只能配 agent 型) | 已写回 §14.2 校验说明 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minonl/dia",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "description": "dia (嗲) — lightweight agent framework: knowledge index, audit, session ingest",
6
6
  "bin": {
@@ -1167,22 +1167,31 @@ wt_default_branch() {
1167
1167
  printf '%s' "$b"
1168
1168
  }
1169
1169
 
1170
- wt_write_hook() {
1171
- local hooksdir="$1" name="$2" extra="${3:-}" chain="${4:-}"
1172
- cat > "$hooksdir/$name" <<'HOOK'
1173
- #!/usr/bin/env bash
1174
- # dia-wt guard(managed by `dia wt adopt`;勿手改)
1170
+ wt_guard_body() {
1171
+ cat <<'HOOKBODY'
1175
1172
  set -uo pipefail
1176
- name="$(basename "$0")"
1173
+ # 钩子名:生成的钩子用 basename $0;`dia wt guard` 用环境变量指定
1174
+ name="${DIA_WT_HOOK_NAME:-$(basename "$0")}"
1177
1175
  top="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
1178
1176
  common="$(git rev-parse --git-common-dir 2>/dev/null || true)"
1179
- case "$common" in /*) ;; *) common="$(cd "$top" && cd "$common" 2>/dev/null && pwd -P)" ;; esac
1177
+ case "$common" in /*) ;; *) common="$(cd "$top" 2>/dev/null && cd "$common" 2>/dev/null && pwd -P)" ;; esac
1178
+ [ -n "$common" ] || exit 0
1180
1179
  gd="$(git rev-parse --git-dir 2>/dev/null || true)"
1181
- case "$gd" in /*) ;; *) gd="$(cd "$top" && cd "$gd" 2>/dev/null && pwd -P)" ;; esac
1180
+ case "$gd" in /*) ;; *) gd="$(cd "$top" 2>/dev/null && cd "$gd" 2>/dev/null && pwd -P)" ;; esac
1182
1181
  is_main=0; [ "$gd" = "$common" ] && is_main=1
1183
1182
  branch="$(git symbolic-ref --short HEAD 2>/dev/null || true)"
1184
- prot=" main master develop dev "
1185
1183
  probe="${DIA_WT_GUARD_PROBE:-0}"
1184
+
1185
+ # 只对已 adopt 的仓生效;未 adopt → 纯透传(保持本机其它仓行为不变)
1186
+ cfg="$common/dia/config.json"
1187
+ adopted=0; [ -f "$cfg" ] && adopted=1
1188
+ def=""
1189
+ if [ "$adopted" = 1 ]; then
1190
+ def="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("defaultBranch") or "")' "$cfg" 2>/dev/null || true)"
1191
+ fi
1192
+ prot=" main master develop dev $def "
1193
+ prot="${prot// / }"
1194
+
1186
1195
  wt_guard_scan() {
1187
1196
  local ls="$1" rs="$2" range="" base="" hits=""
1188
1197
  if [ -n "$rs" ] && [ "$rs" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$rs" 2>/dev/null; then
@@ -1197,74 +1206,100 @@ hits="$(git diff -U0 "$range" -- . 2>/dev/null | grep -E '^\+' | grep -vE '^\+\+
1197
1206
  if [ -n "$hits" ]; then
1198
1207
  echo "[dia-wt] 推送包含敏感内容(已阻断):" >&2
1199
1208
  printf '%s\n' "$hits" >&2
1200
- echo "[dia-wt] 误报请修扫描规则(hooks 由 dia wt adopt 生成),不提供关闭开关" >&2
1209
+ echo "[dia-wt] 误报请修扫描规则(hooks 由 dia wt adopt 安装),不提供关闭开关" >&2
1201
1210
  return 1
1202
1211
  fi
1203
1212
  }
1204
1213
 
1205
- case "$name" in
1206
- pre-commit)
1207
- if [ "$probe" = 1 ]; then
1208
- { [ "$is_main" = 1 ] && [[ "$prot" == *" $branch "* ]]; } && exit 1 || exit 0
1209
- fi
1210
- if [ "$is_main" = 1 ] && [[ "$prot" == *" $branch "* ]]; then
1211
- echo "[dia-wt] 主 checkout 禁止在 $branch 直接提交;请先:dia wt create <repo> <feature>" >&2
1212
- exit 1
1213
- fi ;;
1214
- pre-push)
1215
- if [ "$probe" = 1 ]; then
1216
- [ "$is_main" = 1 ] && exit 1 || exit 0
1217
- fi
1218
- if [ "$is_main" = 1 ]; then
1219
- echo "[dia-wt] 主 checkout 禁止直接 push;请走 worktree 分支 + MR" >&2
1220
- exit 1
1221
- fi
1222
- while read -r _lr ls rr rs; do
1223
- case "${rr:-}" in
1224
- refs/heads/main|refs/heads/master|refs/heads/develop|refs/heads/dev)
1225
- echo "[dia-wt] 禁止直接 push ${rr#refs/heads/};请走 MR" >&2; exit 1 ;;
1226
- esac
1227
- wt_guard_scan "$ls" "$rs" || exit 1
1228
- done ;;
1229
- post-checkout)
1230
- [ "$probe" = 1 ] && exit 0
1231
- if [ "$is_main" = 1 ] && [[ "$prot" != *" $branch "* ]]; then
1232
- echo "[dia-wt] 提示:主 checkout 当前在 $branch(建议回到默认分支)" >&2
1233
- fi ;;
1234
- esac
1235
- # 钩子链:原 hooksPath(husky / .githooks / simple-git-hooks)优先,其次仓库默认 hooks 目录
1236
- chain="${DIA_WT_CHAIN_DIR:-}"
1237
- case "$chain" in
1238
- "") ;;
1239
- /*) ;;
1240
- *) chain="$top/$chain" ;;
1241
- esac
1242
- if [ -n "$chain" ] && [ -x "$chain/$name" ] && [ "$chain/$name" != "$0" ]; then exec "$chain/$name" "$@"; fi
1214
+ if [ "$probe" = 1 ]; then
1215
+ case "$name" in
1216
+ pre-commit) { [ "$adopted" = 1 ] && [ "$is_main" = 1 ] && [[ "$prot" == *" $branch "* ]]; } && exit 1 || exit 0 ;;
1217
+ pre-push) { [ "$adopted" = 1 ] && [ "$is_main" = 1 ]; } && exit 1 || exit 0 ;;
1218
+ *) exit 0 ;;
1219
+ esac
1220
+ fi
1221
+
1222
+ if [ "$adopted" = 1 ]; then
1223
+ case "$name" in
1224
+ pre-commit)
1225
+ if [ "$is_main" = 1 ] && [[ "$prot" == *" $branch "* ]]; then
1226
+ echo "[dia-wt] 主 checkout 禁止在 $branch 直接提交;请先:dia wt create <repo> <feature>" >&2
1227
+ exit 1
1228
+ fi ;;
1229
+ pre-push)
1230
+ if [ "$is_main" = 1 ]; then
1231
+ echo "[dia-wt] 主 checkout 禁止直接 push;请走 worktree 分支 + MR" >&2
1232
+ exit 1
1233
+ fi
1234
+ # 先把 stdin 全读进来:既要逐条判 ref / 扫敏感内容,也要原样喂给仓库自己的钩子
1235
+ push_lines=()
1236
+ while IFS= read -r line; do push_lines+=("$line"); done
1237
+ for line in "${push_lines[@]}"; do
1238
+ read -r _lr ls rr rs <<<"$line"
1239
+ case "${rr:-}" in
1240
+ refs/heads/main|refs/heads/master|refs/heads/develop|refs/heads/dev)
1241
+ echo "[dia-wt] 禁止直接 push ${rr#refs/heads/};请走 MR" >&2; exit 1 ;;
1242
+ esac
1243
+ [ -n "${ls:-}" ] || continue
1244
+ wt_guard_scan "$ls" "${rs:-}" || exit 1
1245
+ done ;;
1246
+ post-checkout)
1247
+ if [ "$is_main" = 1 ] && [[ "$prot" != *" $branch "* ]]; then
1248
+ echo "[dia-wt] 提示:主 checkout 当前在 $branch(建议回到默认分支)" >&2
1249
+ fi ;;
1250
+ esac
1251
+ fi
1252
+
1253
+ # 链式:仓库自己的钩子(simple-git-hooks 等管理器生成在 <common>/hooks)
1243
1254
  orig="$common/hooks/$name"
1244
- if [ -x "$orig" ] && [ "$orig" != "$0" ]; then exec "$orig" "$@"; fi
1255
+ if [ -x "$orig" ] && [ "$orig" != "$0" ]; then
1256
+ if [ "$name" = "pre-push" ] && [ ${#push_lines[@]} -gt 0 ]; then
1257
+ printf '%s\n' "${push_lines[@]}" | "$orig" "$@"
1258
+ exit $?
1259
+ fi
1260
+ exec "$orig" "$@"
1261
+ fi
1245
1262
  exit 0
1246
- HOOK
1263
+ HOOKBODY
1264
+ }
1265
+
1266
+ # simple-git-hooks RC 注入目录(跟随 dia 数据根)
1267
+ wt_hooks_rc_dir() { printf '%s/hooks' "${DIA_ROOT:-${DIA_DATA_DIR:-$HOME/.dia}}"; }
1268
+ # 在 ~/.zshenv 里确保 export KEY=VALUE(幂等;带 marker 便于移除)
1269
+ wt_ensure_env_export() {
1270
+ local key="$1" val="$2" rc="${DIA_WT_SHELL_RC:-$HOME/.zshenv}"
1271
+ local marker="# dia-wt: $key"
1272
+ [ -f "$rc" ] || : > "$rc"
1273
+ grep -q "^$marker" "$rc" 2>/dev/null && return 0
1274
+ printf '\n%s\nexport %s="%s"\n' "$marker" "$key" "$val" >> "$rc"
1275
+ }
1276
+ wt_write_hook() {
1277
+ local hooksdir="$1" name="$2" extra="${3:-}" chain="${4:-}"
1278
+ {
1279
+ printf '#!/usr/bin/env bash\n# dia-wt guard(managed by `dia wt adopt`;勿手改)\n'
1280
+ wt_guard_body
1281
+ } > "$hooksdir/$name"
1247
1282
  if [ -n "$chain" ]; then
1248
1283
  python3 - "$hooksdir/$name" "$chain" <<'PYCHAIN'
1249
1284
  import sys
1250
1285
  p, chain = sys.argv[1], sys.argv[2]
1251
1286
  s = open(p).read()
1252
- s = s.replace('chain="${DIA_WT_CHAIN_DIR:-}"', 'chain="${DIA_WT_CHAIN_DIR:-%s}"' % chain.replace('\\', '\\\\').replace('"', '\\"'))
1287
+ s = s.replace('orig="$common/hooks/$name"', 'orig="%s/$name"' % chain.replace('\\', '\\\\').replace('"', '\\"'))
1253
1288
  open(p, "w").write(s)
1254
1289
  PYCHAIN
1255
- fi
1256
- if [ -n "$extra" ]; then
1257
- python3 - "$hooksdir/$name" "$extra" <<'PYGT'
1258
- import sys, re
1259
- p, extra = sys.argv[1], sys.argv[2]
1260
- s = open(p).read()
1261
- s = re.sub(r'^prot=" main master develop dev "$', f'prot=" main master develop dev {extra} "', s, flags=re.M)
1262
- open(p, "w").write(s)
1263
- PYGT
1264
1290
  fi
1265
1291
  chmod +x "$hooksdir/$name"
1266
1292
  }
1267
1293
 
1294
+ cmd_guard() {
1295
+ local name="${1:-}"
1296
+ case "$name" in
1297
+ pre-commit|pre-push|post-checkout) ;;
1298
+ *) echo "usage: dia wt guard <pre-commit|pre-push|post-checkout>" >&2; return 2 ;;
1299
+ esac
1300
+ DIA_WT_HOOK_NAME="$name" eval "$(wt_guard_body)"
1301
+ }
1302
+
1268
1303
  cmd_adopt() {
1269
1304
  local repo="" mode="dry" template="" def="" json=0 all=0
1270
1305
  local -a orig=("$@")
@@ -1305,19 +1340,20 @@ cmd_adopt() {
1305
1340
  local common cfgdir cfg hooksdir prev
1306
1341
  common="$(wt_common_dir "$repo")" || die "无法解析 git-common-dir"
1307
1342
  cfgdir="$common/dia"; cfg="$cfgdir/config.json"; hooksdir="$cfgdir/hooks"
1308
- prev="$(git -C "$repo" config --get core.hooksPath 2>/dev/null || true)"
1343
+ prev="$(git -C "$repo" config --local --get core.hooksPath 2>/dev/null || true)"
1309
1344
  [ -n "$template" ] || template='{{parent}}/{{repo}}-worktree--{{feature}}'
1310
1345
  [ -n "$def" ] || def="$(wt_default_branch "$repo")"
1311
1346
 
1312
1347
  if [ "$mode" = "revert" ]; then
1313
1348
  [ -f "$cfg" ] || die "未 adopt(缺 $cfg)"
1314
- local saved
1315
- saved="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$cfg")"
1316
- if [ "$prev" != "$hooksdir" ]; then
1317
- echo "[dia-wt] hooksPath 已被改动($prev),跳过还原并保留 $cfgdir 备份" >&2
1318
- return 1
1349
+ local saved mgr
1350
+ saved="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$cfg" 2>/dev/null || true)"
1351
+ mgr="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksManager") or "")' "$cfg" 2>/dev/null || true)"
1352
+ if [ "$mgr" = "local" ] || [ -z "$mgr" ]; then
1353
+ if [ "$prev" = "$hooksdir" ]; then
1354
+ if [ -n "$saved" ]; then git -C "$repo" config --local core.hooksPath "$saved"; else git -C "$repo" config --local --unset core.hooksPath 2>/dev/null || true; fi
1355
+ fi
1319
1356
  fi
1320
- if [ -n "$saved" ]; then git -C "$repo" config --local core.hooksPath "$saved"; else git -C "$repo" config --local --unset core.hooksPath 2>/dev/null || true; fi
1321
1357
  rm -rf "$cfgdir"
1322
1358
  echo "reverted:$cfgdir"
1323
1359
  return 0
@@ -1331,16 +1367,28 @@ cmd_adopt() {
1331
1367
  local cfgDef; cfgDef="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("defaultBranch") or "")' "$cfg" 2>/dev/null || true)"
1332
1368
  [ -n "$cfgDef" ] && def="$cfgDef"
1333
1369
  fi
1334
- case "$prev" in /*) ;; *) echo "FAIL D02 core.hooksPath 不是绝对路径($prev)"; bad=1 ;; esac
1335
- for h in pre-commit pre-push post-checkout; do
1336
- [ -x "$hooksdir/$h" ] || { echo "FAIL D02 缺 $h 钩子"; bad=1; }
1337
- done
1338
- if [ -n "$def" ] && [ -x "$hooksdir/pre-commit" ]; then
1339
- grep -q "prot=\" main master develop dev $def \"" "$hooksdir/pre-commit" \
1340
- || { echo "FAIL D03 钩子未保护默认分支 $def"; bad=1; }
1341
- if wt_is_main_checkout "$repo" && [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ]; then
1370
+ local mgr2; mgr2="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksManager") or "")' "$cfg" 2>/dev/null || true)"
1371
+ if [ "$mgr2" = "simple-git-hooks" ]; then
1372
+ local rcf; rcf="$(wt_hooks_rc_dir)/simple-git-hooks-rc.sh"
1373
+ [ -f "$rcf" ] || { echo "FAIL D02 缺 simple-git-hooks RC:$rcf"; bad=1; }
1374
+ grep -rqs "SIMPLE_GIT_HOOKS_RC" "$HOME/.zshenv" || { echo "FAIL D02 shell 未 export SIMPLE_GIT_HOOKS_RC"; bad=1; }
1375
+ python3 - "$repo/package.json" <<'PYM' >/dev/null 2>&1 || { echo "FAIL D02 package.json 未启用 simple-git-hooks"; bad=1; }
1376
+ import json,sys
1377
+ d=json.load(open(sys.argv[1])); c=d.get("simple-git-hooks"); dev=d.get("devDependencies",{}) or {}
1378
+ raise SystemExit(0 if isinstance(c,dict) or "simple-git-hooks" in dev else 1)
1379
+ PYM
1380
+ if [ -n "$def" ] && wt_is_main_checkout "$repo" && [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ]; then
1381
+ ( cd "$repo" && DIA_WT_GUARD_PROBE=1 bash "$SELF" guard pre-commit >/dev/null 2>&1 ) \
1382
+ && { echo "FAIL D03 守卫探针未拦截 $def 提交"; bad=1; }
1383
+ fi
1384
+ else
1385
+ case "$prev" in /*) ;; *) echo "FAIL D02 本地 core.hooksPath 非绝对($prev)"; bad=1 ;; esac
1386
+ for h in pre-commit pre-push post-checkout; do
1387
+ [ -x "$hooksdir/$h" ] || { echo "FAIL D02 缺 $h 钩子"; bad=1; }
1388
+ done
1389
+ if [ -n "$def" ] && wt_is_main_checkout "$repo" && [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ]; then
1342
1390
  ( cd "$repo" && DIA_WT_GUARD_PROBE=1 "$hooksdir/pre-commit" >/dev/null 2>&1 ) \
1343
- && { echo "FAIL D03 守卫探针未拦截主 checkout 的 $def 提交"; bad=1; }
1391
+ && { echo "FAIL D03 守卫探针未拦截 $def 提交"; bad=1; }
1344
1392
  fi
1345
1393
  fi
1346
1394
  if [ "$json" = 1 ]; then
@@ -1366,6 +1414,80 @@ cmd_adopt() {
1366
1414
 
1367
1415
  [ -n "$def" ] || die "默认分支不可解析;请 --default-branch main"
1368
1416
  mkdir -p "$hooksdir"
1417
+ # 安装/刷新本机全局钩子,并让本仓走全局钩子(不改 package.json、不占用仓库钩子目录)
1418
+ # ── 方案:每个仓本地钩子;simple-git-hooks 仓改用它的 RC 注入 ──────────────
1419
+ # 不再写任何全局 git 配置(避免外溢)。
1420
+ if [ -f "$repo/package.json" ] && python3 - "$repo/package.json" <<'PYCHK'
1421
+ import json,sys
1422
+ d=json.load(open(sys.argv[1]))
1423
+ cfg=d.get("simple-git-hooks")
1424
+ dev=d.get("devDependencies",{}) or {}
1425
+ raise SystemExit(0 if isinstance(cfg,dict) or "simple-git-hooks" in dev else 1)
1426
+ PYCHK
1427
+ then
1428
+ # simple-git-hooks:钩子由管理器生成到 .git/hooks,我们不碰;通过 SIMPLE_GIT_HOOKS_RC 注入守卫
1429
+ local rcdir rcf
1430
+ rcdir="$(wt_hooks_rc_dir)"; rcf="$rcdir/simple-git-hooks-rc.sh"
1431
+ mkdir -p "$rcdir"
1432
+ cat > "$rcf" <<'RC'
1433
+ # 由 simple-git-hooks 生成的钩子 source 本文件(SIMPLE_GIT_HOOKS_RC)
1434
+ # 只对已 adopt 的仓生效;未 adopt → 直接返回
1435
+ if command -v dia >/dev/null 2>&1; then
1436
+ dia wt guard "$(basename "$0")" || exit $?
1437
+ fi
1438
+ RC
1439
+ wt_ensure_env_export SIMPLE_GIT_HOOKS_RC "$rcf"
1440
+ python3 - "$cfg" "$repo" "$def" "$template" "$prev" <<'PYG'
1441
+ import json, sys, datetime
1442
+ cfg, repo, br, tmpl, prev = sys.argv[1:6]
1443
+ json.dump({
1444
+ "schema": 2, "repo": repo, "defaultBranch": br, "template": tmpl,
1445
+ "hooksManager": "simple-git-hooks", "hooksPrevious": prev or None,
1446
+ "adoptedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
1447
+ }, open(cfg, "w"), ensure_ascii=False, indent=1)
1448
+ PYG
1449
+ echo "adopted:$repo(hook manager = simple-git-hooks;守卫经 SIMPLE_GIT_HOOKS_RC 注入)"
1450
+ return 0
1451
+ fi
1452
+
1453
+ # 其它仓:自己管 <common>/dia/hooks,并把本地 core.hooksPath 指过去。
1454
+ # 若该仓已有**别人**的本地 hooksPath(husky/.githooks 等),不抢占:只报告,交由转为 simple-git-hooks 处理。
1455
+ if [ -n "$prev" ] && [ "$prev" != "$hooksdir" ]; then
1456
+ python3 - "$cfg" "$repo" "$def" "$template" "$prev" <<'PYG'
1457
+ import json, sys, datetime
1458
+ cfg, repo, br, tmpl, prev = sys.argv[1:6]
1459
+ json.dump({
1460
+ "schema": 2, "repo": repo, "defaultBranch": br, "template": tmpl,
1461
+ "hooksManager": "external", "hooksPrevious": prev,
1462
+ "adoptedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
1463
+ }, open(cfg, "w"), ensure_ascii=False, indent=1)
1464
+ PYG
1465
+ echo "[dia-wt] ⚠️ $repo 的 core.hooksPath=$prev 由其它管理器管理,未接管(守卫不生效);建议改用 simple-git-hooks(见 docs/worktree.md)" >&2
1466
+ return 0
1467
+ fi
1468
+ mkdir -p "$hooksdir"
1469
+ local savedPrev=""
1470
+ [ -f "$cfg" ] && savedPrev="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$cfg" 2>/dev/null || true)"
1471
+ [ "$prev" = "$hooksdir" ] && prev="$savedPrev"
1472
+ local chain="$prev"
1473
+ [ -n "$chain" ] || chain="$common/hooks"
1474
+ [ -f "$cfg" ] && cp "$cfg" "$cfgdir/config.json.bak.$(date +%s)"
1475
+ wt_write_hook "$hooksdir" pre-commit "$def" "$chain"
1476
+ wt_write_hook "$hooksdir" pre-push "$def" "$chain"
1477
+ wt_write_hook "$hooksdir" post-checkout "$def" "$chain"
1478
+ git -C "$repo" config --local core.hooksPath "$hooksdir"
1479
+ python3 - "$cfg" "$repo" "$def" "$template" "$prev" "$chain" <<'PYG'
1480
+ import json, sys, datetime
1481
+ cfg, repo, br, tmpl, prev, chain = sys.argv[1:7]
1482
+ json.dump({
1483
+ "schema": 2, "repo": repo, "defaultBranch": br, "template": tmpl,
1484
+ "hooksManager": "local", "hooksPath": cfg.rsplit("/",1)[0] + "/hooks",
1485
+ "hooksPrevious": prev or None, "hooksChain": chain,
1486
+ "adoptedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
1487
+ }, open(cfg, "w"), ensure_ascii=False, indent=1)
1488
+ PYG
1489
+ echo "adopted:$repo(守卫 = 仓库本地钩子 $hooksdir)"
1490
+ return 0
1369
1491
  # 保留首次 adopt 记下的 hooksPrevious(重复 apply 时 prev 已是我们自己的 hooksdir)
1370
1492
  local savedPrev=""
1371
1493
  [ -f "$cfg" ] && savedPrev="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$cfg" 2>/dev/null || true)"
@@ -1384,11 +1506,6 @@ data = {
1384
1506
  }
1385
1507
  json.dump(data, open(cfg, "w"), ensure_ascii=False, indent=1)
1386
1508
  PY
1387
- # 提示:simple-git-hooks / husky 的 postinstall 会重写 core.hooksPath 下的钩子,
1388
- # 每次 `pnpm/npm install` 后可能把守卫冲掉 → 用 `dia wt doctor --fix` 或重跑 adopt --apply 修复。
1389
- if grep -qs '"simple-git-hooks"\|"husky"\|lefthook' "$repo/package.json" "$repo/.lefthook.yml" 2>/dev/null; then
1390
- echo "[dia-wt] 注意:该仓使用 hook manager(simple-git-hooks/husky/lefthook),install 后若守卫被重写,跑 dia wt doctor --fix" >&2
1391
- fi
1392
1509
  wt_write_hook "$hooksdir" pre-commit "$def" "$chain"
1393
1510
  wt_write_hook "$hooksdir" pre-push "$def" "$chain"
1394
1511
  wt_write_hook "$hooksdir" post-checkout "$def" "$chain"
@@ -1419,14 +1536,25 @@ cmd_doctor() {
1419
1536
  git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || continue
1420
1537
  local common cfgdir cfg hooksdir prev def
1421
1538
  common="$(wt_common_dir "$repo")"; cfgdir="$common/dia"; cfg="$cfgdir/config.json"; hooksdir="$cfgdir/hooks"
1422
- prev="$(git -C "$repo" config --get core.hooksPath 2>/dev/null || true)"
1539
+ prev="$(git -C "$repo" config --local --get core.hooksPath 2>/dev/null || true)"
1423
1540
  def=""
1424
1541
  if [ -f "$cfg" ]; then
1425
1542
  def="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("defaultBranch") or "")' "$cfg" 2>/dev/null || true)"
1426
1543
  fi
1427
1544
  [ -n "$def" ] || def="$(wt_default_branch "$repo")"
1428
1545
  # --fix:无损修复(重写三个守卫钩子 + hooksPath 绝对路径)。只对已 adopt 的仓生效。
1429
- if [ "$fix" = 1 ] && [ -f "$cfg" ]; then
1546
+ if [ "$fix" = 1 ] && [ -f "$cfg" ] && [ "$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksManager") or "")' "$cfg" 2>/dev/null || true)" = "simple-git-hooks" ]; then
1547
+ local rcdir rcf; rcdir="$(wt_hooks_rc_dir)"; rcf="$rcdir/simple-git-hooks-rc.sh"
1548
+ mkdir -p "$rcdir"
1549
+ cat > "$rcf" <<'RC'
1550
+ # 由 simple-git-hooks 生成的钩子 source 本文件(SIMPLE_GIT_HOOKS_RC)
1551
+ if command -v dia >/dev/null 2>&1; then
1552
+ dia wt guard "$(basename "$0")" || exit $?
1553
+ fi
1554
+ RC
1555
+ wt_ensure_env_export SIMPLE_GIT_HOOKS_RC "$rcf"
1556
+ [ "$json" = 0 ] && echo "FIX $repo simple-git-hooks RC 已重建"
1557
+ elif [ "$fix" = 1 ] && [ -f "$cfg" ]; then
1430
1558
  local fixChain=""
1431
1559
  fixChain="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksChain") or "")' "$cfg" 2>/dev/null || true)"
1432
1560
  [ -n "$fixChain" ] || fixChain="$common/hooks"
@@ -1438,18 +1566,31 @@ cmd_doctor() {
1438
1566
  prev="$hooksdir"
1439
1567
  [ "$json" = 0 ] && echo "FIX $repo 钩子已重装(chain=$fixChain)"
1440
1568
  fi
1441
- local bad=0
1569
+ local bad=0 mcfg=""
1442
1570
  [ -f "$cfg" ] || { echo "FAIL D01 $repo 未 adopt"; bad=1; }
1443
- case "$prev" in /*) ;; *) echo "FAIL D02 $repo hooksPath 非绝对($prev)"; bad=1 ;; esac
1444
- for h in pre-commit pre-push post-checkout; do
1445
- [ -x "$hooksdir/$h" ] || { echo "FAIL D02 $repo 缺 $h"; bad=1; }
1446
- done
1447
- if [ -n "$def" ] && [ -x "$hooksdir/pre-commit" ]; then
1448
- # 静态:钩子里的 protected 列表必须包含 defaultBranch
1449
- grep -q "prot=\" main master develop dev $def \"" "$hooksdir/pre-commit" \
1450
- || { echo "FAIL D03 $repo 钩子未保护默认分支 $def"; bad=1; }
1451
- # 动态:主 checkout 当前就在 def 上时必须被拦
1452
- if wt_is_main_checkout "$repo" && [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ]; then
1571
+ [ -f "$cfg" ] && mcfg="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksManager") or "")' "$cfg" 2>/dev/null || true)"
1572
+ local ghp mgr2
1573
+ ghp="$(git config --global --get core.hooksPath 2>/dev/null || true)"
1574
+ # D20 全局配置残留:旧版把守卫装到全局 hooksPath;现在改为每仓本地,必须清掉
1575
+ if [ -n "$ghp" ] && [ -f "$ghp/pre-commit" ] && grep -q "dia-wt guard" "$ghp/pre-commit" 2>/dev/null; then
1576
+ echo "FAIL D20 全局 core.hooksPath 仍指向 dia 旧钩子目录($ghp);执行 git config --global --unset core.hooksPath"
1577
+ bad=1
1578
+ fi
1579
+ mgr2="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksManager") or "")' "$cfg" 2>/dev/null || true)"
1580
+ if [ "$mgr2" = "simple-git-hooks" ]; then
1581
+ local rcf; rcf="$(wt_hooks_rc_dir)/simple-git-hooks-rc.sh"
1582
+ [ -f "$rcf" ] || { echo "FAIL D02 $repo 缺 simple-git-hooks RC:$rcf"; bad=1; }
1583
+ grep -rqs "SIMPLE_GIT_HOOKS_RC" "$HOME/.zshenv" || echo "WARN D02 $repo shell 未 export SIMPLE_GIT_HOOKS_RC(守卫不会执行)"
1584
+ if [ -n "$def" ] && wt_is_main_checkout "$repo" && [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ]; then
1585
+ ( cd "$repo" && DIA_WT_GUARD_PROBE=1 bash "$SELF" guard pre-commit >/dev/null 2>&1 ) \
1586
+ && { echo "FAIL D03 $repo 守卫探针未拦截 $def 提交"; bad=1; }
1587
+ fi
1588
+ else
1589
+ case "$prev" in /*) ;; *) echo "FAIL D02 $repo 本地 core.hooksPath 非绝对($prev)"; bad=1 ;; esac
1590
+ for h in pre-commit pre-push post-checkout; do
1591
+ [ -x "$hooksdir/$h" ] || { echo "FAIL D02 $repo 缺 $h"; bad=1; }
1592
+ done
1593
+ if [ -n "$def" ] && wt_is_main_checkout "$repo" && [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ]; then
1453
1594
  ( cd "$repo" && DIA_WT_GUARD_PROBE=1 "$hooksdir/pre-commit" >/dev/null 2>&1 ) \
1454
1595
  && { echo "FAIL D03 $repo 守卫未拦截 $def 提交"; bad=1; }
1455
1596
  fi
@@ -1507,6 +1648,7 @@ case "${1:-}" in
1507
1648
  status) shift; cmd_status "$@" ;;
1508
1649
  check) shift; cmd_check "$@" ;;
1509
1650
  adopt) shift; cmd_adopt "$@" ;;
1651
+ guard) shift; cmd_guard "$@" ;;
1510
1652
  doctor) shift; cmd_doctor "$@" ;;
1511
1653
  gate) shift; cmd_gate "$@" ;;
1512
1654
  clean) shift; cmd_clean "$@" ;;
@@ -1522,6 +1664,7 @@ dia wt —— worktree 目录形式选型 + 创建(唯一实现)
1522
1664
  dia wt status [repo-path] # ★ 清理前必查:唯一工作副本 + 改动重叠(只读)
1523
1665
  dia wt check [<repo>...] [--all] [--deep] [--quiet]
1524
1666
  # ★ 依赖体检(R20):软链/逃逸/悬空;--all 扫全部仓
1667
+ dia wt guard <pre-commit|pre-push|post-checkout> # 守卫本体(供 hook manager 调用)
1525
1668
  dia wt adopt <repo>|--all [--apply|--check|--revert] [--template T] [--default-branch B] [--json]
1526
1669
  # 配置路径模板 + 守卫钩子(默认 dry-run;--all 扫仓根)
1527
1670
  # 守卫含推送前敏感扫描(不提供关闭开关;误报修规则)