agents-gitflow-guard 0.0.20 → 0.0.22

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
@@ -40,17 +40,17 @@ You define your own branches —
40
40
 
41
41
  ```bash
42
42
  # DSH — in-process plugin; restart DSH afterwards (plugins load at startup)
43
- dsh plugin --profile web add agents-gitflow-guard@0.0.20
43
+ dsh plugin --profile web add agents-gitflow-guard@0.0.22
44
44
  ```
45
45
 
46
46
  ```bash
47
47
  # Claude Code · Codex · OpenCode · Antigravity — standalone hooks, no DSH needed
48
- npm i -g agents-gitflow-guard@0.0.20
48
+ npm i -g agents-gitflow-guard@0.0.22
49
49
  ```
50
50
 
51
51
  ```bash
52
52
  # Pi — in-process extension
53
- npm i -D agents-gitflow-guard@0.0.20
53
+ npm i -D agents-gitflow-guard@0.0.22
54
54
  ```
55
55
 
56
56
  > **Version gotcha**: a bare `add` or unpinned `npm i` resolves whatever `latest` is at install time — on machines behind a stale npm/pnpm registry cache or mirror it may install an old version. If the installed version looks wrong, pin it explicitly. (DSH users: the pnpm peer-dependency *warning* is expected — DSH supplies `@deepseek-ai/cordis` / `@deepseek-ai/dsh-tools` through its shared profile module fallback at startup; the plugin works normally.)
@@ -68,7 +68,7 @@ gitflow-guard wire --client claude --project --yes
68
68
  # Codex / OpenCode / Antigravity (each its own config file; --yes skips the y/N prompt)
69
69
  gitflow-guard wire --client codex --project --yes
70
70
  gitflow-guard wire --client opencode --project --yes
71
- gitflow-guard wire --client antigravity --project --yes # experimental
71
+ gitflow-guard wire --client antigravity --project --yes
72
72
  ```
73
73
 
74
74
  ```bash
@@ -82,14 +82,14 @@ gitflow-guard setup
82
82
 
83
83
  > ⚠️ **main is protected by default.** Trunk / single-branch users (everyone pushes straight to one branch) will get blocked on direct `main` pushes until they opt out — create `gitflow-guard.config.json` with `{ "enabled": false }`, or map your own branches (see [Configuration Reference](#configuration-reference)). `gitflow-guard status` repeats this notice whenever the built-in defaults are in effect.
84
84
 
85
- **Step 3 — verify.** Ask the agent (or run in a DSH session) to `git push origin develop`. Expect the tool call to be denied:
85
+ **Step 3 — verify.** Ask the agent to `git push origin develop`. Expect the tool call to be denied:
86
86
 
87
87
  ```text
88
88
  Error: [gitflow-guard] blocked: Protected branch "develop" forbids direct push
89
89
  Next: Integration branch (develop) is updated via PR/MR from a feature branch: push the feature first, then `gh pr create --base develop` / `glab mr create --target-branch develop`.
90
90
  ```
91
91
 
92
- Messages are English by default; create a config with `"locale": "zh"` to switch to Chinese (see [Configuration Reference](#configuration-reference)).
92
+ Messages are English by default; create a config with `"locale": "zh"` to switch to Chinese — messages then read like: *已拦截:受保护分支「develop」禁止直推 / 下一步:集成分支(develop)由 PR/MR 合入 feature……* (see [Configuration Reference](#configuration-reference)).
93
93
 
94
94
  **Done.** The guard is live for this repo with the built-in defaults. Want more stages (`preview` / `production`) or different branch names? Write a `gitflow-guard.config.json` and only the fields you care about — everything else keeps the built-in defaults. For the full decision table, see the [Gate Matrix](#gate-matrix--what-gets-blocked-what-passes).
95
95
 
@@ -356,14 +356,14 @@ The PR/MR target is resolved via `gh pr view` (GitHub) or `glab mr view` (GitLab
356
356
 
357
357
  | Agent | Install command | After that |
358
358
  |---|---|---|
359
- | DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.20` | restart DSH — the plugin auto-mounts as a profile layer |
360
- | Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.20` | `gitflow-guard wire --client <name>` — one command per client (below) |
361
- | Pi | `npm i -D agents-gitflow-guard@0.0.20` | copy `pi/gitflow-guard.ts` into `.pi/extensions/` (below) |
359
+ | DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.22` | restart DSH — the plugin auto-mounts as a profile layer |
360
+ | Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.22` | `gitflow-guard wire --client <name>` — one command per client (below) |
361
+ | Pi | `npm i -D agents-gitflow-guard@0.0.22` | copy `pi/gitflow-guard.ts` into `.pi/extensions/` (below) |
362
362
 
363
363
  **DSH — in-process plugin** (the standard path, already covered in [Quick Start](#quick-start--30-seconds-to-a-guarded-repo)):
364
364
 
365
365
  ```bash
366
- dsh plugin --profile web add agents-gitflow-guard@0.0.20 # pin recommended, see note above
366
+ dsh plugin --profile web add agents-gitflow-guard@0.0.22 # pin recommended, see note above
367
367
  ```
368
368
 
369
369
  Then restart DSH. Upgrades are the same command, followed by another restart.
@@ -380,11 +380,11 @@ The package declares `dsh.bundle.patch`, so `dsh plugin add` automatically makes
380
380
  **Standalone agent hooks** — Claude Code / Codex / OpenCode / Antigravity, no DSH required. Install the CLI once, then wire one client per command (the guard is on by default via its built-in config, so wiring is all that remains):
381
381
 
382
382
  ```bash
383
- npm i -g agents-gitflow-guard@0.0.20 # provides the `gitflow-guard` binary
383
+ npm i -g agents-gitflow-guard@0.0.22 # provides the `gitflow-guard` binary
384
384
  gitflow-guard wire --client claude --project --yes
385
385
  gitflow-guard wire --client codex --project --yes
386
386
  gitflow-guard wire --client opencode --project --yes
387
- gitflow-guard wire --client antigravity --project --yes # experimental
387
+ gitflow-guard wire --client antigravity --project --yes
388
388
  ```
389
389
 
390
390
  `wire` reads the existing config file (if any), merges the hook entry in without touching anything else, is idempotent (already wired → skipped), supports `--dry-run` to preview and `--unwire` to remove, and asks before touching `--global` files. The exact files it writes (for reference, and for hand-writing instead of `wire`) are:
@@ -411,18 +411,18 @@ gitflow-guard wire --client antigravity --project --yes # experimental
411
411
  }
412
412
  ```
413
413
 
414
- ```yaml
415
- # OpenCode — .opencode/hook/hooks.yaml
416
- hooks:
417
- - id: gitflow-guard
418
- event: tool.before.bash
419
- actions:
420
- - bash: |
421
- gitflow-guard check --platform opencode
414
+ ```ts
415
+ // OpenCode — `.opencode/plugins/gitflow-guard.ts` (copy of the shipped `opencode/gitflow-guard.ts`;
416
+ // OpenCode 1.18+ dropped hooks.yaml, the extension point is now plugins — `tool.execute.before`,
417
+ // deny = throwing; `wire --client opencode` copies the file for you)
422
418
  ```
419
+ `gitflow-guard wire --client opencode` writes this file from the package; hand-write only if you know what you are doing.
423
420
 
424
421
  ```json
425
422
  // Antigravity (Google) — .agents/hooks.json
423
+ // (Antigravity hook processes run with cwd = the config file's directory, so a relative
424
+ // bin/… path breaks; `wire` writes an absolute path for project scope, `gitflow-guard`
425
+ // from PATH for global scope. Shown here: globally-installed form.)
426
426
  {
427
427
  "gitflow-guard": {
428
428
  "PreToolUse": [
@@ -440,7 +440,7 @@ hooks:
440
440
  Pi loads extensions in-process (no stdin payload, no subprocess hook). Install the shipped entry point into the project and keep the package in devDependencies:
441
441
 
442
442
  ```bash
443
- npm i -D agents-gitflow-guard@0.0.20
443
+ npm i -D agents-gitflow-guard@0.0.22
444
444
  mkdir -p .pi/extensions
445
445
  cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-guard.ts
446
446
  ```
@@ -535,7 +535,7 @@ If it saves your team from a shortcut gone wrong, the coffee button at the top o
535
535
  | **integration** | the core role (built-in default: `develop`); features merge in via PR/MR; protected |
536
536
  | **preview** | optional env-endpoint branches (`branches.preview`, array); updates via PR/MR only |
537
537
  | **production** | optional production branches (`branches.production`, array); PR/MR + merge by user only |
538
- | **archive** | optional post-release archive branch (`branches.archive`); user-hand only |
538
+ | **archive** | optional post-release archive branch (`branches.archive`, array); agents may create PR/MRs into it, but the merge stays user-hand only |
539
539
  | **feature branch** | your working branch, matched by `featurePattern`; free zone |
540
540
  | **gate matrix** | the decision table mapping each classified command to allow/deny |
541
541
  | **pre-execute** | the tool-pipeline hook where denial happens — before the command runs |
@@ -545,10 +545,18 @@ If it saves your team from a shortcut gone wrong, the coffee button at the top o
545
545
 
546
546
  ## Roadmap
547
547
 
548
- - **i18n — localized block messages** ✅ (0.0.3): English by default, `"locale": "zh"` for Chinese.
549
- - **v2 — audit sync**: sync the user-level audit log across machines (audit is local-only today).
550
- - **v2more pre-built templates**: ready-made config templates for common flows (solo `develop`, multi-env enterprise) as community-contributed presets.
551
- - **v2 CI hard-gating research**: whether `pr checks` could become a real gate without hurting the platform-agnostic core.
548
+ **Shipped**:
549
+
550
+ - **i18nlocalized block messages** (0.0.3): English by default, `"locale": "zh"` for Chinese; custom locales via `registerLocale` since 0.0.12.
551
+ - **Zero-config onboarding** (0.0.20): built-in `develop` + `main` defaults, deep-merge override, guard on by default with no config file.
552
+ - ✅ **One-command wiring** (0.0.20): `gitflow-guard wire` / `setup` writes each stdin-hook client's hook entry (Claude Code / Codex / OpenCode / Antigravity).
553
+ - ✅ **Six platforms** (0.0.2–0.0.17): DSH (in-process), Claude Code, Codex, OpenCode, Antigravity, Pi (in-process).
554
+
555
+ **v2 (open)**:
556
+
557
+ - **audit sync**: sync the user-level audit log across machines (audit is local-only today; since 0.0.14 it lives outside the repository).
558
+ - **more pre-built templates**: extend the built-in defaults with ready-made presets for common flows (solo `develop`, multi-env enterprise) as community-contributed configs.
559
+ - **CI hard-gating research**: whether `pr checks` could become a real gate without hurting the platform-agnostic core.
552
560
 
553
561
  Contributions welcome — see [Development](#development).
554
562
 
@@ -566,9 +574,10 @@ The plugin is free and open source (MIT). If it saves you and your team from a s
566
574
 
567
575
  ```bash
568
576
  npm install
569
- npm test # unit tests: classify / gate / config / cli / repo / platform / i18n / index / accuracy-audit
577
+ npm test # unit tests: classify / gate / config / cli / repo / platform / i18n / index / accuracy-audit / pi
570
578
  npm run typecheck # tsc --noEmit, 0 errors
571
579
  npm run build # tsdown → lib/ (CLI and plugin share the build)
580
+ npm run check:pins # assert package.json version matches every README lock-version pin and the CHANGELOG heading
572
581
  npm run verify:matrix # continuous cross-agent regression: DSH logic + zh-locale regression + Claude Code / Codex / OpenCode / Antigravity hook wiring + Pi extension
573
582
  ```
574
583
 
package/README.zh.md CHANGED
@@ -39,17 +39,17 @@
39
39
 
40
40
  ```bash
41
41
  # DSH —— 进程内插件(装完重启 DSH; 插件在进程启动时加载)
42
- dsh plugin --profile web add agents-gitflow-guard@0.0.20
42
+ dsh plugin --profile web add agents-gitflow-guard@0.0.22
43
43
  ```
44
44
 
45
45
  ```bash
46
46
  # Claude Code · Codex · OpenCode · Antigravity —— 独立 hook,不需要 DSH
47
- npm i -g agents-gitflow-guard@0.0.20
47
+ npm i -g agents-gitflow-guard@0.0.22
48
48
  ```
49
49
 
50
50
  ```bash
51
51
  # Pi —— 进程内扩展
52
- npm i -D agents-gitflow-guard@0.0.20
52
+ npm i -D agents-gitflow-guard@0.0.22
53
53
  ```
54
54
 
55
55
  > **版本坑**: 裸 `add` 或不带版本的 `npm i` 装的是安装时刻的 `latest`——在 npm/pnpm 注册表缓存或镜像陈旧的机器上可能拿到旧版本。看到版本不对就锁版本。(DSH 用户: pnpm 打印的 peer 依赖 *警告* 属预期——DSH 启动时经共享模块回退提供 `@deepseek-ai/cordis` / `@deepseek-ai/dsh-tools`,插件正常工作。)
@@ -67,7 +67,7 @@ gitflow-guard wire --client claude --project --yes
67
67
  # Codex / OpenCode / Antigravity(各写各的配置文件; --yes 跳过 y/N 确认)
68
68
  gitflow-guard wire --client codex --project --yes
69
69
  gitflow-guard wire --client opencode --project --yes
70
- gitflow-guard wire --client antigravity --project --yes # 实验支持
70
+ gitflow-guard wire --client antigravity --project --yes
71
71
  ```
72
72
 
73
73
  ```bash
@@ -115,7 +115,9 @@ AI 编码 agent 在你的仓库里工作。它通过系统提示词、项目智
115
115
 
116
116
  **这是软规则。** Agent 会跳过、重排、干脆"忘记"它——不是因为恶意,而是因为软指令对模型来说本来就是可选的。
117
117
 
118
- 这个插件把软规则变成**硬机制**。agent 每次尝试的 git 操作都会对照*本地仓库的真实状态*检查;违规在命令执行前就被拦截,并给出原因和下一步。没人需要记得规则——规则被强制执行。
118
+ 这个插件把软规则变成**硬机制**。agent 每次尝试的 git 操作都会对照*本地仓库的真实状态*检查;违规在命令执行前就被拦截,并给出原因和下一步。
119
+
120
+ 没人需要记得规则——规则被强制执行。
119
121
 
120
122
  ---
121
123
 
@@ -352,14 +354,14 @@ PR/MR 目标通过 `gh pr view`(GitHub)或 `glab mr view`(GitLab)解析;没有
352
354
 
353
355
  | 客户端 | 安装命令 | 装完再做什么 |
354
356
  |---|---|---|
355
- | DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.20` | 重启 DSH——插件自动挂为 profile 层 |
356
- | Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.20` | `gitflow-guard wire --client <名>`——每个客户端一条命令(见下) |
357
- | Pi | `npm i -D agents-gitflow-guard@0.0.20` | 把 `pi/gitflow-guard.ts` 拷进 `.pi/extensions/`(见下) |
357
+ | DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.22` | 重启 DSH——插件自动挂为 profile 层 |
358
+ | Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.22` | `gitflow-guard wire --client <名>`——每个客户端一条命令(见下) |
359
+ | Pi | `npm i -D agents-gitflow-guard@0.0.22` | 把 `pi/gitflow-guard.ts` 拷进 `.pi/extensions/`(见下) |
358
360
 
359
361
  **DSH —— 进程内插件**(标准路径,已在[快速开始](#快速开始30-秒用上)覆盖):
360
362
 
361
363
  ```bash
362
- dsh plugin --profile web add agents-gitflow-guard@0.0.20 # 建议锁版本, 见上文提示
364
+ dsh plugin --profile web add agents-gitflow-guard@0.0.22 # 建议锁版本, 见上文提示
363
365
  ```
364
366
 
365
367
  然后重启 DSH。升级用同一命令,再重启一次。
@@ -376,11 +378,11 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
376
378
  **各 agent 独立 hook**——Claude Code / Codex / OpenCode / Antigravity,不依赖 DSH。全局装一次 CLI,然后**每客户端一条命令接线**(守卫凭内置默认配置已默认开启,接线是唯一剩下的事):
377
379
 
378
380
  ```bash
379
- npm i -g agents-gitflow-guard@0.0.20 # 提供 `gitflow-guard` 二进制
381
+ npm i -g agents-gitflow-guard@0.0.22 # 提供 `gitflow-guard` 二进制
380
382
  gitflow-guard wire --client claude --project --yes
381
383
  gitflow-guard wire --client codex --project --yes
382
384
  gitflow-guard wire --client opencode --project --yes
383
- gitflow-guard wire --client antigravity --project --yes # 实验支持
385
+ gitflow-guard wire --client antigravity --project --yes
384
386
  ```
385
387
 
386
388
  `wire` 读取已有配置文件(如有)并把 hook 条目合入——不碰其他内容、幂等(已接则跳过)、支持 `--dry-run` 预览与 `--unwire` 移除、写 `--global` 前必先询问。它写入的准确文件(供参考,也可代替 `wire` 手写)是:
@@ -407,18 +409,17 @@ gitflow-guard wire --client antigravity --project --yes # 实验支持
407
409
  }
408
410
  ```
409
411
 
410
- ```yaml
411
- # OpenCode — .opencode/hook/hooks.yaml
412
- hooks:
413
- - id: gitflow-guard
414
- event: tool.before.bash
415
- actions:
416
- - bash: |
417
- gitflow-guard check --platform opencode
412
+ ```ts
413
+ // OpenCode — `.opencode/plugins/gitflow-guard.ts`(随包 `opencode/gitflow-guard.ts` 的副本;
414
+ // OpenCode 1.18+ 已移除 hooks.yaml,扩展点改为 plugins —— `tool.execute.before` 事件,
415
+ // 拒绝语义 = 抛错; `wire --client opencode` 自动复制该文件)
418
416
  ```
417
+ `gitflow-guard wire --client opencode` 会从包内写入此文件;非必要不建议手写。
419
418
 
420
419
  ```json
421
420
  // Antigravity (Google) — .agents/hooks.json
421
+ // (agy hook 进程 cwd = hook 配置文件所在目录,相对 bin/… 会解析失败; `wire` 项目级写绝对路径、
422
+ // 全局写 PATH 上的 gitflow-guard。此处展示全局安装形态。)
422
423
  {
423
424
  "gitflow-guard": {
424
425
  "PreToolUse": [
@@ -436,7 +437,7 @@ hooks:
436
437
  Pi 以进程内扩展装载(没有 stdin payload,也没有子进程 hook)。把随包发布的入口装进项目、包留在 devDependencies:
437
438
 
438
439
  ```bash
439
- npm i -D agents-gitflow-guard@0.0.20
440
+ npm i -D agents-gitflow-guard@0.0.22
440
441
  mkdir -p .pi/extensions
441
442
  cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-guard.ts
442
443
  ```
@@ -541,10 +542,18 @@ MIT,免费,无条件。随便用、随便改、随便发,唯一义务是保留
541
542
 
542
543
  ## 路线图
543
544
 
544
- - **i18n——拦截文案本地化** ✅(0.0.3):默认英文,`"locale": "zh"` 切中文。
545
- - **v2——审计同步**:跨机器同步用户级审计日志(现仅本地)。
546
- - **v2——更多预制模板**:常用流程(solo `develop`、多环境企业)的现成配置模板,由社区贡献。
547
- - **v2——CI 硬门槛研究**:`pr checks` 能否在不伤平台无关核心的前提下变成真实门槛。
545
+ **已落地**:
546
+
547
+ - **i18n——拦截文案本地化**(0.0.3):默认英文,`"locale": "zh"` 切中文;0.0.12 起支持 `registerLocale` 自定义语言。
548
+ - **零配置开箱**(0.0.20):内置 `develop` + `main` 默认配置、深度合并覆盖,无配置文件也默认开启守卫。
549
+ - ✅ **一键接线**(0.0.20):`gitflow-guard wire` / `setup` 一条命令写入各 stdin-hook 客户端(Claude Code / Codex / OpenCode / Antigravity)的 hook 条目。
550
+ - ✅ **六个平台**(0.0.2–0.0.17):DSH(进程内)、Claude Code、Codex、OpenCode、Antigravity、Pi(进程内)。
551
+
552
+ **v2 规划中**:
553
+
554
+ - **审计同步**:跨机器同步用户级审计日志(现仍仅本地;0.0.14 起已存于仓库外)。
555
+ - **更多预制模板**:在内置默认之上延伸,为常用流程(solo `develop`、多环境企业)提供社区贡献的现成配置预设。
556
+ - **CI 硬门槛研究**:`pr checks` 能否在不伤平台无关核心的前提下变成真实门槛。
548
557
 
549
558
  欢迎贡献——见[开发](#开发)。
550
559
 
@@ -562,9 +571,10 @@ MIT,免费,无条件。随便用、随便改、随便发,唯一义务是保留
562
571
 
563
572
  ```bash
564
573
  npm install
565
- npm test # 单测: classify / gate / config / cli / repo / platform / i18n / index / accuracy-audit
574
+ npm test # 单测: classify / gate / config / cli / repo / platform / i18n / index / accuracy-audit / pi
566
575
  npm run typecheck # tsc --noEmit, 0 Error
567
576
  npm run build # tsdown → lib/(CLI 与插件共用)
577
+ npm run check:pins # 校验 package.json 版本与双语 README 锁版本示例及 CHANGELOG 标题一致
568
578
  npm run verify:matrix # 连续复测矩阵: DSH 逻辑 + zh 文案回归 + Claude Code / Codex / OpenCode / Antigravity hook 编码 + Pi 扩展
569
579
  ```
570
580
 
package/lib/cli.mjs CHANGED
@@ -1,8 +1,9 @@
1
- import { _ as resolveLocale, d as gitRunner, f as loadConfig, h as makeT, i as formatDeny, l as currentBranch, o as stateDir, p as roleMatches, r as evaluateCommand, u as findRepoRoot, v as classify } from "./src-DPJRoEJq.mjs";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { _ as resolveLocale, d as gitRunner, f as loadConfig, h as makeT, i as formatDeny, l as currentBranch, o as stateDir, p as roleMatches, r as evaluateCommand, u as findRepoRoot, v as classify } from "./src-DMlNMtXn.mjs";
2
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { createInterface } from "node:readline";
6
+ import { fileURLToPath } from "node:url";
6
7
  //#region src/platform.ts
7
8
  function str(v) {
8
9
  return typeof v === "string" ? v : "";
@@ -37,7 +38,7 @@ function extractHookPayload(raw, platform = "auto") {
37
38
  cwd = str(j.cwd);
38
39
  } else if (plat === "antigravity") {
39
40
  command = str(j.toolCall?.args?.CommandLine);
40
- cwd = str(j.cwd);
41
+ cwd = str(j.toolCall?.args?.Cwd);
41
42
  }
42
43
  if (!command) return null;
43
44
  return {
@@ -98,7 +99,9 @@ const CLIENTS = [
98
99
  function isWireClient(v) {
99
100
  return CLIENTS.includes(v);
100
101
  }
101
- /** 各客户端的 hook 落位规格(dsh/pi 无 hook 文件, 仅输出接入引导) */
102
+ /** 各客户端的 hook 落位规格(dsh/pi 无 hook 文件, 仅输出接入引导)
103
+ * opencode: OpenCode 1.18+ 已废弃 hooks.yaml(实机零调用, 见 docs/e2e/TestResult/opencode.md),
104
+ * 官方扩展点为 plugins 目录 —— wire 把随包插件 opencode/gitflow-guard.ts 复制到插件目录。 */
102
105
  const WIRE_CLIENTS = [
103
106
  {
104
107
  client: "claude",
@@ -112,14 +115,13 @@ const WIRE_CLIENTS = [
112
115
  },
113
116
  {
114
117
  client: "opencode",
115
- projectPath: ".opencode/hook/hooks.yaml",
116
- globalPath: () => join(homedir(), ".config", "opencode", "hook", "hooks.yaml")
118
+ projectPath: ".opencode/plugins/gitflow-guard.ts",
119
+ globalPath: () => join(homedir(), ".config", "opencode", "plugins", "gitflow-guard.ts")
117
120
  },
118
121
  {
119
122
  client: "antigravity",
120
123
  projectPath: ".agents/hooks.json",
121
- globalPath: () => join(homedir(), ".gemini", "config", "hooks.json"),
122
- experimental: true
124
+ globalPath: () => join(homedir(), ".gemini", "config", "hooks.json")
123
125
  },
124
126
  {
125
127
  client: "dsh",
@@ -132,24 +134,18 @@ const WIRE_CLIENTS = [
132
134
  globalPath: () => ""
133
135
  }
134
136
  ];
135
- /** stdin-hook 客户端的 hook 命令(与 references/*.md 逐一对应; codex/antigravity 用相对 bin/...) */
136
- const COMMANDS = {
137
- claude: "node ${CLAUDE_PROJECT_DIR}/bin/gitflow-guard.mjs check --platform claude",
138
- codex: "node bin/gitflow-guard.mjs check --platform codex",
139
- opencode: "node \"$OPENCODE_PROJECT_DIR/bin/gitflow-guard.mjs\" check --platform opencode",
140
- antigravity: "node bin/gitflow-guard.mjs check --platform antigravity"
141
- };
142
- /** OpenCode YAML 模板(顶层 hooks: + 语义 id gitflow-guard) */
143
- const OPENCODE_TEMPLATE = [
144
- "hooks:",
145
- " - id: gitflow-guard",
146
- " event: tool.before.bash",
147
- " actions:",
148
- " - bash: |",
149
- ` ${COMMANDS.opencode}`
150
- ].join("\n");
151
- const YAML_ID_GUARD = /^\s*- id: gitflow-guard\s*$/m;
152
- const YAML_ID_ANY = /^\s*- id:/m;
137
+ /** 随包发布的 OpenCode 插件源文件(wire --client opencode 复制到插件目录; dev 下即仓库 opencode/) */
138
+ const OPENCODE_PLUGIN_SOURCE = fileURLToPath(new URL("../opencode/gitflow-guard.ts", import.meta.url));
139
+ /** JSON 客户端的 hook 命令(与 references/*.md 逐一对应)。
140
+ * antigravity 必须绝对路径: agy hook 进程 cwd = hook 配置文件所在目录(TestResult/antigravity.md AGY-D2),
141
+ * 相对 bin/... 会解析为 .agents/bin/... → MODULE_NOT_FOUND; 全局落位无仓库根, 用 PATH 上的 gitflow-guard */
142
+ function commandFor(client, repoRoot) {
143
+ switch (client) {
144
+ case "claude": return "node ${CLAUDE_PROJECT_DIR}/bin/gitflow-guard.mjs check --platform claude";
145
+ case "codex": return "node bin/gitflow-guard.mjs check --platform codex";
146
+ case "antigravity": return repoRoot ? `node ${join(repoRoot, "bin", "gitflow-guard.mjs")} check --platform antigravity` : "gitflow-guard check --platform antigravity";
147
+ }
148
+ }
153
149
  /** 读取文本文件; 缺失返回 null(其余异常也视为缺失, 决策保守) */
154
150
  async function readText(path) {
155
151
  try {
@@ -169,6 +165,20 @@ function jsonContains(obj, needle) {
169
165
  if (obj !== null && typeof obj === "object") return Object.values(obj).some((x) => jsonContains(x, needle));
170
166
  return false;
171
167
  }
168
+ /** JSON 递归搜索(谓词版): 用于旧格式条目的柔性识别 */
169
+ function jsonContainsBy(obj, pred) {
170
+ if (pred(obj)) return true;
171
+ if (Array.isArray(obj)) return obj.some((x) => jsonContainsBy(x, pred));
172
+ if (obj !== null && typeof obj === "object") return Object.values(obj).some((x) => jsonContainsBy(x, pred));
173
+ return false;
174
+ }
175
+ /** 判断命令是否为本插件 antigravity 条目: 新格式绝对路径 node <root>/bin/gitflow-guard.mjs … 或
176
+ * AGY-D2 之前的旧相对格式 node bin/gitflow-guard.mjs …, 或全局 PATH 形态(无 .mjs);
177
+ * 格式演进后旧条目仍能被识别/替换/移除, 避免新旧双条并存。 */
178
+ function antigravityCommandish(cmd) {
179
+ if (typeof cmd !== "string") return false;
180
+ return cmd.includes("gitflow-guard.mjs check --platform antigravity") || cmd === "gitflow-guard check --platform antigravity";
181
+ }
172
182
  function parseJsonOrThrow(path, raw) {
173
183
  try {
174
184
  const parsed = JSON.parse(raw);
@@ -182,8 +192,8 @@ async function writeJson(path, obj) {
182
192
  await writeText(path, `${JSON.stringify(obj, null, 2)}\n`);
183
193
  }
184
194
  /** JSON 客户端(claude/codex/antigravity)新增 hook 条目; 非破坏性合并, 同命令已存在则跳过 */
185
- async function addJsonEntry(path, client, dryRun) {
186
- const cmd = COMMANDS[client];
195
+ async function addJsonEntry(path, client, dryRun, repoRoot) {
196
+ const cmd = commandFor(client, repoRoot);
187
197
  const raw = await readText(path);
188
198
  const obj = raw === null ? {} : parseJsonOrThrow(path, raw);
189
199
  if (jsonContains(obj, cmd)) return "exists";
@@ -203,7 +213,11 @@ async function addJsonEntry(path, client, dryRun) {
203
213
  if (client === "antigravity") {
204
214
  const block = obj["gitflow-guard"] ??= { PreToolUse: [] };
205
215
  if (!Array.isArray(block.PreToolUse)) throw new Error(`invalid ${path}: gitflow-guard.PreToolUse must be an array`);
206
- block.PreToolUse.push(entry);
216
+ const entries = block.PreToolUse;
217
+ if (entries.some((e) => jsonContainsBy(e, (v) => v === cmd))) return "exists";
218
+ const next = entries.filter((e) => !jsonContainsBy(e, antigravityCommandish));
219
+ next.push(entry);
220
+ block.PreToolUse = next;
207
221
  } else {
208
222
  const hooksObj = obj["hooks"] ??= {};
209
223
  const arr = hooksObj["PreToolUse"] ??= [];
@@ -214,14 +228,17 @@ async function addJsonEntry(path, client, dryRun) {
214
228
  return "added";
215
229
  }
216
230
  /** JSON 客户端移除本插件条目; 不动其他内容 */
217
- async function removeJsonEntry(path, client, dryRun) {
218
- const cmd = COMMANDS[client];
231
+ async function removeJsonEntry(path, client, dryRun, repoRoot) {
232
+ const cmd = commandFor(client, repoRoot);
219
233
  const raw = await readText(path);
220
234
  if (raw === null) return "absent";
221
235
  const obj = parseJsonOrThrow(path, raw);
222
- if (!jsonContains(obj, cmd)) return "absent";
223
- if (client === "antigravity") delete obj["gitflow-guard"];
224
- else {
236
+ if (client === "antigravity") {
237
+ const block = obj["gitflow-guard"];
238
+ if (!block || !jsonContainsBy(block, antigravityCommandish)) return "absent";
239
+ delete obj["gitflow-guard"];
240
+ } else {
241
+ if (!jsonContains(obj, cmd)) return "absent";
225
242
  const hooksObj = obj["hooks"];
226
243
  const arr = hooksObj?.["PreToolUse"];
227
244
  if (Array.isArray(arr)) {
@@ -234,60 +251,36 @@ async function removeJsonEntry(path, client, dryRun) {
234
251
  if (!dryRun) await writeJson(path, obj);
235
252
  return "removed";
236
253
  }
237
- /** OpenCode YAML: hooks: 列表按语义 id gitflow-guard 判重/落位 */
238
- async function addYamlEntry(path, dryRun) {
239
- const raw = await readText(path);
240
- if (raw !== null) {
241
- if (YAML_ID_GUARD.test(raw)) return "exists";
242
- const lines = raw.split("\n");
243
- const hooksIdx = lines.findIndex((l) => /^hooks:\s*$/.test(l));
244
- const block = OPENCODE_TEMPLATE.split("\n").slice(1);
245
- if (hooksIdx === -1) {
246
- const joined = [
247
- ...lines,
248
- "",
249
- ...block
250
- ].join("\n");
251
- if (!dryRun) await writeText(path, joined);
252
- return "added";
253
- }
254
- lines.splice(hooksIdx + 1, 0, ...block);
255
- if (!dryRun) await writeText(path, lines.join("\n"));
256
- return "added";
254
+ /** OpenCode 插件: 复制随包插件文件; 已存在同文件视为已接线(幂等) */
255
+ async function addPluginFile(path, dryRun) {
256
+ if (await readText(path) !== null) return "exists";
257
+ let source;
258
+ try {
259
+ source = await readFile(OPENCODE_PLUGIN_SOURCE, "utf8");
260
+ } catch {
261
+ throw new Error(`cannot read bundled opencode plugin source at ${OPENCODE_PLUGIN_SOURCE} — install the package (npm i -g agents-gitflow-guard) or copy opencode/gitflow-guard.ts into the project's .opencode/plugins/ manually`);
257
262
  }
258
- if (!dryRun) await writeText(path, OPENCODE_TEMPLATE);
263
+ if (!dryRun) await writeText(path, source);
259
264
  return "added";
260
265
  }
261
- /** OpenCode YAML: 移除 gitflow-guard 块; 若列表清空则连顶层 hooks: 一并清理 */
262
- async function removeYamlEntry(path, dryRun) {
263
- const raw = await readText(path);
264
- if (raw === null) return "absent";
265
- if (!YAML_ID_GUARD.test(raw)) return "absent";
266
- const lines = raw.split("\n");
267
- const start = lines.findIndex((l) => YAML_ID_GUARD.test(l));
268
- let end = lines.length;
269
- for (let i = start + 1; i < lines.length; i++) if (YAML_ID_ANY.test(lines[i])) {
270
- end = i;
271
- break;
272
- }
273
- let rest = [...lines.slice(0, start), ...lines.slice(end)];
274
- if (!rest.some((l) => YAML_ID_ANY.test(l))) rest = rest.filter((l) => !/^hooks:\s*$/.test(l));
275
- const text = rest.join("\n");
276
- if (!dryRun) await writeText(path, text);
266
+ /** OpenCode 插件: 删除插件文件; 不动其他插件 */
267
+ async function removePluginFile(path, dryRun) {
268
+ if (await readText(path) === null) return "absent";
269
+ if (!dryRun) await unlink(path);
277
270
  return "removed";
278
271
  }
279
272
  /** 执行一次 wire 落位/移除/预览; dsh/pi 由上层直接短路, 不进这里 */
280
- async function applyWire(client, path, unwire, dryRun) {
281
- if (client === "opencode") return unwire ? removeYamlEntry(path, dryRun) : addYamlEntry(path, dryRun);
282
- return unwire ? removeJsonEntry(path, client, dryRun) : addJsonEntry(path, client, dryRun);
273
+ async function applyWire(client, path, unwire, dryRun, repoRoot = null) {
274
+ if (client === "opencode") return unwire ? removePluginFile(path, dryRun) : addPluginFile(path, dryRun);
275
+ return unwire ? removeJsonEntry(path, client, dryRun, repoRoot) : addJsonEntry(path, client, dryRun, repoRoot);
283
276
  }
284
- /** 只读探测: 该配置文件是否已含本插件 hook(status 的接线提示用) */
285
- async function isWired(client, path) {
277
+ /** 只读探测: 该客户端是否已接线(opencode 判插件文件存在; JSON 客户端按命令精确匹配) */
278
+ async function isWired(client, path, repoRoot = null) {
286
279
  const raw = await readText(path);
287
280
  if (raw === null) return false;
288
- if (client === "opencode") return YAML_ID_GUARD.test(raw);
281
+ if (client === "opencode") return true;
289
282
  try {
290
- return jsonContains(JSON.parse(raw), COMMANDS[client]);
283
+ return jsonContains(JSON.parse(raw), commandFor(client, repoRoot));
291
284
  } catch {
292
285
  return false;
293
286
  }
@@ -416,7 +409,7 @@ async function status(flags, runner) {
416
409
  const hints = [];
417
410
  for (const spec of WIRE_CLIENTS) {
418
411
  if (spec.client === "dsh" || spec.client === "pi") continue;
419
- if (!await isWired(spec.client, join(repoRoot, spec.projectPath))) hints.push(spec.client);
412
+ if (!await isWired(spec.client, join(repoRoot, spec.projectPath), repoRoot)) hints.push(spec.client);
420
413
  }
421
414
  if (hints.length > 0) {
422
415
  console.log(t("cli.statusWireHints"));
@@ -490,7 +483,7 @@ async function wireCore(client, scope, opts, t) {
490
483
  path
491
484
  }));
492
485
  if (opts.dryRun) {
493
- const res = await applyWire(client, path, !!opts.unwire, true);
486
+ const res = await applyWire(client, path, !!opts.unwire, true, opts.repoRoot);
494
487
  if (res === "added") console.log(t("cli.wireDryRunAdd", {
495
488
  client,
496
489
  path
@@ -515,7 +508,7 @@ async function wireCore(client, scope, opts, t) {
515
508
  if (ans !== "y" && ans !== "yes") return 1;
516
509
  }
517
510
  }
518
- const res = await applyWire(client, path, !!opts.unwire, false);
511
+ const res = await applyWire(client, path, !!opts.unwire, false, opts.repoRoot);
519
512
  if (res === "added") console.log(t("cli.wireCreated", {
520
513
  client,
521
514
  path
package/lib/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as name, c as createPiExtension, g as registerLocale, i as formatDeny, m as MESSAGE_KEYS, n as apply, o as stateDir, r as evaluateCommand, s as userStateRoot, t as appendAudit } from "./src-DPJRoEJq.mjs";
1
+ import { a as name, c as createPiExtension, g as registerLocale, i as formatDeny, m as MESSAGE_KEYS, n as apply, o as stateDir, r as evaluateCommand, s as userStateRoot, t as appendAudit } from "./src-DMlNMtXn.mjs";
2
2
  export { MESSAGE_KEYS, appendAudit, apply, createPiExtension, evaluateCommand, formatDeny, name, registerLocale, stateDir, userStateRoot };
@@ -732,9 +732,10 @@ Notes:
732
732
  --locale overrides the message language for this invocation (flag > project config > English).
733
733
  check reads the hook payload on stdin (platform-specific protocol: claude/opencode exit 2,
734
734
  codex/antigravity JSON on stdout) and is meant for pre/post hooks of AI agents.
735
- wire writes each client's hook config into the project (default) or global scope; dsh/pi are
736
- in-process and only print guidance. No config file needed — built-in defaults (develop+main)
737
- apply out of the box; create gitflow-guard.config.json to override, or set "enabled": false to turn off.`
735
+ wire writes each client's hook config (or the OpenCode plugin file) into the project
736
+ (default) or global scope; dsh/pi are in-process and only print guidance. No config file
737
+ needed — built-in defaults (develop+main) apply out of the box; create
738
+ gitflow-guard.config.json to override, or set "enabled": false to turn off.`
738
739
  };
739
740
  const zh = {
740
741
  "role.integration": () => "集成分支",
@@ -839,7 +840,7 @@ const zh = {
839
840
  --locale 可临时覆盖本次调用的文案语言(旗标 > 项目配置 > 英文)。
840
841
  check 读 stdin hook payload 做门禁(平台协议: claude/opencode exit 2, codex/antigravity stdout JSON),
841
842
  供 Claude Code / Codex / OpenCode 等 agent 的 pre/post hook 调用。
842
- wire 把各客户端默认 hook 写入工程(默认)或全局作用域; dsh/pi 为进程内接入, 仅打印引导。
843
+ wire 把各客户端默认 hook 写入工程(默认)或全局作用域(opencode 为复制插件文件); dsh/pi 为进程内接入, 仅打印引导。
843
844
  无需配置文件 — 内置默认(develop+main)开箱即用; 建 gitflow-guard.config.json 可覆盖, 或写 "enabled": false 关闭。`
844
845
  };
845
846
  /** 内置文案注册表: en 为兜底语言; 下游可经 registerLocale 追加 */
@@ -0,0 +1,98 @@
1
+ // OpenCode 插件(随包发布; `wire --client opencode` 会复制本文件到 .opencode/plugins/):
2
+ // 在 tool.execute.before 事件拦截 bash/powershell 工具, 经守卫 CLI 门禁(check --platform opencode),
3
+ // 拒绝(exit 2)时抛错阻断工具执行; 守卫不可用时 fail-open 放行, 不破坏工具管道。
4
+ //
5
+ // 背景: OpenCode 1.18+ 已移除 hooks.yaml 机制(实机测试零调用, 见 docs/e2e/TestResult/opencode.md),
6
+ // 官方扩展点为 plugins 目录 + 事件订阅, 阻断语义 = handler 抛错(官方 env-protection 示例同款)。
7
+ // 插件导出必须是**函数**(工厂), 返回事件处理器对象 —— 直接导出对象会报 "Plugin export is not a function"。
8
+ // 协议参考: .agents/hooks/references/opencode.md。
9
+ //
10
+ // 用法: 项目内执行 `gitflow-guard wire --client opencode --project --yes`, wire 会把本文件
11
+ // 复制到 <project>/.opencode/plugins/gitflow-guard.ts(全局落位到 ~/.config/opencode/plugins/);
12
+ // 也可手工复制。零外部依赖: 不 import @opencode-ai/plugin, 客户端原生加载本文件即可。
13
+
14
+ import { spawn } from 'node:child_process'
15
+ import { existsSync } from 'node:fs'
16
+ import { dirname, join, resolve } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ /** 插件自身所在目录: 项目级 = <项目>/.opencode/plugins, 全局 = ~/.config/opencode/plugins */
20
+ const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url))
21
+
22
+ interface GuardTarget {
23
+ bin: string
24
+ args: string[]
25
+ }
26
+
27
+ /** 组装 spawn 目标: opencode 插件在 opencode 进程内执行, process.execPath 是 opencode 自身而非 node,
28
+ * 不能拿它当解释器 —— Unix 直跑脚本(#!/usr/bin/env node shebang), Windows 用 PATH 上的 node。 */
29
+ function makeTarget(bin: string): GuardTarget {
30
+ return process.platform === 'win32' ? { bin: 'node', args: [bin] } : { bin, args: [] }
31
+ }
32
+
33
+ /** 定位守卫 CLI(按可用性取第一个):
34
+ * 1. 插件上两级(项目根)的 bin/gitflow-guard.mjs —— 项目级落位时插件在 <项目>/.opencode/plugins/;
35
+ * 2. $OPENCODE_PROJECT_DIR/bin/gitflow-guard.mjs —— 客户端展开的项目目录兜底;
36
+ * 3. GITFLOW_GUARD_BIN 显式指定;
37
+ * 4. PATH 上的 gitflow-guard —— 全局安装场景。 */
38
+ function guardTarget(): GuardTarget | null {
39
+ const projectRoot = resolve(dirname(dirname(PLUGIN_DIR)))
40
+ const local = join(projectRoot, 'bin', 'gitflow-guard.mjs')
41
+ if (existsSync(local)) return makeTarget(local)
42
+ const envProject = process.env.OPENCODE_PROJECT_DIR
43
+ if (envProject) {
44
+ const viaEnv = join(envProject, 'bin', 'gitflow-guard.mjs')
45
+ if (existsSync(viaEnv)) return makeTarget(viaEnv)
46
+ }
47
+ if (process.env.GITFLOW_GUARD_BIN) return makeTarget(process.env.GITFLOW_GUARD_BIN)
48
+ return makeTarget('gitflow-guard')
49
+ }
50
+
51
+ /** 运行守卫 check; exit 2 = 拒绝(抛错阻断工具), 其余一律放行(fail-open) */
52
+ async function runGuard(command: string): Promise<void> {
53
+ const target = guardTarget()
54
+ if (!target) {
55
+ console.error('[gitflow-guard] guard CLI not found — allowing tool call (fail-open)')
56
+ return
57
+ }
58
+ const args = [...target.args, 'check', '--platform', 'opencode', '--command', command]
59
+ let stderr = ''
60
+ // 对象属性承载 spawn 错误: TS 对回调内赋值的局部变量不做跨闭包窄化(会推断为 never)
61
+ const outcome: { spawnError?: Error } = {}
62
+ const code = await new Promise<number>((resolvePromise) => {
63
+ const child = spawn(target.bin, args, { stdio: ['ignore', 'pipe', 'pipe'] })
64
+ let settled = false
65
+ child.stderr.on('data', (d: Buffer) => (stderr += d.toString()))
66
+ child.on('error', (e) => {
67
+ // spawn 失败等内部故障: fail-open, 不阻断工具管道; 告警统一在尾部输出(避免双打)
68
+ if (settled) return
69
+ settled = true
70
+ outcome.spawnError = e
71
+ resolvePromise(-1)
72
+ })
73
+ child.on('close', (code) => {
74
+ if (settled) return
75
+ settled = true
76
+ resolvePromise(code ?? -1)
77
+ })
78
+ })
79
+ if (code === 2) throw new Error(stderr.trim() || `[gitflow-guard] blocked: ${command}`)
80
+ if (code !== 0) {
81
+ const spawnMsg = outcome.spawnError?.message
82
+ if (spawnMsg) console.error(`[gitflow-guard] cannot spawn guard: ${spawnMsg} — allowing tool call (fail-open)`)
83
+ else console.error(`[gitflow-guard] check exited ${code} — allowing tool call (fail-open): ${stderr.trim()}`)
84
+ }
85
+ }
86
+
87
+ /** OpenCode 插件工厂(官方要求导出函数): 订阅 tool.execute.before, 仅拦 bash/powershell */
88
+ export default async function gitflowGuardPlugin() {
89
+ return {
90
+ 'tool.execute.before': async (input: { tool?: string; args?: Record<string, unknown> }, output: { args?: Record<string, unknown> }) => {
91
+ // 只拦截命令执行面(bash/powershell); 其余工具(read/edit 等)不经过守卫
92
+ if (input.tool !== 'bash' && input.tool !== 'powershell') return
93
+ const command = typeof output.args?.command === 'string' ? output.args.command : ''
94
+ if (!command) return
95
+ await runGuard(command)
96
+ },
97
+ }
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-gitflow-guard",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "description": "A configurable branch-role guard for AI coding agents (DSH / Claude Code / Codex / OpenCode / Antigravity / Pi) — zero-config defaults (develop+main) out of the box, deep-merge override, and per-client native hooks via gitflow-guard wire/setup; integration/preview/production/archive, each with its own update rules",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "bin",
20
20
  "patch.yml",
21
21
  "pi",
22
+ "opencode",
22
23
  "README.md",
23
24
  "README.zh.md"
24
25
  ],