@minonl/dia 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4720,6 +4720,67 @@ function build(opts = {}) {
4720
4720
  return { schema: 1, nodes: filteredNodes, edges: filteredEdges, cycles: findCycles(tasks) };
4721
4721
  }
4722
4722
 
4723
+ // src/graph-tui.ts
4724
+ var ORDER = ["doing", "blocked", "review", "todo", "awaiting_acceptance", "accepted", "done", "dropped", "rejected", "escalated"];
4725
+ var color = (s, code, on) => on ? `\x1B[${code}m${s}\x1B[0m` : s;
4726
+ function render2(g, opts = {}) {
4727
+ const on = opts.color ?? false;
4728
+ const tasks = g.nodes.filter((n) => n.kind === "task");
4729
+ const taskById = new Map(tasks.map((t) => [t.id, t]));
4730
+ const edgesFrom = (id, kind) => g.edges.filter((e) => e.from === id && e.kind === kind);
4731
+ const lines = [];
4732
+ const groups = new Map;
4733
+ for (const t of tasks) {
4734
+ const st = t.status || "todo";
4735
+ groups.set(st, [...groups.get(st) || [], t]);
4736
+ }
4737
+ for (const st of [...ORDER.filter((s) => groups.has(s)), ...[...groups.keys()].filter((s) => !ORDER.includes(s))]) {
4738
+ lines.push(color(`[${st}]`, "1;36", on));
4739
+ for (const t of groups.get(st).sort((a, b) => a.label.localeCompare(b.label))) {
4740
+ lines.push(` ${color(t.label, "1", on)} ${color(t.id.replace(/^task:/, ""), "2", on)}${t.owner ? ` owner=${t.owner}` : ""}`);
4741
+ for (const e of edgesFrom(t.id, "depends_on")) {
4742
+ const dep = taskById.get(e.to);
4743
+ lines.push(` ${color("↳ depends", "34", on)} ${dep?.label || e.to}${dep ? ` [${dep.status}]` : ""}`);
4744
+ }
4745
+ for (const e of edgesFrom(t.id, "touches"))
4746
+ lines.push(` ${color("↳ touches", "35", on)} ${e.to.replace(/^path:/, "")}`);
4747
+ if (t.blocked_by?.length)
4748
+ lines.push(` ${color(`⚠ blocked_by ${t.blocked_by.join(", ")}`, "31", on)}`);
4749
+ }
4750
+ lines.push("");
4751
+ }
4752
+ if (!tasks.length)
4753
+ lines.push("(no tasks)");
4754
+ const claimEdges = g.edges.filter((e) => e.kind === "claims");
4755
+ if (claimEdges.length) {
4756
+ lines.push(color("claims", "1;36", on));
4757
+ for (const e of claimEdges)
4758
+ lines.push(` ${e.from.replace(/^session:/, "")} → ${e.to.replace(/^path:/, "")}`);
4759
+ lines.push("");
4760
+ }
4761
+ for (const c of g.cycles)
4762
+ lines.push(color(`⚠ cycle: ${c.join(" -> ")}`, "31", on));
4763
+ return lines.join(`
4764
+ `);
4765
+ }
4766
+ function watch(build, opts = {}) {
4767
+ const paint = () => process.stdout.write("\x1B[2J\x1B[H" + render2(build(), { color: opts.color ?? process.stdout.isTTY }) + `
4768
+ `);
4769
+ paint();
4770
+ const timer = setInterval(() => {
4771
+ try {
4772
+ paint();
4773
+ } catch {}
4774
+ }, opts.intervalMs ?? 2000);
4775
+ const stop = () => {
4776
+ clearInterval(timer);
4777
+ process.exit(0);
4778
+ };
4779
+ process.on("SIGINT", stop);
4780
+ process.on("SIGTERM", stop);
4781
+ return new Promise(() => {});
4782
+ }
4783
+
4723
4784
  // packages/cli/src/index.ts
4724
4785
  init_paths();
4725
4786
 
@@ -5491,6 +5552,25 @@ function jobRootResolved() {
5491
5552
  const legacy = join24(homedir11(), ".local", "share", "dia-job");
5492
5553
  return !existsSync21(next) && existsSync21(legacy) ? legacy : next;
5493
5554
  }
5555
+ function runJobCapture(argv) {
5556
+ const { bin, error } = jobBin();
5557
+ if (!bin) {
5558
+ process.stderr.write(`dia job: ${error || "未找到 dia-job;先跑 packages/job/install.sh 或设置 DIA_JOB_BIN"}
5559
+ `);
5560
+ return { status: 2, stdout: "", stderr: "" };
5561
+ }
5562
+ const r = spawnSync2(bin, argv, {
5563
+ encoding: "utf8",
5564
+ stdio: ["inherit", "pipe", "pipe"],
5565
+ env: { ...process.env, DIA_JOB_ROOT: jobRootResolved() }
5566
+ });
5567
+ if (r.error) {
5568
+ process.stderr.write(`dia job: ${r.error.message}
5569
+ `);
5570
+ return { status: 1, stdout: "", stderr: "" };
5571
+ }
5572
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
5573
+ }
5494
5574
  function runJob(argv) {
5495
5575
  const { bin, error } = jobBin();
5496
5576
  if (!bin) {
@@ -7237,7 +7317,18 @@ var STALE_FACTOR = 1.5;
7237
7317
  var MIN_HEALTH_INTERVAL = 60;
7238
7318
  var healthInterval = (hc) => hc?.interval ?? DEFAULT_HEALTH_INTERVAL;
7239
7319
  var staleAfter = (hc) => Math.floor(healthInterval(hc) * STALE_FACTOR);
7240
- var isRecord = (v) => !!v && typeof v === "object" && v.kind === "health" && typeof v.state === "string";
7320
+ var isRecord = (v) => !!v && typeof v === "object" && v.kind === "health" && (v.state === "ok" || v.state === "degraded" || v.state === "fail");
7321
+ var TS_RE = /"ts"\s*:\s*(-?\d+)/;
7322
+ var KIND_RE = /"kind"\s*:\s*"([A-Za-z0-9_-]+)"/;
7323
+ function parseStateLine(line) {
7324
+ const head = line.slice(0, 256), tail = line.slice(-256);
7325
+ const ts = TS_RE.exec(head)?.[1] ?? TS_RE.exec(tail)?.[1];
7326
+ const kind = KIND_RE.exec(head)?.[1] ?? KIND_RE.exec(tail)?.[1];
7327
+ if (!ts || !kind)
7328
+ return null;
7329
+ const n = Number(ts);
7330
+ return Number.isFinite(n) ? { ts: n, kind } : null;
7331
+ }
7241
7332
  function findLastHealth(lines, service, gen) {
7242
7333
  for (let i = lines.length - 1;i >= 0; i--) {
7243
7334
  const r = lines[i];
@@ -7422,6 +7513,24 @@ function diaCmd() {
7422
7513
  const entry = entries.find((p) => existsSync28(p));
7423
7514
  return entry ? `${q(process.execPath)} ${q(entry)}` : "dia";
7424
7515
  }
7516
+ function decorateStatus(text, view) {
7517
+ const lines = String(text ?? "").split(`
7518
+ `);
7519
+ const outLines = [];
7520
+ for (const line of lines) {
7521
+ outLines.push(line);
7522
+ const m = line.match(/local\.dia-job-svc-([A-Za-z0-9._-]+)/) || line.match(/(?:^|\s)svc-([A-Za-z0-9._-]+)\s/);
7523
+ if (!m)
7524
+ continue;
7525
+ try {
7526
+ const v = view(m[1]);
7527
+ if (v?.label)
7528
+ outLines.push(` health=${v.label}`);
7529
+ } catch {}
7530
+ }
7531
+ return outLines.join(`
7532
+ `);
7533
+ }
7425
7534
  function servicesRoot() {
7426
7535
  return process.env.DIA_SERVICE_HOME || join31(diaHome(), "services");
7427
7536
  }
@@ -7456,15 +7565,34 @@ function readHealthLines(file) {
7456
7565
  if (!existsSync28(file))
7457
7566
  return [];
7458
7567
  const rows = [];
7459
- for (const line of readFileSync25(file, "utf8").split(`
7460
- `)) {
7568
+ const lines = readFileSync25(file, "utf8").split(`
7569
+ `);
7570
+ for (const line of lines) {
7461
7571
  if (!line.trim())
7462
7572
  continue;
7463
7573
  try {
7464
7574
  const d = JSON.parse(line);
7465
7575
  if (isRecord(d))
7466
7576
  rows.push(d);
7577
+ continue;
7467
7578
  } catch {}
7579
+ const head = parseStateLine(line);
7580
+ const state = head ? /"state"\s*:\s*"(ok|degraded|fail)"/.exec(line)?.[1] : undefined;
7581
+ if (head && state) {
7582
+ rows.push({
7583
+ ...head,
7584
+ state,
7585
+ service: "",
7586
+ gen: "",
7587
+ source: "schedule",
7588
+ candidate: state,
7589
+ streak: 0,
7590
+ raw: state,
7591
+ exit: null,
7592
+ timeout: false,
7593
+ detail: ""
7594
+ });
7595
+ }
7468
7596
  }
7469
7597
  return rows;
7470
7598
  }
@@ -7473,6 +7601,41 @@ function appendHealthRecord(file, rec) {
7473
7601
  appendFileSync9(file, JSON.stringify(rec) + `
7474
7602
  `);
7475
7603
  }
7604
+ function readServiceHealth(id, opts = {}) {
7605
+ const { healthFile } = opts;
7606
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
7607
+ const jr = opts.jobRoot ?? jobRoot2();
7608
+ const dir = opts.servicesRoot ?? (process.env.DIA_SERVICE_HOME || join31(jr, "services"));
7609
+ const installDir = join31(dir, id);
7610
+ let lock = null;
7611
+ try {
7612
+ lock = readLockFile(id);
7613
+ } catch {}
7614
+ let svc = null;
7615
+ try {
7616
+ svc = readServiceManifest(lock?.dir ?? installDir);
7617
+ } catch {}
7618
+ if (!svc)
7619
+ return healthView({ adopted: true, hasHealthcheck: false, now });
7620
+ const hc = svc?.healthcheck;
7621
+ if (!hc)
7622
+ return healthView({ hasHealthcheck: false, now });
7623
+ const ledger = healthFile ?? healthStatePath(id);
7624
+ const recs = readHealthLines(ledger).filter(isRecord);
7625
+ let rec = lock ? findLastHealth(recs, id, lock.gen) : null;
7626
+ if (!rec) {
7627
+ for (let i = recs.length - 1;i >= 0; i--) {
7628
+ const r = recs[i];
7629
+ if (r.state !== "ok" && r.state !== "degraded" && r.state !== "fail")
7630
+ continue;
7631
+ if (r.service && r.service !== id)
7632
+ continue;
7633
+ if (!rec || r.ts > rec.ts)
7634
+ rec = r;
7635
+ }
7636
+ }
7637
+ return healthView({ record: rec, hasHealthcheck: true, hc, now });
7638
+ }
7476
7639
  function listServices(opts = {}) {
7477
7640
  const root = opts.servicesRoot ?? servicesRoot();
7478
7641
  const jr = opts.jobRoot ?? jobRoot2();
@@ -8165,7 +8328,7 @@ usage:
8165
8328
  dia agents list|register|unregister|discover
8166
8329
  dia msg deliver <agent> <id> [--transport tmux|acp]
8167
8330
  dia task add|list|link|status|done [--owner S] [--depends a,b] [--touches p1,p2]
8168
- dia graph [--json] [--task t-...]
8331
+ dia graph [--json] [--task t-...] [--tui [--watch] [--no-color]]
8169
8332
  dia claim <path> [--reason R] [--ttl 4h] [--session S] | dia claims [--all] [--mine] | dia unclaim <path|id> [--force]
8170
8333
  dia team run <id> --mock | --acp|--dsh --workspace DIR
8171
8334
  dia team gate|accept|reject|next
@@ -8558,6 +8721,19 @@ async function taskCli(argv) {
8558
8721
  }
8559
8722
  async function graphCli(argv) {
8560
8723
  const asJson = has(argv, "--json");
8724
+ if (has(argv, "--tui") || has(argv, "--watch")) {
8725
+ const paint = (g) => out(render2(g, { color: !!process.stdout.isTTY && !has(argv, "--no-color") }));
8726
+ try {
8727
+ if (has(argv, "--watch"))
8728
+ return await watch(() => build({ task: flag(argv, "--task") }), { color: !!process.stdout.isTTY && !has(argv, "--no-color") });
8729
+ const g = build({ task: flag(argv, "--task") });
8730
+ paint(g);
8731
+ return g.cycles.length ? 3 : 0;
8732
+ } catch (e) {
8733
+ err(e?.message || String(e));
8734
+ return String(e?.message || e).startsWith("E_GRAPH") ? 2 : 31;
8735
+ }
8736
+ }
8561
8737
  try {
8562
8738
  const g = build({ task: flag(argv, "--task") });
8563
8739
  if (asJson)
@@ -10086,8 +10262,19 @@ async function main(rawArgv) {
10086
10262
  return agentsCli(argv.slice(1));
10087
10263
  if (cmd === "team")
10088
10264
  return teamCli(argv.slice(1));
10089
- if (cmd === "job")
10090
- return runJob(argv.slice(1));
10265
+ if (cmd === "job") {
10266
+ const rest = argv.slice(1);
10267
+ if (rest[0] === "status" || rest[0] === "list") {
10268
+ const r = runJobCapture(rest);
10269
+ if (r.stderr)
10270
+ process.stderr.write(r.stderr);
10271
+ const text = decorateStatus(r.stdout, (id) => readServiceHealth(id));
10272
+ if (text)
10273
+ out(text.replace(/\n$/, ""));
10274
+ return r.status;
10275
+ }
10276
+ return runJob(rest);
10277
+ }
10091
10278
  if (cmd === "price") {
10092
10279
  const table = loadPrices();
10093
10280
  if (has(argv.slice(1), "--json"))
@@ -64,3 +64,17 @@ 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-...` 只显示该任务的直接邻居。
@@ -352,3 +352,79 @@ unknown(stale)**不单独返回**——`health` 是实时执行,不存在
352
352
 
353
353
  1. 判据写成**可判定的检查**:`node scripts/docs-index.mjs --check && node scripts/docs-link-check.mjs`(+ 可选 checklist 计数);
354
354
  2. 或者把它当**评审工具**用:接受 `escalated` 是预期结果,只取 `verify.json` 的 findings 作为 grill 产出。
355
+
356
+ ## 14. P2:agent 型服务(entry = persona 会话)
357
+
358
+ > 状态:**草案(待实现)**。§0 已定「本版只做脚本型」;本节把 P2 的形状先定下来,避免实现时二次发明语义。
359
+
360
+ ### 14.1 与脚本型的关系
361
+
362
+ ```mermaid
363
+ flowchart LR
364
+ S["service"] --> A["脚本型(已实现)<br/>entry = bin/*"]
365
+ S --> B["agent 型(P2)<br/>entry = ACP 会话"]
366
+ B --> J["job:把 ACP 端点拉起并保持<br/>(dsh --profile dia-acp / opencode acp)"]
367
+ B --> H["healthcheck:端点可应答 + 最近一轮成功<br/>(不判断“想得对不对”)"]
368
+ ```
369
+
370
+ **不变的**:`lifecycle`(job 托管)、`healthcheck` 契约(专用码域)、台账(`kind:"health"`)、`gen` 代际。
371
+ **不同的**:没有 `stdin` 事件契约 —— 输入是 ACP 的 `prompt`,输出是会话事件;所以 `contract` 换成 ACP 语义。
372
+
373
+ ### 14.2 清单
374
+
375
+ ```jsonc
376
+ {
377
+ "kind": "service",
378
+ "id": "example-agent",
379
+ "entry": {
380
+ "agent": {
381
+ "host": "dsh", // dsh | opencode
382
+ "profile": "dia-acp", // 由 persona/宿主提供;缺省用宿主默认
383
+ "persona": "yan" // 可选:仅做归属校验,不注入内容
384
+ }
385
+ },
386
+ "lifecycle": { "type": "service", "target": "local" },
387
+ "healthcheck": { "kind": "acp", "interval": 1800, "timeout": 60 },
388
+ "kits": []
389
+ }
390
+ ```
391
+
392
+ | 字段 | 语义 |
393
+ |---|---|
394
+ | `entry.agent.host` | 用哪套 ACP 实现(`dsh --profile <p>` 或 `opencode acp`) |
395
+ | `entry.agent.profile` | 宿主的 profile 名(不复制 persona 内容,只引用) |
396
+ | `entry.agent.persona` | 可选;`verify` 时校验「该 profile 是否属于此 persona」,防止挂错人 |
397
+ | `healthcheck.kind: "acp"` | 走 ACP 健康检查(见 §14.3),**不再要求 `cmd`** |
398
+
399
+ ### 14.3 健康检查(ACP 版)
400
+
401
+ | 检查 | 判据 | 失败归类 |
402
+ |---|---|---|
403
+ | 端点存活 | `initialize` 能返回 `protocolVersion`(JSON-RPC 1.0) | 连不上/超时 → `degraded` |
404
+ | 会话可用 | `session/new` 成功返回 `sessionId` | 返回错误 → `fail` |
405
+ | 最近一轮 | 台账里最近一次 `prompt` 的成功/失败(由调用方写事件) | 连续失败 `debounce` 次 → `degraded` |
406
+
407
+ **明确不做**:不判断「回答质量」、不重放 prompt、不在 healthcheck 里跑真实业务(成本 + 副作用)。
408
+
409
+ ### 14.4 与脚本型的复用
410
+
411
+ - `healthView` / `transition` / `gen` / 台账格式**完全复用**(`state.jsonl` + `kind:"health"`)
412
+ - 差异只在「怎么产生一次 judgement」:`runHealth()` 换成 `runAcpHealth()`(`initialize` + `session/new`)
413
+ - `dia service health <id>` 对 agent 型走 `runAcpHealth`,退出码沿用 §5
414
+
415
+ ### 14.5 边界
416
+
417
+ | 场景 | 行为 |
418
+ |---|---|
419
+ | 宿主未安装 / profile 不存在 | `add` 期 `E_BACKEND`(不装半套) |
420
+ | 端点活着但 `session/new` 失败 | `fail`(配置/鉴权问题,不是 infra 抖动) |
421
+ | 长任务(一轮 prompt 很久) | **不算失败**;只有「端点不可应答」才算 |
422
+ | 多实例 | `lifecycle.target` 声明归属;ACP 端点同机只允许一个 |
423
+ | 权限/凭据 | 由宿主 profile 自己管;`dia` 不读取、不注入 |
424
+
425
+ ### 14.6 验收(P2)
426
+
427
+ - [ ] 单测:清单校验(`entry.agent` 与 `entry.cmd` **互斥**,同时给 → `E_MANIFEST`)
428
+ - [ ] 单测:`runAcpHealth` 用 `tests/fixtures/fake-acp.mjs` 覆盖 `initialize` 失败 / `session/new` 失败 / 正常三态
429
+ - [ ] 单测:`healthcheck.kind` 缺省时按脚本型(向后兼容)
430
+ - [ ] e2e:装一个 agent 型 demo(fake ACP)→ `dia job status` 显示 `health=ok…`,杀掉端点后变 `degraded(stale)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minonl/dia",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
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,72 +1206,81 @@ 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
1245
- exit 0
1246
- HOOK
1247
- if [ -n "$chain" ]; then
1248
- python3 - "$hooksdir/$name" "$chain" <<'PYCHAIN'
1249
- import sys
1250
- p, chain = sys.argv[1], sys.argv[2]
1251
- s = open(p).read()
1252
- s = s.replace('chain="${DIA_WT_CHAIN_DIR:-}"', 'chain="${DIA_WT_CHAIN_DIR:-%s}"' % chain.replace('\\', '\\\\').replace('"', '\\"'))
1253
- open(p, "w").write(s)
1254
- 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
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 $?
1264
1259
  fi
1265
- chmod +x "$hooksdir/$name"
1260
+ exec "$orig" "$@"
1261
+ fi
1262
+ exit 0
1263
+ HOOKBODY
1264
+ }
1265
+
1266
+ wt_global_hooks_dir() { printf '%s/hooks' "$(dia_wt_data_root 2>/dev/null || echo "${DIA_ROOT:-$HOME/.dia}")"; }
1267
+ wt_write_global_hooks() {
1268
+ local dir; dir="$(wt_global_hooks_dir)"
1269
+ mkdir -p "$dir"
1270
+ local h
1271
+ for h in pre-commit pre-push post-checkout; do
1272
+ { printf '#!/usr/bin/env bash\n# dia-wt guard(本机全局钩子;由 `dia wt adopt` 安装,勿手改)\n'; wt_guard_body; } > "$dir/$h"
1273
+ chmod +x "$dir/$h"
1274
+ done
1275
+ }
1276
+
1277
+ cmd_guard() {
1278
+ local name="${1:-}"
1279
+ case "$name" in
1280
+ pre-commit|pre-push|post-checkout) ;;
1281
+ *) echo "usage: dia wt guard <pre-commit|pre-push|post-checkout>" >&2; return 2 ;;
1282
+ esac
1283
+ DIA_WT_HOOK_NAME="$name" eval "$(wt_guard_body)"
1266
1284
  }
1267
1285
 
1268
1286
  cmd_adopt() {
@@ -1305,19 +1323,18 @@ cmd_adopt() {
1305
1323
  local common cfgdir cfg hooksdir prev
1306
1324
  common="$(wt_common_dir "$repo")" || die "无法解析 git-common-dir"
1307
1325
  cfgdir="$common/dia"; cfg="$cfgdir/config.json"; hooksdir="$cfgdir/hooks"
1308
- prev="$(git -C "$repo" config --get core.hooksPath 2>/dev/null || true)"
1326
+ prev="$(git -C "$repo" config --local --get core.hooksPath 2>/dev/null || true)"
1309
1327
  [ -n "$template" ] || template='{{parent}}/{{repo}}-worktree--{{feature}}'
1310
1328
  [ -n "$def" ] || def="$(wt_default_branch "$repo")"
1311
1329
 
1312
1330
  if [ "$mode" = "revert" ]; then
1313
1331
  [ -f "$cfg" ] || die "未 adopt(缺 $cfg)"
1314
1332
  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
1333
+ saved="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$cfg" 2>/dev/null || true)"
1334
+ # 只还原 adopt 之前就存在的仓库级 hooksPath;当前若被别人改过则不动
1335
+ if [ -n "$saved" ] && [ "$prev" = "$hooksdir" ]; then
1336
+ git -C "$repo" config --local core.hooksPath "$saved"
1319
1337
  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
1338
  rm -rf "$cfgdir"
1322
1339
  echo "reverted:$cfgdir"
1323
1340
  return 0
@@ -1331,17 +1348,16 @@ cmd_adopt() {
1331
1348
  local cfgDef; cfgDef="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("defaultBranch") or "")' "$cfg" 2>/dev/null || true)"
1332
1349
  [ -n "$cfgDef" ] && def="$cfgDef"
1333
1350
  fi
1334
- case "$prev" in /*) ;; *) echo "FAIL D02 core.hooksPath 不是绝对路径($prev)"; bad=1 ;; esac
1351
+ local ghd ghp
1352
+ ghd="$(wt_global_hooks_dir)"
1353
+ ghp="$(git config --global --get core.hooksPath 2>/dev/null || true)"
1354
+ [ "$ghp" = "$ghd" ] || { echo "FAIL D02 全局 core.hooksPath 未指向 $ghd(当前:${ghp:-未设置})"; bad=1; }
1335
1355
  for h in pre-commit pre-push post-checkout; do
1336
- [ -x "$hooksdir/$h" ] || { echo "FAIL D02 缺 $h 钩子"; bad=1; }
1356
+ [ -x "$ghd/$h" ] || { echo "FAIL D02 缺全局钩子 $h"; bad=1; }
1337
1357
  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
1342
- ( cd "$repo" && DIA_WT_GUARD_PROBE=1 "$hooksdir/pre-commit" >/dev/null 2>&1 ) \
1343
- && { echo "FAIL D03 守卫探针未拦截主 checkout 的 $def 提交"; bad=1; }
1344
- fi
1358
+ if [ -n "$def" ] && wt_is_main_checkout "$repo" && [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ]; then
1359
+ ( cd "$repo" && DIA_WT_GUARD_PROBE=1 "$ghd/pre-commit" >/dev/null 2>&1 ) \
1360
+ && { echo "FAIL D03 守卫探针未拦截 $def 提交"; bad=1; }
1345
1361
  fi
1346
1362
  if [ "$json" = 1 ]; then
1347
1363
  python3 -c 'import json,sys;print(json.dumps({"repo":sys.argv[1],"ok":sys.argv[2]=="0","defaultBranch":sys.argv[3]},ensure_ascii=False,separators=(",",":")))' "$repo" "$bad" "$def"
@@ -1366,6 +1382,36 @@ cmd_adopt() {
1366
1382
 
1367
1383
  [ -n "$def" ] || die "默认分支不可解析;请 --default-branch main"
1368
1384
  mkdir -p "$hooksdir"
1385
+ # 安装/刷新本机全局钩子,并让本仓走全局钩子(不改 package.json、不占用仓库钩子目录)
1386
+ local ghd ghp
1387
+ ghd="$(wt_global_hooks_dir)"
1388
+ ghp="$(git config --global --get core.hooksPath 2>/dev/null || true)"
1389
+ # 配置外溢防线:全局 hooksPath 若已被别人设置,绝不覆盖
1390
+ if [ -n "$ghp" ] && [ "$ghp" != "$ghd" ]; then
1391
+ die "全局 core.hooksPath 已指向 $ghp(非 dia),拒绝覆盖;请先自行处理该配置"
1392
+ fi
1393
+ wt_write_global_hooks
1394
+ git config --global core.hooksPath "$ghd"
1395
+ # 仓库级 hooksPath:旧版 dia 目录 → 撤掉迁移;别人的管理器(husky/.githooks)→ 保留并提示
1396
+ if [ -n "$prev" ] && [ "$prev" != "$ghd" ]; then
1397
+ if [ "$prev" = "$hooksdir" ]; then
1398
+ git -C "$repo" config --local --unset core.hooksPath 2>/dev/null || true; prev=""
1399
+ else
1400
+ echo "[dia-wt] ⚠️ 该仓本地 core.hooksPath=$prev(由 hook manager 接管),全局守卫对本仓不生效" >&2
1401
+ fi
1402
+ fi
1403
+ rm -rf "$hooksdir" 2>/dev/null || true
1404
+ python3 - "$cfg" "$repo" "$def" "$template" "$prev" <<'PYG'
1405
+ import json, sys, datetime
1406
+ cfg, repo, br, tmpl, prev = sys.argv[1:6]
1407
+ json.dump({
1408
+ "schema": 2, "repo": repo, "defaultBranch": br, "template": tmpl,
1409
+ "hooksManager": "global", "hooksPrevious": prev or None,
1410
+ "adoptedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
1411
+ }, open(cfg, "w"), ensure_ascii=False, indent=1)
1412
+ PYG
1413
+ echo "adopted:$repo(守卫 = 本机全局钩子 $(wt_global_hooks_dir))"
1414
+ return 0
1369
1415
  # 保留首次 adopt 记下的 hooksPrevious(重复 apply 时 prev 已是我们自己的 hooksdir)
1370
1416
  local savedPrev=""
1371
1417
  [ -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 +1430,6 @@ data = {
1384
1430
  }
1385
1431
  json.dump(data, open(cfg, "w"), ensure_ascii=False, indent=1)
1386
1432
  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
1433
  wt_write_hook "$hooksdir" pre-commit "$def" "$chain"
1393
1434
  wt_write_hook "$hooksdir" pre-push "$def" "$chain"
1394
1435
  wt_write_hook "$hooksdir" post-checkout "$def" "$chain"
@@ -1419,7 +1460,7 @@ cmd_doctor() {
1419
1460
  git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || continue
1420
1461
  local common cfgdir cfg hooksdir prev def
1421
1462
  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)"
1463
+ prev="$(git -C "$repo" config --local --get core.hooksPath 2>/dev/null || true)"
1423
1464
  def=""
1424
1465
  if [ -f "$cfg" ]; then
1425
1466
  def="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("defaultBranch") or "")' "$cfg" 2>/dev/null || true)"
@@ -1427,6 +1468,13 @@ cmd_doctor() {
1427
1468
  [ -n "$def" ] || def="$(wt_default_branch "$repo")"
1428
1469
  # --fix:无损修复(重写三个守卫钩子 + hooksPath 绝对路径)。只对已 adopt 的仓生效。
1429
1470
  if [ "$fix" = 1 ] && [ -f "$cfg" ]; then
1471
+ wt_write_global_hooks
1472
+ git config --global core.hooksPath "$(wt_global_hooks_dir)"
1473
+ [ "$json" = 0 ] && echo "FIX $repo 全局钩子已重装"
1474
+ fi
1475
+ 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)" = "global" ]; then
1476
+ :
1477
+ elif [ "$fix" = 1 ] && [ -f "$cfg" ]; then
1430
1478
  local fixChain=""
1431
1479
  fixChain="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksChain") or "")' "$cfg" 2>/dev/null || true)"
1432
1480
  [ -n "$fixChain" ] || fixChain="$common/hooks"
@@ -1438,22 +1486,29 @@ cmd_doctor() {
1438
1486
  prev="$hooksdir"
1439
1487
  [ "$json" = 0 ] && echo "FIX $repo 钩子已重装(chain=$fixChain)"
1440
1488
  fi
1441
- local bad=0
1489
+ local bad=0 mcfg=""
1442
1490
  [ -f "$cfg" ] || { echo "FAIL D01 $repo 未 adopt"; bad=1; }
1443
- case "$prev" in /*) ;; *) echo "FAIL D02 $repo hooksPath 非绝对($prev)"; bad=1 ;; esac
1491
+ [ -f "$cfg" ] && mcfg="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksManager") or "")' "$cfg" 2>/dev/null || true)"
1492
+ local ghd ghp
1493
+ ghd="$(wt_global_hooks_dir)"
1494
+ ghp="$(git config --global --get core.hooksPath 2>/dev/null || true)"
1495
+ # D20 全局配置归属:只认 dia 自己的 hooksPath,被别人占用就报出来(绝不自动覆盖)
1496
+ [ "$ghp" = "$ghd" ] || { echo "FAIL D20 全局 core.hooksPath 未指向 $ghd(当前:${ghp:-未设置})"; bad=1; }
1444
1497
  for h in pre-commit pre-push post-checkout; do
1445
- [ -x "$hooksdir/$h" ] || { echo "FAIL D02 $repo 缺 $h"; bad=1; }
1498
+ [ -x "$ghd/$h" ] || { echo "FAIL D02 缺全局钩子 $h"; bad=1; }
1446
1499
  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
1453
- ( cd "$repo" && DIA_WT_GUARD_PROBE=1 "$hooksdir/pre-commit" >/dev/null 2>&1 ) \
1454
- && { echo "FAIL D03 $repo 守卫未拦截 $def 提交"; bad=1; }
1500
+ # D21 仓库级 hooksPath 外溢:本地设置会覆盖全局(husky/.githooks 等)
1501
+ if [ -n "$prev" ]; then
1502
+ if [ "$prev" = "$hooksdir" ]; then
1503
+ echo "FAIL D21 $repo 旧版本地 hooksPath 残留($prev);跑 dia wt adopt --apply 迁移到全局钩子"; bad=1
1504
+ else
1505
+ echo "WARN D21 $repo 本地 hooksPath=$prev 覆盖全局钩子,本仓守卫不生效"
1455
1506
  fi
1456
1507
  fi
1508
+ if [ -n "$def" ] && wt_is_main_checkout "$repo" && [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ]; then
1509
+ ( cd "$repo" && DIA_WT_GUARD_PROBE=1 "$ghd/pre-commit" >/dev/null 2>&1 ) \
1510
+ && { echo "FAIL D03 $repo 守卫探针未拦截 $def 提交"; bad=1; }
1511
+ fi
1457
1512
  if wt_is_main_checkout "$repo"; then
1458
1513
  [ -z "$(git -C "$repo" status --porcelain 2>/dev/null)" ] || echo "WARN D04 $repo 主 checkout 有未提交改动"
1459
1514
  [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ] || echo "WARN D04 $repo 主 checkout 不在 $def"
@@ -1507,6 +1562,7 @@ case "${1:-}" in
1507
1562
  status) shift; cmd_status "$@" ;;
1508
1563
  check) shift; cmd_check "$@" ;;
1509
1564
  adopt) shift; cmd_adopt "$@" ;;
1565
+ guard) shift; cmd_guard "$@" ;;
1510
1566
  doctor) shift; cmd_doctor "$@" ;;
1511
1567
  gate) shift; cmd_gate "$@" ;;
1512
1568
  clean) shift; cmd_clean "$@" ;;
@@ -1522,6 +1578,7 @@ dia wt —— worktree 目录形式选型 + 创建(唯一实现)
1522
1578
  dia wt status [repo-path] # ★ 清理前必查:唯一工作副本 + 改动重叠(只读)
1523
1579
  dia wt check [<repo>...] [--all] [--deep] [--quiet]
1524
1580
  # ★ 依赖体检(R20):软链/逃逸/悬空;--all 扫全部仓
1581
+ dia wt guard <pre-commit|pre-push|post-checkout> # 守卫本体(供 hook manager 调用)
1525
1582
  dia wt adopt <repo>|--all [--apply|--check|--revert] [--template T] [--default-branch B] [--json]
1526
1583
  # 配置路径模板 + 守卫钩子(默认 dry-run;--all 扫仓根)
1527
1584
  # 守卫含推送前敏感扫描(不提供关闭开关;误报修规则)
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bash
2
- # adopt / doctor / gate 回归测试(2026-11-05)
3
- # 覆盖:dry-run 计划、apply 幂等、check 通过、主 checkout 守卫、doctor、revert。
2
+ # adopt / doctor / gate 回归测试(2026-11-06:本机全局钩子模型)
3
+ # 覆盖:dry-run、apply(全局钩子)、check/doctor、主 checkout 守卫、链式调用仓库钩子、
4
+ # 非 adopt 仓透传、敏感扫描、adopt --all、gate、revert。
4
5
  set -uo pipefail
5
6
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
7
  WT="$HERE/../bin/dia-wt"
@@ -8,6 +9,13 @@ TD="$(mktemp -d)"; trap 'rm -rf "$TD"' EXIT
8
9
  PASS=0; FAIL=0
9
10
  ok(){ echo " ✓ $1"; PASS=$((PASS+1)); }; bad(){ echo " ✗ $1"; FAIL=$((FAIL+1)); }
10
11
 
12
+ # 隔离:HOME / 全局 git 配置 / 数据根,避免污染真实机器
13
+ export HOME="$TD/home"; mkdir -p "$HOME"
14
+ export GIT_CONFIG_GLOBAL="$TD/gitconfig"; : > "$GIT_CONFIG_GLOBAL"
15
+ export GIT_CONFIG_NOSYSTEM=1
16
+ export DIA_WT_DATA_ROOT="$TD/data"
17
+ GH="$TD/data/hooks"
18
+
11
19
  R="$TD/repo"; mkdir -p "$R"; git -C "$R" init -q -b main
12
20
  git -C "$R" config user.email t@example.com; git -C "$R" config user.name t
13
21
  echo a > "$R/a.txt"; git -C "$R" add -A; git -C "$R" commit -qm init
@@ -18,16 +26,14 @@ printf '%s' "$out" | grep -q '"defaultBranch":"main"' && ok "dry-run 识别默
18
26
 
19
27
  bash "$WT" adopt "$R" --apply >/dev/null 2>&1 || bad "apply 失败"
20
28
  [ -f "$R/.git/dia/config.json" ] && ok "config 已写" || bad "缺 config"
21
- hp="$(git -C "$R" config --get core.hooksPath)"
22
- case "$hp" in /*) ok "hooksPath 是绝对路径" ;; *) bad "hooksPath=$hp" ;; esac
23
- [ -x "$R/.git/dia/hooks/pre-commit" ] && ok "pre-commit 可执行" || bad "缺 pre-commit"
24
-
25
- md5_before="$(md5 -q "$R/.git/dia/config.json" 2>/dev/null || md5sum "$R/.git/dia/config.json" | cut -d' ' -f1)"
26
- sleep 1
27
- bash "$WT" adopt "$R" --apply >/dev/null 2>&1
28
- md5_after="$(md5 -q "$R/.git/dia/config.json" 2>/dev/null || md5sum "$R/.git/dia/config.json" | cut -d' ' -f1)"
29
- [ "$md5_before" != "$md5_after" ] && ok "重复 apply 会刷新时间戳(幂等语义:不重复建目录/钩子)" || bad "重复 apply 无变化"
30
-
29
+ [ "$(git config --global --get core.hooksPath)" = "$GH" ] && ok "全局 hooksPath 指向 $GH" || bad "全局 hooksPath 未设置"
30
+ for h in pre-commit pre-push post-checkout; do [ -x "$GH/$h" ] || bad "缺全局钩子 $h"; done
31
+ ok "全局三钩子可执行"
32
+ [ -z "$(git -C "$R" config --local --get core.hooksPath || true)" ] && ok "不再设置仓库本地 hooksPath" || bad "仍设了本地 hooksPath"
33
+ python3 -c 'import json,sys;d=json.load(open(sys.argv[1]));sys.exit(0 if d.get("hooksManager")=="global" else 1)' "$R/.git/dia/config.json" \
34
+ && ok "config 记录 hooksManager=global" || bad "hooksManager 未记录"
35
+
36
+ bash "$WT" adopt "$R" --apply >/dev/null 2>&1 && ok "重复 apply 幂等" || bad "重复 apply 失败"
31
37
  bash "$WT" adopt "$R" --check >/dev/null 2>&1 && ok "check 通过" || bad "check 失败"
32
38
  bash "$WT" doctor "$R" >/dev/null 2>&1 && ok "doctor 通过" || bad "doctor 失败"
33
39
 
@@ -35,119 +41,45 @@ echo b > "$R/b.txt"; git -C "$R" add b.txt
35
41
  if git -C "$R" commit -qm should-block 2>/dev/null; then bad "主 checkout main 提交未被拦截"; else ok "主 checkout main 提交被拦截"; fi
36
42
  git -C "$R" reset -q
37
43
 
38
- # ── 推送前敏感扫描(PII/凭据形态)──
39
- BR="$TD/remote.git"; git init -q --bare "$BR"
40
- git -C "$R" remote add origin "$BR" 2>/dev/null || true
41
- git --git-dir="$BR" fetch -q "$R" main:main 2>/dev/null || true
42
- git -C "$R" fetch -q origin 2>/dev/null || true
43
- git -C "$R" worktree add -q "$TD/wt" -b feat/scan 2>/dev/null || true
44
- BAD_HOME="/Users"; echo "$BAD_HOME/alice/secret" > "$TD/wt/bad.txt"
45
- git -C "$TD/wt" add -A && git -C "$TD/wt" commit -qm bad
46
- if git -C "$TD/wt" push -q origin feat/scan 2>/dev/null; then bad "敏感推送未被拦截"; else ok "敏感推送被拦截"; fi
47
- echo ok > "$TD/wt/bad.txt" && git -C "$TD/wt" add -A && git -C "$TD/wt" commit -qm fix
48
- git -C "$TD/wt" push -q origin feat/scan 2>/dev/null && ok "干净推送放行" || bad "干净推送被拒"
49
-
50
- # 误报回归:`$TD/home/kits/...` 这种相对片段不该被判成 /home/<user>
51
- printf 'path=$TD/home/kits/dia-msg/dia.json\n' > "$TD/wt/vars.txt"
52
- git -C "$TD/wt" add -A && git -C "$TD/wt" commit -qm vars
53
- git -C "$TD/wt" push -q origin feat/scan 2>/dev/null && ok "相对片段 home/ 不误报" || bad "相对片段被误判为敏感路径"
54
-
55
- bash "$WT" adopt "$R" --revert >/dev/null 2>&1 || bad "revert 失败"
56
- [ -z "$(git -C "$R" config --get core.hooksPath || true)" ] && ok "revert 还原 hooksPath" || bad "revert 未还原"
57
- [ ! -d "$R/.git/dia" ] && ok "revert 删除 dia 目录" || bad "dia 目录残留"
58
-
59
- # ── 原 hooksPath 保留 + 二次 apply 不改写 hooksPrevious(review P2-7)──────────
60
- R2="$TD/repo2"; mkdir -p "$R2" "$TD/orighooks"; git -C "$R2" init -q -b main
44
+ # 链式:仓库自己的 .git/hooks/pre-commit 仍要执行(simple-git-hooks 等管理器就写这里)
45
+ printf '#!/bin/sh\necho chained >> "$(git rev-parse --show-toplevel)/chain.log"\n' > "$R/.git/hooks/pre-commit"
46
+ chmod +x "$R/.git/hooks/pre-commit"
47
+ git -C "$R" worktree add -q -b feat/y "$TD/repo-worktree--y" main
48
+ echo c > "$TD/repo-worktree--y/c.txt"; git -C "$TD/repo-worktree--y" add c.txt
49
+ git -C "$TD/repo-worktree--y" commit -qm feat-y >/dev/null 2>&1
50
+ [ -f "$TD/repo-worktree--y/chain.log" ] && ok "仓库自己的钩子被链式执行" || bad "仓库钩子未执行"
51
+ rm -f "$TD/repo-worktree--y/chain.log"
52
+
53
+ # 本地 hooksPath(husky/.githooks)优先于全局 → 守卫不生效,但 adopt 不得破坏它
54
+ R2="$TD/repo2"; mkdir -p "$R2/.githooks"; git -C "$R2" init -q -b main
61
55
  git -C "$R2" config user.email t@example.com; git -C "$R2" config user.name t
62
56
  echo a > "$R2/a.txt"; git -C "$R2" add -A; git -C "$R2" commit -qm init
63
- printf '#!/bin/sh\nexit 0\n' > "$TD/orighooks/pre-commit"; chmod +x "$TD/orighooks/pre-commit"
64
- git -C "$R2" config --local core.hooksPath "$TD/orighooks"
65
- bash "$WT" adopt "$R2" --apply >/dev/null 2>&1 || bad "R2 apply 失败"
66
- hp1="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$R2/.git/dia/config.json")"
67
- [ "$hp1" = "$TD/orighooks" ] && ok "首次 apply 记录 hooksPrevious" || bad "hooksPrevious=$hp1"
68
- bash "$WT" adopt "$R2" --apply >/dev/null 2>&1
69
- hp2="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$R2/.git/dia/config.json")"
70
- [ "$hp2" = "$TD/orighooks" ] && ok "二次 apply 不覆盖 hooksPrevious" || bad "hooksPrevious 被改成 $hp2"
71
- bash "$WT" adopt "$R2" --revert >/dev/null 2>&1 || bad "R2 revert 失败"
72
- [ "$(git -C "$R2" config --get core.hooksPath)" = "$TD/orighooks" ] && ok "revert 还原原 hooksPath" || bad "revert 未还原原 hooksPath"
73
- # hooksPath 被第三方改动 → revert 拒绝(不抢配置)
57
+ git -C "$R2" config --local core.hooksPath .githooks
74
58
  bash "$WT" adopt "$R2" --apply >/dev/null 2>&1
75
- git -C "$R2" config --local core.hooksPath "$TD/orighooks"
76
- if bash "$WT" adopt "$R2" --revert >/dev/null 2>&1; then bad "hooksPath 被改动后 revert 仍执行"; else ok "hooksPath 被改动后 revert 拒绝"; fi
77
- [ -f "$R2/.git/dia/config.json" ] && ok "拒绝 revert 时保留 config 备份" || bad "拒绝 revert 却删了 config"
78
- git -C "$R2" config --local core.hooksPath "$R2/.git/dia/hooks"; bash "$WT" adopt "$R2" --revert >/dev/null 2>&1
59
+ [ "$(git -C "$R2" config --get core.hooksPath)" = ".githooks" ] && ok "保留仓库本地 hooksPath(husky 等)" || bad "破坏了本地 hooksPath"
60
+ bash "$WT" doctor "$R2" 2>&1 | grep -q "本地 hooksPath" && ok "doctor 提示本地 hooksPath 覆盖全局" || bad "doctor 未提示覆盖"
79
61
 
80
- # ── 自定义默认分支受保护(review P1-1 + P1-2)────────────────────────────────
81
- R3="$TD/repo3"; mkdir -p "$R3"; git -C "$R3" init -q -b trunk
62
+ # 未 adopt 的仓:全局钩子纯透传(主 checkout 可正常提交)
63
+ R3="$TD/repo3"; mkdir -p "$R3"; git -C "$R3" init -q -b main
82
64
  git -C "$R3" config user.email t@example.com; git -C "$R3" config user.name t
83
- echo a > "$R3/a.txt"; git -C "$R3" add -A; git -C "$R3" commit -qm init
84
- bash "$WT" adopt "$R3" --apply --default-branch trunk >/dev/null 2>&1 || bad "自定义分支 apply 失败"
85
- bash "$WT" adopt "$R3" --check >/dev/null 2>&1 && ok "自定义默认分支 check 通过" || bad "自定义默认分支 check 失败"
86
- bash "$WT" doctor "$R3" >/dev/null 2>&1 && ok "自定义默认分支 doctor 通过" || bad "自定义默认分支 doctor 失败"
87
- echo b > "$R3/b.txt"; git -C "$R3" add b.txt
88
- if git -C "$R3" commit -qm should-block 2>/dev/null; then bad "trunk 提交未被拦截"; else ok "自定义默认分支 trunk 提交被拦截"; fi
89
- git -C "$R3" reset -q
65
+ echo a > "$R3/a.txt"; git -C "$R3" add -A
66
+ git -C "$R3" commit -qm init >/dev/null 2>&1 && ok "未 adopt 仓不受守卫影响" || bad "未 adopt 仓被误拦"
90
67
 
91
- # ── doctor:未 adopt / 无参数(review P1-4)──────────────────────────────────
92
- bash "$WT" doctor "$R3" >/dev/null 2>&1 || true
93
- R4="$TD/repo4"; mkdir -p "$R4"; git -C "$R4" init -q -b main
94
- git -C "$R4" config user.email t@example.com; git -C "$R4" config user.name t
95
- echo a > "$R4/a.txt"; git -C "$R4" add -A; git -C "$R4" commit -qm init
96
- if bash "$WT" doctor "$R4" >/dev/null 2>&1; then bad "未 adopt 的仓 doctor 误报通过"; else ok "未 adopt 的仓 doctor 失败"; fi
97
- bash "$WT" doctor >/dev/null 2>&1; [ $? = 2 ] && ok "doctor 无参数 exit 2" || bad "doctor 无参数未 exit 2"
98
-
99
- # ── pre-push 守卫(主 checkout 拒绝 / worktree 放行)──────────────────────────
100
- R5="$TD/repo5"; ORIGIN5="$TD/origin5.git"; git init -q --bare "$ORIGIN5"
101
- mkdir -p "$R5"; git -C "$R5" init -q -b main
102
- git -C "$R5" config user.email t@example.com; git -C "$R5" config user.name t
103
- echo a > "$R5/a.txt"; git -C "$R5" add -A; git -C "$R5" commit -qm init
104
- git -C "$R5" remote add origin "$ORIGIN5"; git -C "$R5" push -q origin main
105
- git -C "$R5" fetch -q origin
106
- git -C "$R5" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main
107
- bash "$WT" adopt "$R5" --apply >/dev/null 2>&1 || bad "R5 apply 失败"
108
- # 主 checkout 的 pre-push 拒绝的是「从这里发起的任何 push」(含 feature 分支),不需要脏提交
109
- git -C "$R5" branch feat/push-probe origin/main
110
- if git -C "$R5" push origin feat/push-probe >/dev/null 2>&1; then bad "主 checkout push 未被拦截"; else ok "主 checkout push 被拦截"; fi
111
- git -C "$R5" branch -D feat/push-probe >/dev/null 2>&1 || true
112
- git -C "$R5" worktree add -q -b feat/x "$TD/repo5-worktree--x" main
113
- echo b > "$TD/repo5-worktree--x/b.txt"; git -C "$TD/repo5-worktree--x" add b.txt
114
- git -C "$TD/repo5-worktree--x" commit -qm feat-x 2>/dev/null
115
- if git -C "$TD/repo5-worktree--x" push -q origin feat/x >/dev/null 2>&1; then ok "worktree push 放行"; else bad "worktree push 被误拦"; fi
116
-
117
- # ── template 生效(P2-6):adopt 后 create 按 config.template 推导路径 ─────────
118
- out="$(bash "$WT" create "$R5" t1 --dry-run 2>&1 || true)"
119
- printf '%s' "$out" | grep -q "repo5-worktree--t1" && ok "默认模板 create 路径" || bad "默认模板 create: $out"
120
- bash "$WT" adopt "$R5" --apply --template '{{parent}}/{{repo}}.wt.{{feature}}' >/dev/null 2>&1 || bad "template apply 失败"
121
- out="$(bash "$WT" create "$R5" t1 --dry-run 2>&1 || true)"
122
- printf '%s' "$out" | grep -q "repo5.wt.t1" && ok "自定义模板 create 路径生效" || bad "自定义模板: $out"
123
- bash "$WT" adopt "$R5" --apply >/dev/null 2>&1
124
-
125
- # ── 过期 origin/HEAD(远端分支已删)不误判默认分支 ──────────────────────────
126
- R6="$TD/repo6"; mkdir -p "$R6"; git -C "$R6" init -q -b main
127
- git -C "$R6" config user.email t@example.com; git -C "$R6" config user.name t
128
- echo a > "$R6/a.txt"; git -C "$R6" add -A; git -C "$R6" commit -qm init
129
- git -C "$R6" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/gone
130
- out="$(bash "$WT" adopt "$R6" --json)"
131
- printf '%s' "$out" | grep -q '"defaultBranch":"main"' && ok "过期 origin/HEAD 回落到 main" || bad "过期 origin/HEAD: $out"
132
-
133
- # ── 钩子链:原 hooksPath(.githooks / husky)不被吞掉 ─────────────────────────
134
- R7="$TD/repo7"; mkdir -p "$R7/.githooks"; git -C "$R7" init -q -b main
135
- git -C "$R7" config user.email t@example.com; git -C "$R7" config user.name t
136
- printf '#!/bin/sh\necho chained >> "$(git rev-parse --show-toplevel)/chain.log"\n' > "$R7/.githooks/pre-commit"
137
- chmod +x "$R7/.githooks/pre-commit"
138
- echo a > "$R7/a.txt"; git -C "$R7" add -A; git -C "$R7" commit -qm init
139
- git -C "$R7" config --local core.hooksPath .githooks
140
- bash "$WT" adopt "$R7" --apply >/dev/null 2>&1 || bad "R7 apply 失败"
141
- python3 -c 'import json,sys;d=json.load(open(sys.argv[1]));sys.exit(0 if d.get("hooksChain")==".githooks" else 1)' "$R7/.git/dia/config.json" \
142
- && ok "config 记录 hooksChain" || bad "hooksChain 未记录"
143
- git -C "$R7" worktree add -q -b feat/y "$TD/repo7-worktree--y" main
144
- echo b > "$TD/repo7-worktree--y/b.txt"; git -C "$TD/repo7-worktree--y" add b.txt
145
- git -C "$TD/repo7-worktree--y" commit -qm feat-y >/dev/null 2>&1
146
- [ -f "$TD/repo7-worktree--y/chain.log" ] && ok "原 pre-commit 被链式执行" || bad "原 pre-commit 未执行(钩子被吞)"
147
- bash "$WT" adopt "$R7" --revert >/dev/null 2>&1
148
- [ "$(git -C "$R7" config --get core.hooksPath)" = ".githooks" ] && ok "revert 还原原 hooksPath(相对)" || bad "revert 未还原 .githooks"
149
-
150
- # ── adopt --all:批量主 checkout,跳过 worktree 目录 ─────────────────────────
68
+ # 敏感扫描:adopted 仓的 worktree 推送带绝对路径 → 阻断;相对片段不误报
69
+ BR="$TD/remote.git"; git init -q --bare "$BR"
70
+ git -C "$R" remote add origin "$BR" 2>/dev/null || true
71
+ git --git-dir="$BR" fetch -q "$R" main:main 2>/dev/null || true
72
+ git -C "$R" fetch -q origin 2>/dev/null || true
73
+ BAD="$(printf '/%s/alice/secret' Users)"; echo "$BAD" > "$TD/repo-worktree--y/bad.txt"
74
+ git -C "$TD/repo-worktree--y" add bad.txt
75
+ git -C "$TD/repo-worktree--y" commit -qm bad >/dev/null 2>&1
76
+ if git -C "$TD/repo-worktree--y" push -q origin feat/y 2>/dev/null; then bad "敏感推送未被拦截"; else ok "敏感推送被拦截"; fi
77
+ git -C "$TD/repo-worktree--y" reset -q --hard HEAD~1 # 丢掉含敏感内容的提交
78
+ printf 'path=$TD/home/kits/dia.json\n' > "$TD/repo-worktree--y/vars.txt"
79
+ git -C "$TD/repo-worktree--y" add vars.txt && git -C "$TD/repo-worktree--y" commit -qm vars >/dev/null 2>&1
80
+ git -C "$TD/repo-worktree--y" push -q origin feat/y 2>/dev/null && ok "相对片段 home/ 不误报" || bad "相对片段被误判"
81
+
82
+ # adopt --all:只处理主 checkout,跳过 *-worktree--*
151
83
  AR="$TD/allroot"; mkdir -p "$AR/a" "$AR/b"
152
84
  for r in a b; do
153
85
  git -C "$AR/$r" init -q -b main
@@ -155,35 +87,27 @@ for r in a b; do
155
87
  echo x > "$AR/$r/x.txt"; git -C "$AR/$r" add -A; git -C "$AR/$r" commit -qm init
156
88
  done
157
89
  git -C "$AR/a" worktree add -q -b feat/w "$AR/a-worktree--w" main
158
- out="$(DIA_WT_REPOS_ROOT="$AR" DIA_WT_DATA_ROOT="$TD/dr2" bash "$WT" adopt --all --apply --json 2>&1)"
90
+ out="$(DIA_WT_REPOS_ROOT="$AR" bash "$WT" adopt --all --apply --json 2>&1)"
159
91
  printf '%s' "$out" | grep -q '"repos":2,"ok":2,"fail":0' && ok "adopt --all 覆盖 2 个主 checkout" || bad "adopt --all: $out"
160
- for r in a b; do
161
- [ -n "$(git -C "$AR/$r" config --get core.hooksPath || true)" ] && ok "adopt --all: $r 已配 hooksPath" || bad "adopt --all: $r 未配置"
162
- done
163
92
  [ ! -d "$AR/a-worktree--w/.git/dia" ] && ok "adopt --all 跳过 worktree 目录" || bad "adopt --all 误改 worktree"
164
- out="$(DIA_WT_REPOS_ROOT="$AR" DIA_WT_DATA_ROOT="$TD/dr4" bash "$WT" doctor --all --json 2>/dev/null)"
165
- printf '%s' "$out" | grep -q "$AR/a" && ok "doctor --all 覆盖 DIA_WT_REPOS_ROOT" || bad "doctor --all 未覆盖自定义仓根"
166
- DIA_WT_REPOS_ROOT="$TD/empty-root" DIA_WT_DATA_ROOT="$TD/dr3" bash "$WT" adopt --all >/dev/null 2>&1
93
+ out="$(DIA_WT_REPOS_ROOT="$AR" bash "$WT" doctor --all --json 2>/dev/null)"
94
+ printf '%s' "$out" | grep -q "$AR/a" && ok "doctor --all 覆盖自定义仓根" || bad "doctor --all 未覆盖"
95
+ DIA_WT_REPOS_ROOT="$TD/empty-root" bash "$WT" adopt --all >/dev/null 2>&1
167
96
  [ $? = 2 ] && ok "adopt --all 空结果 exit 2" || bad "adopt --all 空结果未 exit 2"
168
97
 
169
- # ── doctor --fix:钩子被覆盖后能无损重建 ────────────────────────────────────
170
- R8="$TD/repo8"; mkdir -p "$R8"; git -C "$R8" init -q -b main
171
- git -C "$R8" config user.email t@example.com; git -C "$R8" config user.name t
172
- echo a > "$R8/a.txt"; git -C "$R8" add -A; git -C "$R8" commit -qm init
173
- bash "$WT" adopt "$R8" --apply >/dev/null 2>&1
174
- rm -f "$R8/.git/dia/hooks/post-checkout"
175
- printf '#!/bin/sh\nexit 0\n' > "$R8/.git/dia/hooks/pre-commit"; chmod +x "$R8/.git/dia/hooks/pre-commit"
176
- if bash "$WT" doctor "$R8" >/dev/null 2>&1; then bad "钩子被覆盖后 doctor 仍通过"; else ok "钩子被覆盖后 doctor 报失败"; fi
177
- bash "$WT" doctor "$R8" --fix >/dev/null 2>&1 || bad "doctor --fix 失败"
178
- bash "$WT" doctor "$R8" >/dev/null 2>&1 && ok "doctor --fix 后恢复 ok" || bad "doctor --fix 后仍失败"
179
- [ -x "$R8/.git/dia/hooks/post-checkout" ] && ok "doctor --fix 补回 post-checkout" || bad "post-checkout 未补回"
98
+ # gate
99
+ rm -f "$TD/repo-worktree--y/chain.log"
100
+ if bash "$WT" gate "$R" y >/tmp/gate.log 2>&1; then ok "gate 成功"; else bad "gate 失败: $(tail -2 /tmp/gate.log | tr "\n" " ")"; fi
101
+ if bash "$WT" gate "$R" yb >/dev/null 2>&1; then bad "gate 子串误匹配"; else ok "gate 精确匹配 feature"; fi
102
+ echo dirty >> "$TD/repo-worktree--y/c.txt"
103
+ if bash "$WT" gate "$R" y >/dev/null 2>&1; then bad "worktree 脏时 gate 通过"; else ok "worktree 脏时 gate 失败"; fi
104
+ git -C "$TD/repo-worktree--y" checkout -q -- c.txt
180
105
 
181
- # ── gate:成功 / 失败(P1-3 精确匹配)────────────────────────────────────────
182
- bash "$WT" gate "$R5" x >/dev/null 2>&1 && ok "gate 成功" || bad "gate 失败"
183
- if bash "$WT" gate "$R5" xb >/dev/null 2>&1; then bad "gate 子串误匹配(x 命中 xb)"; else ok "gate 精确匹配 feature"; fi
184
- echo dirty >> "$TD/repo5-worktree--x/b.txt"
185
- if bash "$WT" gate "$R5" x >/dev/null 2>&1; then bad "worktree 脏时 gate 通过"; else ok "worktree 脏时 gate 失败"; fi
186
- git -C "$TD/repo5-worktree--x" checkout -q -- b.txt
106
+ # revert:撤本仓配置,保留全局钩子
107
+ bash "$WT" adopt "$R" --revert >/dev/null 2>&1 || bad "revert 失败"
108
+ [ ! -f "$R/.git/dia/config.json" ] && ok "revert 删除本仓配置" || bad "配置残留"
109
+ [ -x "$GH/pre-commit" ] && ok "revert 不影响全局钩子" || bad "revert 误删全局钩子"
110
+ git -C "$R" worktree remove --force "$TD/repo-worktree--y" >/dev/null 2>&1 || true
187
111
 
188
112
  echo; echo "── PASS $PASS · FAIL $FAIL ──"
189
113
  [ "$FAIL" = 0 ]