@minonl/dia 0.0.9 → 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.
package/README.md CHANGED
@@ -90,6 +90,18 @@ bash tests/smoke.sh # 用 Node 跑一遍构建产物
90
90
  bun src/cli.ts version # 直接跑源码
91
91
  ```
92
92
 
93
+ ### CI 矩阵(`.github/workflows/test.yml`)
94
+
95
+ | job | 触发 | 是否 required | 说明 |
96
+ |---|---|---|---|
97
+ | `test (ubuntu-latest)` | PR | ✅ | 全量:单测 / kb / job / build / install / smoke / e2e |
98
+ | `test (macos-latest)` | PR | ✅ | 同上(**macOS 特有差异只有这里能抓到**,例如没有 `timeout(1)`) |
99
+ | `windows-smoke` | PR(代码改动) | ❌ 非阻塞 | 装依赖 + `dia --version` + 纯逻辑测试;`dia job` 的 Windows 后端尚未实现(`install` 返回 rc=3),落地后再升 required |
100
+ | `changes` | PR | ❌ | 判定改动是否只碰 `docs/**` / `llms*.txt`:是则跳过测试矩阵(docs-only 快通道) |
101
+
102
+ > 只在 `pull_request` 上跑(不再对特性分支的 push 重复跑)。
103
+ > 改这里时**保持 `test (…)` 的 job 名不变**——分支保护按名字要求这两个 check。
104
+
93
105
  ## 命令
94
106
 
95
107
  ```bash
package/dist/cli.js CHANGED
@@ -6100,7 +6100,7 @@ function askBroker(req, timeoutMs = 5000) {
6100
6100
  }
6101
6101
 
6102
6102
  // packages/cli/src/index.ts
6103
- import { execFileSync as execFileSync12, spawn as spawn2, spawnSync as spawnSync5 } from "node:child_process";
6103
+ import { execFileSync as execFileSync12, spawn as spawn3, spawnSync as spawnSync5 } from "node:child_process";
6104
6104
  import { mkdirSync as mkdirSync20, openSync as openSync3 } from "node:fs";
6105
6105
  import { basename as basename10, join as join32 } from "node:path";
6106
6106
  import { existsSync as existsSync29, readFileSync as readFileSync26 } from "node:fs";
@@ -7096,7 +7096,7 @@ function propose(opts = {}) {
7096
7096
  }
7097
7097
 
7098
7098
  // src/service.ts
7099
- import { spawnSync as spawnSync4 } from "node:child_process";
7099
+ import { spawn as spawn2, spawnSync as spawnSync4 } from "node:child_process";
7100
7100
  import { createHash as createHash11, randomBytes as randomBytes4 } from "node:crypto";
7101
7101
  import {
7102
7102
  appendFileSync as appendFileSync9,
@@ -7121,6 +7121,52 @@ var ID_RE2 = /^[a-z0-9][a-z0-9-]*$/;
7121
7121
  var VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
7122
7122
  var LIFECYCLE_TYPES = new Set(["periodic", "bounded", "service"]);
7123
7123
  var STDIN_TYPES = new Set(["json", "text", "none"]);
7124
+ var ACP_HOSTS = ["dsh", "opencode"];
7125
+ var HEALTHCHECK_KINDS = new Set(["script", "acp"]);
7126
+ var isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
7127
+ var isNonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
7128
+ function acpAgentCommand(agent) {
7129
+ if (!ACP_HOSTS.includes(agent?.host))
7130
+ throw new Error(`E_MANIFEST: entry.agent.host must be ${ACP_HOSTS.join("|")}`);
7131
+ const profile = agent.profile?.trim();
7132
+ if (agent.host === "dsh")
7133
+ return ["dsh", ...profile ? ["--profile", profile] : [], "acp"];
7134
+ return ["opencode", "acp"];
7135
+ }
7136
+ var isAgentEntry = (e) => !!e && e.agent !== undefined && e.agent !== null;
7137
+ var healthcheckKind = (hc) => hc?.kind ?? "script";
7138
+ var entryCmdOf = (svc) => svc.entry.cmd ?? "";
7139
+ function checkAgentEntry(a) {
7140
+ if (!isPlainObject(a))
7141
+ throw new Error("E_MANIFEST: entry.agent must be an object");
7142
+ if (!ACP_HOSTS.includes(a.host))
7143
+ throw new Error(`E_MANIFEST: entry.agent.host must be ${ACP_HOSTS.join("|")}`);
7144
+ for (const k of ["profile", "persona"]) {
7145
+ if (a[k] !== undefined && !isNonEmptyString(a[k]))
7146
+ throw new Error(`E_MANIFEST: entry.agent.${k} must be a non-empty string`);
7147
+ }
7148
+ if (a.host !== "dsh" && a.profile !== undefined) {
7149
+ throw new Error(`E_MANIFEST: entry.agent.profile 只适用于 host=dsh(${a.host} 没有 profile)`);
7150
+ }
7151
+ return a;
7152
+ }
7153
+ function checkEntry(entry) {
7154
+ if (!isPlainObject(entry))
7155
+ throw new Error("E_MANIFEST: entry required");
7156
+ const hasCmd = entry.cmd !== undefined && entry.cmd !== null;
7157
+ const hasAgent = entry.agent !== undefined && entry.agent !== null;
7158
+ if (hasCmd && hasAgent)
7159
+ throw new Error("E_MANIFEST: entry.cmd 与 entry.agent 互斥,只能声明其一");
7160
+ if (!hasCmd && !hasAgent)
7161
+ throw new Error("E_MANIFEST: entry.cmd required(脚本型)或 entry.agent(agent 型)");
7162
+ if (hasCmd) {
7163
+ if (typeof entry.cmd !== "string" || !entry.cmd.trim())
7164
+ throw new Error("E_MANIFEST: entry.cmd required");
7165
+ } else {
7166
+ checkAgentEntry(entry.agent);
7167
+ }
7168
+ return entry;
7169
+ }
7124
7170
  function entryDir() {
7125
7171
  return process.env.DIA_JOB_ENTRY_DIR || join31(jobRoot2(), "bin");
7126
7172
  }
@@ -7163,8 +7209,14 @@ var LEVELS = ["ok", "degraded", "fail"];
7163
7209
  function checkHealthcheck(hc) {
7164
7210
  if (hc === null || typeof hc !== "object" || Array.isArray(hc))
7165
7211
  throw new Error("E_MANIFEST: healthcheck must be an object");
7166
- if (typeof hc.cmd !== "string" || !hc.cmd.trim())
7167
- throw new Error("E_MANIFEST: healthcheck.cmd required");
7212
+ if (hc.kind !== undefined && !HEALTHCHECK_KINDS.has(hc.kind))
7213
+ throw new Error("E_MANIFEST: healthcheck.kind must be script|acp");
7214
+ if (healthcheckKind(hc) === "script") {
7215
+ if (typeof hc.cmd !== "string" || !hc.cmd.trim())
7216
+ throw new Error("E_MANIFEST: healthcheck.cmd required");
7217
+ } else if (hc.cmd !== undefined) {
7218
+ throw new Error("E_MANIFEST: healthcheck.kind=acp 不得再声明 cmd(判据是 initialize + session/new,不是外部脚本)");
7219
+ }
7168
7220
  if (hc.interval !== undefined) {
7169
7221
  const why = checkCronInterval(hc.interval);
7170
7222
  if (why)
@@ -7199,10 +7251,14 @@ function validateServiceManifest(m) {
7199
7251
  throw new Error("E_MANIFEST: bad id");
7200
7252
  if (!m.version || !VERSION_RE.test(m.version))
7201
7253
  throw new Error("E_MANIFEST: bad version");
7202
- if (m.entry === null || typeof m.entry !== "object" || Array.isArray(m.entry))
7203
- throw new Error("E_MANIFEST: entry required");
7204
- if (typeof m.entry.cmd !== "string" || !m.entry.cmd.trim())
7205
- throw new Error("E_MANIFEST: entry.cmd required");
7254
+ checkEntry(m.entry);
7255
+ const agentEntry = isAgentEntry(m.entry);
7256
+ if (agentEntry && healthcheckKind(m.healthcheck) !== "acp") {
7257
+ throw new Error('E_MANIFEST: entry.agent 必须配 healthcheck.kind="acp"(脚本型 healthcheck 无 cmd 可执行)');
7258
+ }
7259
+ if (!agentEntry && m.healthcheck && healthcheckKind(m.healthcheck) === "acp") {
7260
+ throw new Error('E_MANIFEST: healthcheck.kind="acp" 只适用于 entry.agent 型服务');
7261
+ }
7206
7262
  if (m.lifecycle === null || typeof m.lifecycle !== "object" || Array.isArray(m.lifecycle))
7207
7263
  throw new Error("E_MANIFEST: lifecycle required");
7208
7264
  if (!LIFECYCLE_TYPES.has(m.lifecycle.type))
@@ -7804,6 +7860,134 @@ function runHealth(svc, opts) {
7804
7860
  }
7805
7861
  return { judgement, report, stdout, stderr, ms };
7806
7862
  }
7863
+ async function runAcpHealth(svc, opts) {
7864
+ const hc = svc.healthcheck;
7865
+ if (!hc || healthcheckKind(hc) !== "acp")
7866
+ throw new Error("E_MANIFEST: runAcpHealth 只处理 healthcheck.kind=acp");
7867
+ const agent = svc.entry.agent;
7868
+ if (!agent)
7869
+ throw new Error("E_MANIFEST: healthcheck.kind=acp 需要 entry.agent");
7870
+ const timeoutMs = (hc.timeout ?? DEFAULT_HEALTH_TIMEOUT) * 1000;
7871
+ const now = opts.now ?? Date.now;
7872
+ const t0 = now();
7873
+ const argv = acpAgentCommand(agent);
7874
+ const env = { ...process.env, ...opts.env ?? {} };
7875
+ const steps = [];
7876
+ const finish = (status, exit) => {
7877
+ const judgement = {
7878
+ ...judgeHealth(exit, {}),
7879
+ state: status,
7880
+ exit,
7881
+ timeout: false,
7882
+ detail: steps.filter((x) => x.state !== "ok").map((x) => `${x.step}: ${x.detail}`).join("; ") || `initialize + session/new ok(${agent.host}${agent.profile ? " --profile " + agent.profile : ""})`
7883
+ };
7884
+ return { judgement, report: { status, checks: steps.map((x) => ({ name: x.step, status: x.state, detail: x.detail })) }, ms: now() - t0, steps };
7885
+ };
7886
+ const child = spawn2(argv[0], argv.slice(1), { cwd: opts.dir, env, stdio: ["pipe", "pipe", "pipe"] });
7887
+ const pending = new Map;
7888
+ let buf = "";
7889
+ child.stdout.setEncoding("utf8");
7890
+ child.stdout.on("data", (chunk) => {
7891
+ buf += chunk;
7892
+ for (let i = buf.indexOf(`
7893
+ `);i >= 0; i = buf.indexOf(`
7894
+ `)) {
7895
+ const line = buf.slice(0, i).trim();
7896
+ buf = buf.slice(i + 1);
7897
+ if (!line)
7898
+ continue;
7899
+ let msg;
7900
+ try {
7901
+ msg = JSON.parse(line);
7902
+ } catch {
7903
+ continue;
7904
+ }
7905
+ const waiter = msg && msg.id !== undefined ? pending.get(msg.id) : undefined;
7906
+ if (waiter) {
7907
+ pending.delete(msg.id);
7908
+ waiter(msg);
7909
+ }
7910
+ }
7911
+ });
7912
+ const spawnError = await new Promise((resolve) => {
7913
+ let settled = false;
7914
+ const done = (v) => {
7915
+ if (!settled) {
7916
+ settled = true;
7917
+ resolve(v);
7918
+ }
7919
+ };
7920
+ child.once("error", (e) => done(String(e?.message || e)));
7921
+ child.once("spawn", () => done(null));
7922
+ setTimeout(() => done(null), 50);
7923
+ });
7924
+ if (spawnError)
7925
+ return steps.push({ step: "initialize", state: "degraded", detail: `端点起不来:${spawnError}` }), finish("degraded", 20);
7926
+ const call = (id, method, params) => new Promise((resolve) => {
7927
+ const timer = setTimeout(() => {
7928
+ pending.delete(id);
7929
+ resolve({ timeout: true });
7930
+ }, timeoutMs);
7931
+ pending.set(id, (m) => {
7932
+ clearTimeout(timer);
7933
+ resolve(m);
7934
+ });
7935
+ try {
7936
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + `
7937
+ `);
7938
+ } catch (e) {
7939
+ clearTimeout(timer);
7940
+ pending.delete(id);
7941
+ resolve({ died: String(e?.message || e) });
7942
+ }
7943
+ });
7944
+ try {
7945
+ const init = await call(1, "initialize", { protocolVersion: 1, clientCapabilities: {} });
7946
+ if (init.timeout)
7947
+ steps.push({ step: "initialize", state: "degraded", detail: "initialize 超时" });
7948
+ else if (init.died)
7949
+ steps.push({ step: "initialize", state: "degraded", detail: `端点已退出:${init.died}` });
7950
+ else if (init.error)
7951
+ steps.push({ step: "initialize", state: "degraded", detail: `initialize 报错:${init.error?.message ?? JSON.stringify(init.error)}` });
7952
+ else if (!init.result)
7953
+ steps.push({ step: "initialize", state: "degraded", detail: "initialize 无 result" });
7954
+ else
7955
+ steps.push({ step: "initialize", state: "ok", detail: "initialize ok" });
7956
+ if (steps[0].state !== "ok")
7957
+ return finish("degraded", 20);
7958
+ const ns = await call(2, "session/new", { cwd: opts.dir, mcpServers: [] });
7959
+ if (ns.timeout)
7960
+ steps.push({ step: "session/new", state: "fail", detail: "session/new 超时" });
7961
+ else if (ns.error)
7962
+ steps.push({ step: "session/new", state: "fail", detail: `session/new 报错:${ns.error?.message ?? JSON.stringify(ns.error)}` });
7963
+ else if (!ns.result?.sessionId)
7964
+ steps.push({ step: "session/new", state: "fail", detail: "session/new 未返回 sessionId" });
7965
+ else
7966
+ steps.push({ step: "session/new", state: "ok", detail: `session ${ns.result.sessionId}` });
7967
+ return steps[1].state === "ok" ? finish("ok", 0) : finish("fail", 21);
7968
+ } finally {
7969
+ try {
7970
+ child.kill("SIGKILL");
7971
+ } catch {}
7972
+ }
7973
+ }
7974
+ function recordHealth(svc, judgement, opts = {}) {
7975
+ const id = svc.id;
7976
+ const gen = opts.gen || generationOf(id);
7977
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
7978
+ const file = opts.stateFile || process.env.DIA_SERVICE_HEALTH_FILE || healthStatePath(id);
7979
+ const record = transition(findLastHealth(readHealthLines(file), id, gen), {
7980
+ service: id,
7981
+ gen,
7982
+ source: opts.source ?? "manual",
7983
+ ts: now,
7984
+ judgement,
7985
+ debounce: svc.healthcheck?.debounce,
7986
+ checks: opts.checks
7987
+ });
7988
+ appendHealthRecord(file, record);
7989
+ return { record, statePath: file };
7990
+ }
7807
7991
  function whichTimeout1() {
7808
7992
  const probe = spawnSync4("bash", ["-c", "command -v timeout || command -v gtimeout"], { encoding: "utf8" });
7809
7993
  const p = String(probe.stdout ?? "").trim().split(`
@@ -7862,7 +8046,7 @@ function collectManagedFiles(base, hc) {
7862
8046
  }
7863
8047
  function verify4(dir, opts = {}) {
7864
8048
  const svc = readServiceManifest(dir);
7865
- const entry = resolveEntry(svc.entry.cmd, dir);
8049
+ const entry = resolveEntry(entryCmdOf(svc), dir);
7866
8050
  const drift = [];
7867
8051
  if (!existsSync28(entry.path))
7868
8052
  drift.push({ code: "entry_missing", detail: `entry 不存在:${entry.path}` });
@@ -7965,7 +8149,7 @@ function add2(opts) {
7965
8149
  }
7966
8150
  if (svc.healthcheck)
7967
8151
  healthJobInterval(svc);
7968
- const entry = resolveEntry(svc.entry.cmd, dir);
8152
+ const entry = resolveEntry(entryCmdOf(svc), dir);
7969
8153
  if (!existsSync28(entry.path))
7970
8154
  throw new Error(`E_UNSTABLE_ENTRY: entry 不存在:${entry.path}`);
7971
8155
  if (!isExecutableFile(entry.path))
@@ -7979,7 +8163,7 @@ function add2(opts) {
7979
8163
  gen,
7980
8164
  dryRun: !!opts.dryRun,
7981
8165
  backend,
7982
- entry: { declared: svc.entry.cmd, kind: entry.kind, path: entry.path, link: null },
8166
+ entry: { declared: entryCmdOf(svc), kind: entry.kind, path: entry.path, link: null },
7983
8167
  jobs: { service: serviceJobName(svc.id), health: svc.healthcheck ? healthJobName(svc.id) : null },
7984
8168
  target_mismatch: mismatch
7985
8169
  };
@@ -7988,7 +8172,7 @@ function add2(opts) {
7988
8172
  rmSync9(dir, { recursive: true, force: true });
7989
8173
  return result;
7990
8174
  }
7991
- const backfill = ["--allow-unstable", "--desc", `dia service ${svc.id}: healthcheck 只读判定,稳定入口 ${entryPathOf(svc.entry.cmd, dir)}`];
8175
+ const backfill = ["--allow-unstable", "--desc", `dia service ${svc.id}: healthcheck 只读判定,稳定入口 ${entryPathOf(entryCmdOf(svc), dir)}`];
7992
8176
  const runner = opts.runner ?? defaultRunner({ backfill });
7993
8177
  const installed = [];
7994
8178
  const fail = (code, msg, rc) => {
@@ -8005,7 +8189,7 @@ function add2(opts) {
8005
8189
  entryPath = linkEntry(entry.path);
8006
8190
  result.entry.link = entryPath;
8007
8191
  result.entry.path = entryPath;
8008
- } else if (svc.entry.cmd.startsWith(entryDir())) {
8192
+ } else if (entryCmdOf(svc).startsWith(entryDir())) {
8009
8193
  result.entry.link = entry.path;
8010
8194
  }
8011
8195
  const jobArgs = (name, cmd) => {
@@ -8322,7 +8506,7 @@ usage:
8322
8506
  dia collab status|enable|disable [--persona ID] # dia-msg 默认能力开关(默认开)
8323
8507
  dia whoami [--json] [--name NAME] # 当前会话身份(session id)
8324
8508
  dia in [--unread] [--all] [--legacy] [--json] # 会话收件箱(正文内联)
8325
- dia out --to <session_id> --subject S (--body B|--body-file F) [--kind K] [--reply-to ID]
8509
+ dia out --to <session_id> --subject S (--body B|--body-file F) [--kind K] [--touch p1,p2] [--reply-to ID]
8326
8510
  dia ack <id> | dia reply <id> --body B | dia close <id> --reason R | dia show <id>
8327
8511
  dia msg send|show|inbox|ack|delivered|deliver [--wait]|reply|needs-info|status|stuck|thread|escalate|agents|migrate
8328
8512
  dia agents list|register|unregister|discover
@@ -8820,7 +9004,7 @@ function spawnDetachedDelivery(args) {
8820
9004
  const log = join32(dir, `deliver-${id}.log`);
8821
9005
  const fd = openSync3(log, "a");
8822
9006
  const bin = process.argv[1];
8823
- const child = spawn2(process.execPath, [bin, "msg", "deliver", ...args, "--wait"], {
9007
+ const child = spawn3(process.execPath, [bin, "msg", "deliver", ...args, "--wait"], {
8824
9008
  detached: true,
8825
9009
  stdio: ["ignore", fd, fd],
8826
9010
  env: process.env
@@ -9110,6 +9294,13 @@ async function collabCli(argv) {
9110
9294
  i++;
9111
9295
  }
9112
9296
  }
9297
+ const touchPaths = [...flags(args, "--touch"), ...(flag(args, "--touches") || "").split(",")].map((x) => x.trim()).filter(Boolean);
9298
+ if (touchPaths.length)
9299
+ refs.touches = touchPaths.join(",");
9300
+ const refPaths = (m) => [
9301
+ ...(m.refs?.touches || "").split(","),
9302
+ ...m.refs?.path ? [m.refs.path] : []
9303
+ ].map((x) => x.trim()).filter(Boolean);
9113
9304
  const view = (m) => ({ ...m, body: bodyOf(m) });
9114
9305
  const print = (m) => {
9115
9306
  out(`${m.id} [${m.state}] ${m.subject}`);
@@ -9118,6 +9309,12 @@ async function collabCli(argv) {
9118
9309
  out(` kind: ${m.kind}${m.requires_reply ? " 需回复" : ""}${m.deadline ? ` deadline=${m.deadline}` : ""} thread=${m.thread_id}#${m.seq}`);
9119
9310
  if (m.refs && Object.keys(m.refs).length)
9120
9311
  out(` refs: ${Object.entries(m.refs).map(([k, v]) => `${k}=${v}`).join(" ")}`);
9312
+ const touches = refPaths(m);
9313
+ if (touches.length) {
9314
+ const held = list3({}).filter((c) => touches.includes(c.path) && c.holder !== m.to.session_id);
9315
+ for (const c of held)
9316
+ out(` ⚠ claim: ${c.path} 由 ${c.holder} 持有(${c.reason || "no reason"})`);
9317
+ }
9121
9318
  const body = bodyOf(m);
9122
9319
  if (body) {
9123
9320
  const max = 4000;
@@ -9220,7 +9417,14 @@ async function collabCli(argv) {
9220
9417
  idempotencyKey: flag(args, "--idempotency-key"),
9221
9418
  hops: flag(args, "--hops") ? Number(flag(args, "--hops")) : undefined
9222
9419
  });
9223
- out(asJson ? JSON.stringify(m, null, 1) : `${m.id} ${m.thread_id}#${m.seq} ${m.from.session_id} → ${m.to.session_id} ${m.kind} ${m.subject}`);
9420
+ const overlaps = list3({}).filter((c) => touchPaths.includes(c.path) && c.holder !== m.from.session_id);
9421
+ if (asJson)
9422
+ out(JSON.stringify(overlaps.length ? { ...m, claim_overlaps: overlaps } : m, null, 1));
9423
+ else {
9424
+ out(`${m.id} ${m.thread_id}#${m.seq} ${m.from.session_id} → ${m.to.session_id} ${m.kind} ${m.subject}`);
9425
+ for (const c of overlaps)
9426
+ out(` ⚠ claim overlap: ${c.path} 由 ${c.holder} 持有(${c.reason || "no reason"})`);
9427
+ }
9224
9428
  return 0;
9225
9429
  }
9226
9430
  if (sub === "ack") {
@@ -9814,6 +10018,18 @@ async function serviceCli(argv) {
9814
10018
  }
9815
10019
  const fromWrapper = has(args, "--from-wrapper");
9816
10020
  const ex = flag(args, "--exit");
10021
+ const dir = serviceDir(id);
10022
+ const svc = readServiceManifest(dir);
10023
+ if (healthcheckKind(svc.healthcheck) === "acp" && !fromWrapper) {
10024
+ const run = await runAcpHealth(svc, { dir });
10025
+ const rec = recordHealth(svc, run.judgement, { gen: flag(args, "--gen"), stateFile: flag(args, "--state-file") });
10026
+ const code = run.judgement.state === "ok" ? 0 : run.judgement.state === "degraded" ? 30 : 1;
10027
+ if (asJson)
10028
+ out(JSON.stringify({ id, verdict: run.judgement.state, shown: rec.record.state, exit: run.judgement.exit, detail: run.judgement.detail, steps: run.steps }, null, 1));
10029
+ else
10030
+ out(`${id}: ${run.judgement.state}(shown=${rec.record.state}${run.judgement.detail ? ", " + run.judgement.detail : ""})`);
10031
+ return code;
10032
+ }
9817
10033
  const r = runServiceHealth(id, {
9818
10034
  wrapperExit: fromWrapper ? Number(ex ?? "1") : undefined,
9819
10035
  gen: flag(args, "--gen"),
package/docs/index.md CHANGED
@@ -31,7 +31,7 @@ Agent 入口:[`llms.txt`](https://github.com/minonl/dia/blob/main/llms.txt)(
31
31
  | [价目表(tokens → 金额)](pricing.md) | 原则:没有价格就不编造;unpriced 明确记录,预算按 tokens 兜底。 |
32
32
  | [目标提案(team next)](proposals.md) | 定位:团队可以提候选,不能定目标。 |
33
33
  | [Sandbox 细化设计(dia)](security-sandbox.md) | 依据:dsh 本机源码实测、Hermes/Pi 取证、[/tmp/dia-sbx 实验]。 |
34
- | [服务(service):可托管的执行体](service-noun.md) | 状态:提案(设计,未实现)。只做脚本型服务;healthcheck 按专用退出码域判定,旧结果超新鲜度窗口即报 stale。 |
34
+ | [服务(service):可托管的执行体](service-noun.md) | 状态:已实现(v0.0.10,脚本型 + agent 型)。脚本型走 entry.cmd,agent 型走 entry.agent + healthcheck.kind="acp"; |
35
35
  | [dia 状态页(单一事实源)](status.md) | 任何"现在做到哪了"的问题,以本页为准;其他文档是细节。 |
36
36
  | [全自动团队:人只在验收介入](team-automation.md) | 目标:计划→实现→验证→对抗→修复全自动循环;人只在验收阶段拍板。 |
37
37
  | [团队章程:谁来做 dia(开发团队)](team.md) | 2026-10-28 升级:开发团队执行模式改为全自动循环(计划→实现→验证→对抗→修复),人只在验收阶段介入; |
@@ -78,3 +78,18 @@ dia graph --tui --no-color # 关 ANSI 颜色
78
78
  - 按状态分组显示 task → deps / touches / blocked_by;claims 单列;
79
79
  - 不启 HTTP/daemon,遵守框架“无网络模块”约束;
80
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
 
@@ -355,7 +360,8 @@ unknown(stale)**不单独返回**——`health` 是实时执行,不存在
355
360
 
356
361
  ## 14. P2:agent 型服务(entry = persona 会话)
357
362
 
358
- > 状态:**草案(待实现)**。§0 已定「本版只做脚本型」;本节把 P2 的形状先定下来,避免实现时二次发明语义。
363
+ > 状态:**已实现(PR #60)**。本节定义脚本型之外的 agent 型入口(`entry.agent` + `healthcheck.kind="acp"`);
364
+ > 实现与本节一致,差异见 §15。
359
365
 
360
366
  ### 14.1 与脚本型的关系
361
367
 
@@ -428,3 +434,31 @@ flowchart LR
428
434
  - [ ] 单测:`runAcpHealth` 用 `tests/fixtures/fake-acp.mjs` 覆盖 `initialize` 失败 / `session/new` 失败 / 正常三态
429
435
  - [ ] 单测:`healthcheck.kind` 缺省时按脚本型(向后兼容)
430
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.9",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "description": "dia (嗲) — lightweight agent framework: knowledge index, audit, session ingest",
6
6
  "bin": {
@@ -1263,15 +1263,32 @@ exit 0
1263
1263
  HOOKBODY
1264
1264
  }
1265
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
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"
1282
+ if [ -n "$chain" ]; then
1283
+ python3 - "$hooksdir/$name" "$chain" <<'PYCHAIN'
1284
+ import sys
1285
+ p, chain = sys.argv[1], sys.argv[2]
1286
+ s = open(p).read()
1287
+ s = s.replace('orig="$common/hooks/$name"', 'orig="%s/$name"' % chain.replace('\\', '\\\\').replace('"', '\\"'))
1288
+ open(p, "w").write(s)
1289
+ PYCHAIN
1290
+ fi
1291
+ chmod +x "$hooksdir/$name"
1275
1292
  }
1276
1293
 
1277
1294
  cmd_guard() {
@@ -1329,11 +1346,13 @@ cmd_adopt() {
1329
1346
 
1330
1347
  if [ "$mode" = "revert" ]; then
1331
1348
  [ -f "$cfg" ] || die "未 adopt(缺 $cfg)"
1332
- local saved
1349
+ local saved mgr
1333
1350
  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"
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
1337
1356
  fi
1338
1357
  rm -rf "$cfgdir"
1339
1358
  echo "reverted:$cfgdir"
@@ -1348,16 +1367,29 @@ cmd_adopt() {
1348
1367
  local cfgDef; cfgDef="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("defaultBranch") or "")' "$cfg" 2>/dev/null || true)"
1349
1368
  [ -n "$cfgDef" ] && def="$cfgDef"
1350
1369
  fi
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; }
1355
- for h in pre-commit pre-push post-checkout; do
1356
- [ -x "$ghd/$h" ] || { echo "FAIL D02 缺全局钩子 $h"; bad=1; }
1357
- done
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; }
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
1390
+ ( cd "$repo" && DIA_WT_GUARD_PROBE=1 "$hooksdir/pre-commit" >/dev/null 2>&1 ) \
1391
+ && { echo "FAIL D03 守卫探针未拦截 $def 提交"; bad=1; }
1392
+ fi
1361
1393
  fi
1362
1394
  if [ "$json" = 1 ]; then
1363
1395
  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"
@@ -1383,34 +1415,78 @@ cmd_adopt() {
1383
1415
  [ -n "$def" ] || die "默认分支不可解析;请 --default-branch main"
1384
1416
  mkdir -p "$hooksdir"
1385
1417
  # 安装/刷新本机全局钩子,并让本仓走全局钩子(不改 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
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
1402
1451
  fi
1403
- rm -rf "$hooksdir" 2>/dev/null || true
1404
- python3 - "$cfg" "$repo" "$def" "$template" "$prev" <<'PYG'
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'
1405
1457
  import json, sys, datetime
1406
1458
  cfg, repo, br, tmpl, prev = sys.argv[1:6]
1407
1459
  json.dump({
1408
1460
  "schema": 2, "repo": repo, "defaultBranch": br, "template": tmpl,
1409
- "hooksManager": "global", "hooksPrevious": prev or None,
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,
1410
1486
  "adoptedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
1411
1487
  }, open(cfg, "w"), ensure_ascii=False, indent=1)
1412
1488
  PYG
1413
- echo "adopted:$repo(守卫 = 本机全局钩子 $(wt_global_hooks_dir))"
1489
+ echo "adopted:$repo(守卫 = 仓库本地钩子 $hooksdir)"
1414
1490
  return 0
1415
1491
  # 保留首次 adopt 记下的 hooksPrevious(重复 apply 时 prev 已是我们自己的 hooksdir)
1416
1492
  local savedPrev=""
@@ -1467,13 +1543,17 @@ cmd_doctor() {
1467
1543
  fi
1468
1544
  [ -n "$def" ] || def="$(wt_default_branch "$repo")"
1469
1545
  # --fix:无损修复(重写三个守卫钩子 + hooksPath 绝对路径)。只对已 adopt 的仓生效。
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
- :
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 已重建"
1477
1557
  elif [ "$fix" = 1 ] && [ -f "$cfg" ]; then
1478
1558
  local fixChain=""
1479
1559
  fixChain="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksChain") or "")' "$cfg" 2>/dev/null || true)"
@@ -1489,25 +1569,31 @@ cmd_doctor() {
1489
1569
  local bad=0 mcfg=""
1490
1570
  [ -f "$cfg" ] || { echo "FAIL D01 $repo 未 adopt"; bad=1; }
1491
1571
  [ -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)"
1572
+ local ghp mgr2
1494
1573
  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; }
1497
- for h in pre-commit pre-push post-checkout; do
1498
- [ -x "$ghd/$h" ] || { echo "FAIL D02 缺全局钩子 $h"; bad=1; }
1499
- done
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 覆盖全局钩子,本仓守卫不生效"
1506
- fi
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
1507
1578
  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; }
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
1594
+ ( cd "$repo" && DIA_WT_GUARD_PROBE=1 "$hooksdir/pre-commit" >/dev/null 2>&1 ) \
1595
+ && { echo "FAIL D03 $repo 守卫未拦截 $def 提交"; bad=1; }
1596
+ fi
1511
1597
  fi
1512
1598
  if wt_is_main_checkout "$repo"; then
1513
1599
  [ -z "$(git -C "$repo" status --porcelain 2>/dev/null)" ] || echo "WARN D04 $repo 主 checkout 有未提交改动"
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # adopt / doctor / gate 回归测试(2026-11-06:本机全局钩子模型)
3
- # 覆盖:dry-run、apply(全局钩子)、check/doctor、主 checkout 守卫、链式调用仓库钩子、
4
- # 非 adopt 仓透传、敏感扫描、adopt --all、gate、revert。
2
+ # adopt / doctor / gate 回归测试(2026-11-06:每仓本地钩子 + simple-git-hooks RC)
5
3
  set -uo pipefail
6
4
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
5
  WT="$HERE/../bin/dia-wt"
@@ -9,29 +7,29 @@ TD="$(mktemp -d)"; trap 'rm -rf "$TD"' EXIT
9
7
  PASS=0; FAIL=0
10
8
  ok(){ echo " ✓ $1"; PASS=$((PASS+1)); }; bad(){ echo " ✗ $1"; FAIL=$((FAIL+1)); }
11
9
 
12
- # 隔离:HOME / 全局 git 配置 / 数据根,避免污染真实机器
10
+ # 隔离 HOME / 全局 git 配置 / 数据根 / shell rc(避免污染真实机器)
13
11
  export HOME="$TD/home"; mkdir -p "$HOME"
14
12
  export GIT_CONFIG_GLOBAL="$TD/gitconfig"; : > "$GIT_CONFIG_GLOBAL"
15
13
  export GIT_CONFIG_NOSYSTEM=1
16
- export DIA_WT_DATA_ROOT="$TD/data"
17
- GH="$TD/data/hooks"
14
+ export DIA_ROOT="$TD/dia"
15
+ export DIA_WT_SHELL_RC="$HOME/.zshenv"
18
16
 
19
- R="$TD/repo"; mkdir -p "$R"; git -C "$R" init -q -b main
20
- git -C "$R" config user.email t@example.com; git -C "$R" config user.name t
21
- echo a > "$R/a.txt"; git -C "$R" add -A; git -C "$R" commit -qm init
17
+ mk_repo(){ local r="$1"; mkdir -p "$r"; git -C "$r" init -q -b main; git -C "$r" config user.email t@example.com; git -C "$r" config user.name t; echo a > "$r/a.txt"; git -C "$r" add -A; git -C "$r" commit -qm init; }
22
18
 
19
+ R="$TD/repo"; mk_repo "$R"
23
20
  out="$(bash "$WT" adopt "$R" --json)"
24
21
  printf '%s' "$out" | grep -q '"defaultBranch":"main"' && ok "dry-run 识别默认分支" || bad "dry-run: $out"
25
22
  [ ! -f "$R/.git/dia/config.json" ] && ok "dry-run 不落盘" || bad "dry-run 落盘了"
26
23
 
27
24
  bash "$WT" adopt "$R" --apply >/dev/null 2>&1 || bad "apply 失败"
28
25
  [ -f "$R/.git/dia/config.json" ] && ok "config 已写" || bad "缺 config"
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 未记录"
26
+ hp="$(git -C "$R" config --local --get core.hooksPath)"
27
+ case "$hp" in */repo/.git/dia/hooks) ok "仓库本地 hooksPath 指向 dia 目录" ;; *) bad "hooksPath=$hp" ;; esac
28
+ for h in pre-commit pre-push post-checkout; do [ -x "$R/.git/dia/hooks/$h" ] || bad "缺 $h"; done
29
+ ok "三个钩子可执行"
30
+ python3 -c 'import json,sys;d=json.load(open(sys.argv[1]));sys.exit(0 if d.get("hooksManager")=="local" else 1)' "$R/.git/dia/config.json" \
31
+ && ok "config 记录 hooksManager=local" || bad "hooksManager 未记录"
32
+ [ -z "$(git config --global --get core.hooksPath 2>/dev/null || true)" ] && ok "未写全局 git 配置" || bad "仍写了全局 hooksPath"
35
33
 
36
34
  bash "$WT" adopt "$R" --apply >/dev/null 2>&1 && ok "重复 apply 幂等" || bad "重复 apply 失败"
37
35
  bash "$WT" adopt "$R" --check >/dev/null 2>&1 && ok "check 通过" || bad "check 失败"
@@ -41,51 +39,55 @@ echo b > "$R/b.txt"; git -C "$R" add b.txt
41
39
  if git -C "$R" commit -qm should-block 2>/dev/null; then bad "主 checkout main 提交未被拦截"; else ok "主 checkout main 提交被拦截"; fi
42
40
  git -C "$R" reset -q
43
41
 
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"
42
+ # 链式:仓库自己的 .git/hooks/pre-commit 仍要执行
43
+ printf '#!/bin/sh\necho chained >> "$(git rev-parse --show-toplevel)/chain.log"\n' > "$R/.git/hooks/pre-commit"; chmod +x "$R/.git/hooks/pre-commit"
47
44
  git -C "$R" worktree add -q -b feat/y "$TD/repo-worktree--y" main
48
45
  echo c > "$TD/repo-worktree--y/c.txt"; git -C "$TD/repo-worktree--y" add c.txt
49
46
  git -C "$TD/repo-worktree--y" commit -qm feat-y >/dev/null 2>&1
50
47
  [ -f "$TD/repo-worktree--y/chain.log" ] && ok "仓库自己的钩子被链式执行" || bad "仓库钩子未执行"
51
48
  rm -f "$TD/repo-worktree--y/chain.log"
52
49
 
53
- # 本地 hooksPath(husky/.githooks)优先于全局 → 守卫不生效,但 adopt 不得破坏它
54
- R2="$TD/repo2"; mkdir -p "$R2/.githooks"; git -C "$R2" init -q -b main
55
- git -C "$R2" config user.email t@example.com; git -C "$R2" config user.name t
56
- echo a > "$R2/a.txt"; git -C "$R2" add -A; git -C "$R2" commit -qm init
57
- git -C "$R2" config --local core.hooksPath .githooks
58
- bash "$WT" adopt "$R2" --apply >/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 未提示覆盖"
50
+ # simple-git-hooks 仓:走 RC 注入,不碰仓内文件与 .git/hooks
51
+ RN="$TD/npmrepo"; mk_repo "$RN"
52
+ printf '{"name":"r","devDependencies":{"simple-git-hooks":"^2.11.1"},"simple-git-hooks":{"pre-commit":"npx lint-staged"}}\n' > "$RN/package.json"
53
+ git -C "$RN" add -A; git -C "$RN" commit -qm pkg
54
+ before="$(md5 -q "$RN/package.json" 2>/dev/null || md5sum "$RN/package.json" | cut -d' ' -f1)"
55
+ bash "$WT" adopt "$RN" --apply >/dev/null 2>&1 || bad "npm 仓 adopt 失败"
56
+ after="$(md5 -q "$RN/package.json" 2>/dev/null || md5sum "$RN/package.json" | cut -d' ' -f1)"
57
+ [ "$before" = "$after" ] && ok "不修改仓库 package.json" || bad "package.json 被改动"
58
+ [ -z "$(git -C "$RN" config --local --get core.hooksPath || true)" ] && ok "不占用仓库 hooksPath(交给管理器)" || bad "仍设置了 hooksPath"
59
+ [ -f "$DIA_ROOT/hooks/simple-git-hooks-rc.sh" ] && ok "写入 simple-git-hooks RC" || bad "缺 RC 文件"
60
+ grep -q "SIMPLE_GIT_HOOKS_RC" "$HOME/.zshenv" 2>/dev/null && ok "shell rc 注入 SIMPLE_GIT_HOOKS_RC" || bad "未注入环境变量"
61
+ bash "$WT" doctor "$RN" >/dev/null 2>&1 && ok "npm 仓 doctor 通过" || bad "npm 仓 doctor 失败"
62
+ (cd "$RN" && DIA_WT_GUARD_PROBE=1 bash "$WT" guard pre-commit >/dev/null 2>&1) && bad "guard 未拦截 main 提交" || ok "guard 拦截 main 提交"
61
63
 
62
- # 未 adopt 的仓:全局钩子纯透传(主 checkout 可正常提交)
63
- R3="$TD/repo3"; mkdir -p "$R3"; git -C "$R3" init -q -b main
64
- git -C "$R3" config user.email t@example.com; git -C "$R3" config user.name t
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 仓被误拦"
64
+ # 全局配置残留检查:旧版全局钩子目录 → doctor FAIL
65
+ LEGACY="$TD/legacy-hooks"; mkdir -p "$LEGACY"
66
+ printf '#!/usr/bin/env bash\n# dia-wt guard(旧)\nexit 0\n' > "$LEGACY/pre-commit"; chmod +x "$LEGACY/pre-commit"
67
+ git config --global core.hooksPath "$LEGACY"
68
+ bash "$WT" doctor "$R" >/tmp/d20.log 2>&1; grep -q "D20" /tmp/d20.log && ok "doctor 报全局钩子残留" || bad "doctor 未报全局残留: $(head -3 /tmp/d20.log | tr "\n" " ")"
69
+ git config --global --unset core.hooksPath
67
70
 
68
- # 敏感扫描:adopted 仓的 worktree 推送带绝对路径 → 阻断;相对片段不误报
71
+ # 未 adopt 的仓不受影响
72
+ R3="$TD/repo3"; mk_repo "$R3"; echo z > "$R3/z.txt"; git -C "$R3" add -A
73
+ git -C "$R3" commit -qm ok >/dev/null 2>&1 && ok "未 adopt 仓不受守卫影响" || bad "未 adopt 仓被误拦"
74
+
75
+ # 敏感扫描(worktree push)
69
76
  BR="$TD/remote.git"; git init -q --bare "$BR"
70
77
  git -C "$R" remote add origin "$BR" 2>/dev/null || true
71
78
  git --git-dir="$BR" fetch -q "$R" main:main 2>/dev/null || true
72
79
  git -C "$R" fetch -q origin 2>/dev/null || true
73
80
  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
81
+ git -C "$TD/repo-worktree--y" add bad.txt; git -C "$TD/repo-worktree--y" commit -qm bad >/dev/null 2>&1
76
82
  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 # 丢掉含敏感内容的提交
83
+ git -C "$TD/repo-worktree--y" reset -q --hard HEAD~1
78
84
  printf 'path=$TD/home/kits/dia.json\n' > "$TD/repo-worktree--y/vars.txt"
79
85
  git -C "$TD/repo-worktree--y" add vars.txt && git -C "$TD/repo-worktree--y" commit -qm vars >/dev/null 2>&1
80
86
  git -C "$TD/repo-worktree--y" push -q origin feat/y 2>/dev/null && ok "相对片段 home/ 不误报" || bad "相对片段被误判"
81
87
 
82
- # adopt --all:只处理主 checkout,跳过 *-worktree--*
88
+ # adopt --all
83
89
  AR="$TD/allroot"; mkdir -p "$AR/a" "$AR/b"
84
- for r in a b; do
85
- git -C "$AR/$r" init -q -b main
86
- git -C "$AR/$r" config user.email t@example.com; git -C "$AR/$r" config user.name t
87
- echo x > "$AR/$r/x.txt"; git -C "$AR/$r" add -A; git -C "$AR/$r" commit -qm init
88
- done
90
+ for r in a b; do mk_repo "$AR/$r"; done
89
91
  git -C "$AR/a" worktree add -q -b feat/w "$AR/a-worktree--w" main
90
92
  out="$(DIA_WT_REPOS_ROOT="$AR" bash "$WT" adopt --all --apply --json 2>&1)"
91
93
  printf '%s' "$out" | grep -q '"repos":2,"ok":2,"fail":0' && ok "adopt --all 覆盖 2 个主 checkout" || bad "adopt --all: $out"
@@ -97,16 +99,16 @@ DIA_WT_REPOS_ROOT="$TD/empty-root" bash "$WT" adopt --all >/dev/null 2>&1
97
99
 
98
100
  # gate
99
101
  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
102
+ 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
103
  if bash "$WT" gate "$R" yb >/dev/null 2>&1; then bad "gate 子串误匹配"; else ok "gate 精确匹配 feature"; fi
102
104
  echo dirty >> "$TD/repo-worktree--y/c.txt"
103
105
  if bash "$WT" gate "$R" y >/dev/null 2>&1; then bad "worktree 脏时 gate 通过"; else ok "worktree 脏时 gate 失败"; fi
104
106
  git -C "$TD/repo-worktree--y" checkout -q -- c.txt
105
107
 
106
- # revert:撤本仓配置,保留全局钩子
108
+ # revert
107
109
  bash "$WT" adopt "$R" --revert >/dev/null 2>&1 || bad "revert 失败"
108
110
  [ ! -f "$R/.git/dia/config.json" ] && ok "revert 删除本仓配置" || bad "配置残留"
109
- [ -x "$GH/pre-commit" ] && ok "revert 不影响全局钩子" || bad "revert 误删全局钩子"
111
+ [ -z "$(git -C "$R" config --local --get core.hooksPath || true)" ] && ok "revert 还原本地 hooksPath" || bad "hooksPath 未还原"
110
112
  git -C "$R" worktree remove --force "$TD/repo-worktree--y" >/dev/null 2>&1 || true
111
113
 
112
114
  echo; echo "── PASS $PASS · FAIL $FAIL ──"