agents-gitflow-guard 0.0.2 → 0.0.4

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
@@ -2,7 +2,8 @@
2
2
 
3
3
  > **Are you tired of agents skipping your GitFlow?**
4
4
 
5
- A configurable branch-role guard for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH). You define your own branches —
5
+ A configurable branch-role guard for AI coding agents — [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH), Claude Code, and Codex.
6
+ You define your own branches —
6
7
  **integration** (features merge in via PR/MR), **preview** (env endpoints), **production**, **archive** — each with its own update rules. Agents can't skip the flow, and sensitive merges stay in your hands.
7
8
 
8
9
  [中文文档](README.zh.md) · [License](LICENSE)
@@ -48,8 +49,8 @@ dsh plugin --profile web add agents-gitflow-guard
48
49
  "enabled": true,
49
50
  "featurePattern": "feature/[\\w-]+",
50
51
  "branches": {
51
- "integration": ["develop"], // 集成分支: feature PR 合入, 受保护
52
- "archive": ["main"] // 归档分支: 发布后由你亲手合入
52
+ "integration": ["develop"], // integration: features merge in via PR, protected
53
+ "archive": ["main"] // archive: archived by you after release
53
54
  }
54
55
  }
55
56
  ```
@@ -59,11 +60,11 @@ This one file is the entire setup: `integration` is the **only required** role;
59
60
  **Step 3 — verify.** Ask the agent (or run in a DSH session) to `git push origin develop`. Expect the tool call to be denied:
60
61
 
61
62
  ```text
62
- Error: [gitflow-guard] 已拦截: 受保护分支「develop」禁止直推
63
- 下一步: 集成分支(develop) PR/MR 合入 feature: 先推 feature 分支, gh pr create --base develop / glab mr create --target-branch develop
63
+ Error: [gitflow-guard] blocked: Protected branch "develop" forbids direct push
64
+ 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`.
64
65
  ```
65
66
 
66
- The block message is currently Chinese by default (localization is on the [Roadmap](#roadmap)); in English it means: *blocked: protected branch `develop` direct push forbidden. Next: integration branch is updated by PR/MR — push the feature branch first, then open a PR/MR into `develop`.*
67
+ Messages are English by default; add `"locale": "zh"` to the config to switch to Chinese (see [Configuration Reference](#configuration-reference)).
67
68
 
68
69
  **Done.** The guard is live for this repo. Keep reading for the [Configuration](#configuration-reference) to map your own branches, or the [Gate Matrix](#gate-matrix--what-gets-blocked-what-passes) for the full decision table.
69
70
 
@@ -189,12 +190,12 @@ No plugin code decides "is this merge OK?" for production or archive. The gate s
189
190
  Only **`integration`** is required. Every other role is optional — configure what your flow actually uses, and each entry is an exact branch name **or** a regex pattern.
190
191
 
191
192
  ```text
192
- feature branches ──(free)──> integration(集成分支, PR/MR 合入)
193
+ feature branches ──(free)──> integration (integration branch; updates via PR/MR)
193
194
 
194
- ├──> preview(可选, 环境终点, PR/MR 更新)
195
+ ├──> preview (optional; env endpoints; updates via PR/MR)
195
196
 
196
- └──> production(可选, PR/MR + 只有你能点合并)
197
- archive(可选, 发布后你亲手归档)
197
+ └──> production (optional; PR/MR + only you click merge)
198
+ archive (optional; you archive after release)
198
199
  ```
199
200
 
200
201
  | role | config key | required? | enforced behavior |
@@ -207,7 +208,7 @@ archive(可选, 发布后你亲手归档)
207
208
 
208
209
  ### Customizing branch names & rules — any naming works
209
210
 
210
- **小团队(个人 / 2-3 人)—— 最简,只有 integration:**
211
+ **Small team (solo / 23 devs) minimal: integration only:**
211
212
 
212
213
  ```jsonc
213
214
  {
@@ -217,7 +218,7 @@ archive(可选, 发布后你亲手归档)
217
218
  }
218
219
  ```
219
220
 
220
- **大团队(多预览环境 + 生产 + 归档):**
221
+ **Larger team (multiple preview envs + production + archive):**
221
222
 
222
223
  ```jsonc
223
224
  {
@@ -251,6 +252,7 @@ archive(可选, 发布后你亲手归档)
251
252
  "production": { "branches": ["prd"], "update": "pr", "mergeBy": "user" }, // optional
252
253
  "archive": ["main"] // optional
253
254
  },
255
+ "locale": "en", // optional: message language ('en' default, or 'zh')
254
256
  "ci": { "enabled": true } // optional: gh pr checks logged as reference
255
257
  }
256
258
  ```
@@ -259,6 +261,7 @@ archive(可选, 发布后你亲手归档)
259
261
  - `update`: `pr` (default) = updates only via PR/MR; `flexible` = allow direct/local merges (small teams).
260
262
  - `mergeBy` (production): `user` (default) = only you click merge; `anyone` = allow PR merge through.
261
263
  - Each branch entry is an exact name or a regex (auto-detected).
264
+ - **Language**: messages are English by default; add `"locale": "zh"` for Chinese.
262
265
  - **Validation**: `integration` is required; overlapping role entries are rejected; invalid regex is rejected. **Any error disables the plugin for that project** (reported) rather than applying a half-guessed setup.
263
266
 
264
267
  ---
@@ -306,9 +309,10 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
306
309
 
307
310
  The package declares `dsh.bundle.patch`, so `dsh plugin add` automatically makes it a profile layer — no manual profile editing.
308
311
 
309
- **Claude Code hook** — the same guard inside Claude Code, no DSH required. This repo already ships the config at `.claude/settings.json`; for any other repo, add these hooks:
312
+ **Claude Code / Codex hooks** — the same guard inside those agents, no DSH required. This repo ships project configs at `.claude/settings.json` (Claude Code) and `.codex/hooks.json` (Codex); any other repo adds its own:
310
313
 
311
- ```json
314
+ ```jsonc
315
+ // Claude Code — .claude/settings.json
312
316
  {
313
317
  "hooks": {
314
318
  "PreToolUse": [
@@ -318,9 +322,20 @@ The package declares `dsh.bundle.patch`, so `dsh plugin add` automatically makes
318
322
  }
319
323
  ```
320
324
 
321
- - The hook reads the payload on stdin and answers `exit 0` (allow) or `exit 2` (block — stderr is the reason shown to the model, plus a "next step" hint).
325
+ ```jsonc
326
+ // Codex — .codex/hooks.json
327
+ {
328
+ "hooks": {
329
+ "PreToolUse": [
330
+ { "matcher": "^Bash$", "hooks": [{ "type": "command", "command": "node bin/gitflow-guard.mjs check --platform codex" }] }
331
+ ]
332
+ }
333
+ }
334
+ ```
335
+
336
+ - The hook reads the payload on stdin and answers with that platform's protocol: Claude Code → `exit 2` (stderr is the reason + "next step" hint); Codex → JSON `{"hookSpecificOutput":{"permissionDecision":"deny",...}}` on stdout (Codex also accepts the legacy `{"decision":"block","reason":...}` shape, which `--platform antigravity` uses).
322
337
  - Only `PreToolUse` is needed: the guard blocks *before* the command runs. There is no permit to consume afterwards, so no `PostToolUse` hooks are required.
323
- - Use an **absolute path** to the binary — hook subprocesses may not inherit your shell `PATH`. `${CLAUDE_PROJECT_DIR}/bin/gitflow-guard.mjs` (after `npm run build`) also works from a checkout.
338
+ - Use an **absolute path** to the binary — hook subprocesses may not inherit your shell `PATH`. `${CLAUDE_PROJECT_DIR}/bin/gitflow-guard.mjs` (Claude Code) or `node bin/gitflow-guard.mjs` (Codex, runs from the project working directory) also work from a checkout.
324
339
  - Fully opt-in: the hook does nothing unless the repo has `gitflow-guard.config.json` with `enabled: true`.
325
340
 
326
341
  ---
@@ -409,7 +424,7 @@ If it saves your team from a shortcut gone wrong, the coffee button at the top o
409
424
 
410
425
  ## Roadmap
411
426
 
412
- - **i18n — localized block messages**: deny messages are Chinese by default today; make them follow the user's language (and the plugin config).
427
+ - **i18n — localized block messages** (0.0.3): English by default, `"locale": "zh"` for Chinese.
413
428
  - **v2 — audit sync**: sync `.git/gitflow-guard/audit.jsonl` across machines (audit is local-only today).
414
429
  - **v2 — more pre-built templates**: ready-made config templates for common flows (solo `develop`, multi-env enterprise) as community-contributed presets.
415
430
  - **v2 — CI hard-gating research**: whether `pr checks` could become a real gate without hurting the platform-agnostic core.
@@ -433,9 +448,12 @@ npm install
433
448
  npm test # unit tests: classify / gate / config / cli / repo / platform
434
449
  npm run typecheck # tsc --noEmit, 0 errors
435
450
  npm run build # tsdown → lib/ (CLI and plugin share the build)
451
+ npm run verify:matrix # continuous cross-agent regression: DSH logic + Claude Code / Codex / antigravity hook wiring
436
452
  ```
437
453
 
438
- **Rule**: any logic change must pass a 0-error build + all green tests before done.
454
+ **Rule**: any logic change must pass a 0-error build + all green tests + a green `verify:matrix` before done.
455
+
456
+ **Adding a new agent client** (e.g. Gemini / OpenCode / Cursor): all of these must change in one commit — `src/platform.ts` (+tests, `HookPlatform` union), a repo hook config beside `.claude/settings.json` / `.codex/hooks.json`, `.agents/hooks/references/<tool>.md`, `scripts/verify-matrix.mjs`, the README hook section and the top tagline, `package.json` description/keywords, and `CHANGELOG`. Done only when `npm run verify:matrix` is green. (Same checklist in [AGENTS.md](AGENTS.md) §8.)
439
457
 
440
458
  ---
441
459
 
package/README.zh.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **有没有受够了 agent 跳过你的合入流程?**
4
4
 
5
- 一个可自由配置分支角色守卫的 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)插件。
5
+ 一个可自由配置分支角色的流程守卫,为 AI 编码 agent 而生——[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)、Claude Code、Codex。
6
6
  你自己定义分支——**集成分支**(feature 经 PR/MR 合入)、**预览分支**(环境终点)、**生产分支**、**归档分支**——每个角色各自配规则。agent 无法跳过流程,敏感合并始终留在你手上。
7
7
 
8
8
  [English](README.md) · [许可证](LICENSE)
@@ -59,11 +59,11 @@ dsh plugin --profile web add agents-gitflow-guard
59
59
  **第 3 步——验证**。让 agent 执行 `git push origin develop`,预期工具调用被拒绝:
60
60
 
61
61
  ```text
62
- Error: [gitflow-guard] 已拦截: 受保护分支「develop」禁止直推
63
- 下一步: 集成分支(develop) PR/MR 合入 feature: 先推 feature 分支, gh pr create --base develop / glab mr create --target-branch develop
62
+ Error: [gitflow-guard] blocked: Protected branch "develop" forbids direct push
63
+ 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`.
64
64
  ```
65
65
 
66
- 拦截文案目前默认是中文(本地化在[路线图](#路线图));英文意思是:*blocked: protected branch `develop` direct push forbidden. Next: integration branch is updated by PR/MR push the feature branch first, then open a PR/MR into `develop`.*
66
+ **文案默认是英文**(面向国际化)。要在你的项目里看中文,在 `gitflow-guard.config.json` 里加 `"locale": "zh"`;中文效果是:*已拦截:受保护分支「develop」禁止直推 / 下一步:集成分支(develop)由 PR/MR 合入 feature……*
67
67
 
68
68
  **完成。** 守卫对该仓库生效。继续往下看[配置参考](#配置参考)映射自己的分支,或看[门禁矩阵](#门禁矩阵拦什么放什么)的完整判定表。
69
69
 
@@ -249,6 +249,7 @@ archive(可选, 发布后你亲手归档)
249
249
  "production": { "branches": ["prd"], "update": "pr", "mergeBy": "user" }, // 可选
250
250
  "archive": ["main"] // 可选
251
251
  },
252
+ "locale": "en", // 可选: 文案语言('en' 默认, 或 'zh')
252
253
  "ci": { "enabled": true } // 可选: gh pr checks 作参考日志
253
254
  }
254
255
  ```
@@ -257,6 +258,7 @@ archive(可选, 发布后你亲手归档)
257
258
  - `update`:`pr`(默认)= 只能 PR/MR 合入;`flexible` = 允许直推/本地合入(小团队)。
258
259
  - `mergeBy`(生产):`user`(默认)= 只能你点合并;`anyone` = 放行 PR 合并。
259
260
  - 每条分支条目是精确名或正则(自动识别)。
261
+ - **文案语言**:默认英文;加 `"locale": "zh"` 切中文。
260
262
  - **校验**:`integration` 必填;角色条目重叠会被拒;非法正则会报错。**任何错误都会让该项目的插件禁用并上报**(而不是用半吊子配置)。
261
263
 
262
264
  ---
@@ -304,9 +306,10 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
304
306
 
305
307
  包自带 `dsh.bundle.patch` 声明,`dsh plugin add` 自动把它挂为 profile 层,无需手工编辑 profile。
306
308
 
307
- **Claude Code hook**——同一守卫也能在 Claude Code 里跑,不依赖 DSH。本仓库已自带 `.claude/settings.json`;其他仓库加这些 hooks:
309
+ **Claude Code / Codex hook**——同一守卫也能在这些 agent 里跑,不依赖 DSH。本仓库已自带 `.claude/settings.json`(Claude Code)和 `.codex/hooks.json`(Codex);其他仓库加自己的 hooks:
308
310
 
309
- ```json
311
+ ```jsonc
312
+ // Claude Code — .claude/settings.json
310
313
  {
311
314
  "hooks": {
312
315
  "PreToolUse": [
@@ -316,9 +319,20 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
316
319
  }
317
320
  ```
318
321
 
319
- - hook 读 stdin payload,按 `exit 0`(放行)/ `exit 2`(拦截,stderr 展示给模型的原因 + "下一步"提示)作答。
322
+ ```jsonc
323
+ // Codex — .codex/hooks.json
324
+ {
325
+ "hooks": {
326
+ "PreToolUse": [
327
+ { "matcher": "^Bash$", "hooks": [{ "type": "command", "command": "node bin/gitflow-guard.mjs check --platform codex" }] }
328
+ ]
329
+ }
330
+ }
331
+ ```
332
+
333
+ - hook 读 stdin payload,按**各平台协议**作答:Claude Code → `exit 2`(stderr 展示原因 + "下一步"提示);Codex → stdout 输出 JSON `{"hookSpecificOutput":{"permissionDecision":"deny",...}}`(Codex 也兼容旧的 `{"decision":"block","reason":...}` 形状,`--platform antigravity` 用的就是它)。
320
334
  - 只需要 `PreToolUse`:守卫在命令执行*之前*拦截;没有特许可事后消费,因此无需 `PostToolUse` 钩子。
321
- - 用**绝对路径**指向二进制——hook 子进程不一定继承你的 shell PATH。`${CLAUDE_PROJECT_DIR}/bin/gitflow-guard.mjs`(`npm run build` )也可以。
335
+ - 用**绝对路径**指向二进制——hook 子进程不一定继承你的 shell PATH。Claude Code 用 `${CLAUDE_PROJECT_DIR}/bin/gitflow-guard.mjs`,Codex `node bin/gitflow-guard.mjs`(在项目目录执行)也可以。
322
336
  - 完全 opt-in:仓库没有 `gitflow-guard.config.json`(或 `enabled` 非 true)时 hook 什么都不做。
323
337
 
324
338
  ---
@@ -407,7 +421,7 @@ MIT,免费,无条件。随便用、随便改、随便发,唯一义务是保留
407
421
 
408
422
  ## 路线图
409
423
 
410
- - **i18n——拦截文案本地化**:目前默认中文;让它跟随用户语言(和插件配置)
424
+ - **i18n——拦截文案本地化** ✅(0.0.3):默认英文,`"locale": "zh"` 切中文。
411
425
  - **v2——审计同步**:跨机器同步 `.git/gitflow-guard/audit.jsonl`(现仅本地)。
412
426
  - **v2——更多预制模板**:常用流程(solo `develop`、多环境企业)的现成配置模板,由社区贡献。
413
427
  - **v2——CI 硬门槛研究**:`pr checks` 能否在不伤平台无关核心的前提下变成真实门槛。
package/lib/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { c as findRepoRoot, d as roleMatches, f as classify, i as formatDeny, l as gitRunner, o as stateDir, r as evaluateCommand, s as currentBranch, u as loadConfig } from "./src-Dv6jKDsO.mjs";
1
+ import { c as findRepoRoot, d as resolveLocale, f as loadConfig, i as formatDeny, l as gitRunner, m as classify, o as stateDir, p as roleMatches, r as evaluateCommand, s as currentBranch, u as makeT } from "./src-LvZrsHEn.mjs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  //#region src/platform.ts
@@ -84,17 +84,6 @@ function encodeDeny(platform, reason) {
84
84
  }
85
85
  //#endregion
86
86
  //#region src/cli.ts
87
- const USAGE = `gitflow-guard — GitFlow 流程守卫 CLI
88
-
89
- 用法:
90
- gitflow-guard status [--repo <路径>]
91
- gitflow-guard audit [--lines <数量>] [--repo <路径>]
92
- gitflow-guard check [--platform <claude|auto>] [--command "<cmd>"] [--repo <路径>]
93
- gitflow-guard --help
94
-
95
- 说明:
96
- status/audit 只读, agent 可自查。
97
- check 读 stdin hook payload 做门禁(exit 0=放行 / 2=拦截), 供 Claude Code 等 agent 的 pre/post hook 调用。`;
98
87
  function parseFlags(argv) {
99
88
  const flags = {};
100
89
  for (let i = 0; i < argv.length; i++) {
@@ -117,9 +106,10 @@ async function resolveRepo(flags) {
117
106
  }
118
107
  async function main(argv, opts = {}) {
119
108
  const runner = opts.runner ?? gitRunner;
109
+ const usage = makeT("en")("usage.text");
120
110
  const [cmd, ...rest] = argv;
121
111
  if (cmd === "--help" || cmd === "help" || cmd === void 0) {
122
- console.log(USAGE);
112
+ console.log(usage);
123
113
  return 0;
124
114
  }
125
115
  const flags = parseFlags(rest);
@@ -127,7 +117,7 @@ async function main(argv, opts = {}) {
127
117
  if (cmd === "status") return await status(flags, runner);
128
118
  if (cmd === "audit") return await audit(flags);
129
119
  if (cmd === "check") return await check(flags);
130
- console.error(`[gitflow-guard] 未知子命令: ${cmd ?? ""}\n\n${USAGE}`);
120
+ console.error(`${makeT("en")("cli.unknownCommand", { cmd: cmd ?? "" })}\n\n${usage}`);
131
121
  return 1;
132
122
  } catch (e) {
133
123
  console.error(`[gitflow-guard] ${e.message}`);
@@ -137,25 +127,36 @@ async function main(argv, opts = {}) {
137
127
  async function status(flags, runner) {
138
128
  const repoRoot = await resolveRepo(flags);
139
129
  if (!repoRoot) {
140
- console.error("[gitflow-guard] 无法定位 git 仓库");
130
+ console.error(makeT("en")("cli.cannotLocate"));
141
131
  return 1;
142
132
  }
143
133
  const { config, errors } = await loadConfig(repoRoot);
144
134
  const enabled = config?.enabled === true;
145
- console.log(`[gitflow-guard] status ${repoRoot}`);
135
+ const t = makeT(resolveLocale(config?.locale));
136
+ console.log(t("cli.statusTitle", { repo: repoRoot }));
146
137
  if (!enabled) {
147
- console.log("配置: 未启用(不存在 gitflow-guard.config.json 或 enabled=false)");
148
- for (const e of errors) console.log(` 配置错误: ${e}`);
138
+ console.log(t("cli.statusDisabled"));
139
+ for (const e of errors) console.log(t("cli.statusConfigError", { err: e }));
149
140
  return 0;
150
141
  }
151
142
  const branch = await currentBranch(runner, repoRoot);
152
143
  const c = config;
153
- console.log(`配置: 已启用 | featurePattern: ${c.featurePattern}`);
154
- console.log(`集成分支: ${c.branches.integration.branches.join(", ")} (update=${c.branches.integration.update || "pr"})`);
155
- if (c.branches.preview) console.log(`预览分支: ${c.branches.preview.branches.join(", ")} (update=${c.branches.preview.update || "pr"})`);
156
- if (c.branches.production) console.log(`生产分支: ${c.branches.production.branches.join(", ")} (update=${c.branches.production.update || "pr"}, 合并=${c.branches.production.mergeBy || "user"})`);
157
- if (c.branches.archive) console.log(`归档分支: ${c.branches.archive.branches.join(", ")}`);
158
- console.log(`当前分支: ${branch ?? "(未知)"}`);
144
+ console.log(t("cli.statusEnabled", { pattern: c.featurePattern }));
145
+ console.log(t("cli.statusIntegration", {
146
+ list: c.branches.integration.branches.join(", "),
147
+ mode: c.branches.integration.update || "pr"
148
+ }));
149
+ if (c.branches.preview) console.log(t("cli.statusPreview", {
150
+ list: c.branches.preview.branches.join(", "),
151
+ mode: c.branches.preview.update || "pr"
152
+ }));
153
+ if (c.branches.production) console.log(t("cli.statusProduction", {
154
+ list: c.branches.production.branches.join(", "),
155
+ mode: c.branches.production.update || "pr",
156
+ merge: c.branches.production.mergeBy || "user"
157
+ }));
158
+ if (c.branches.archive) console.log(t("cli.statusArchive", { list: c.branches.archive.branches.join(", ") }));
159
+ console.log(t("cli.statusCurrentBranch", { branch: branch ?? t("cli.statusUnknownBranch") }));
159
160
  const r = await runner.run([
160
161
  "for-each-ref",
161
162
  "--format=%(refname:short)",
@@ -170,14 +171,14 @@ async function status(flags, runner) {
170
171
  if (new RegExp(c.featurePattern).test(b)) return "feature";
171
172
  return "other";
172
173
  };
173
- console.log("本地分支(按角色):");
174
+ console.log(t("cli.statusLocalBranches"));
174
175
  for (const b of localBranches) console.log(` ${b} → ${classifyBranch(b)}`);
175
176
  return 0;
176
177
  }
177
178
  async function audit(flags) {
178
179
  const repoRoot = await resolveRepo(flags);
179
180
  if (!repoRoot) {
180
- console.error("[gitflow-guard] 无法定位 git 仓库");
181
+ console.error(makeT("en")("cli.cannotLocate"));
181
182
  return 1;
182
183
  }
183
184
  const lines = flags.lines != null && Number.isFinite(flags.lines) && flags.lines > 0 ? Math.floor(flags.lines) : 20;
@@ -190,7 +191,7 @@ async function audit(flags) {
190
191
  console.log(` ${line}`);
191
192
  }
192
193
  } catch {
193
- console.log(" 暂无审计记录");
194
+ console.log(makeT("en")("cli.auditEmpty"));
194
195
  }
195
196
  return 0;
196
197
  }
@@ -222,16 +223,17 @@ async function check(flags) {
222
223
  const { config } = await loadConfig(repoRoot);
223
224
  if (!config?.enabled) return 0;
224
225
  const hookPlatform = platform === "auto" ? detectPlatform(raw) : platform;
226
+ const locale = resolveLocale(config.locale);
225
227
  const result = await evaluateCommand(payload.command, { repoRoot });
226
228
  if (result.outcome === "deny" && result.reason) {
227
- const enc = encodeDeny(hookPlatform, formatDeny(result.reason.why, result.reason.next));
229
+ const enc = encodeDeny(hookPlatform, formatDeny(locale, result.reason.why, result.reason.next));
228
230
  if (enc.stdout) process.stdout.write(enc.stdout + "\n");
229
231
  if (enc.stderr) process.stderr.write(enc.stderr + "\n");
230
232
  return enc.exitCode;
231
233
  }
232
234
  return 0;
233
235
  } catch (e) {
234
- process.stderr.write(`[gitflow-guard] check 内部错误, 已放行: ${e.message}\n`);
236
+ process.stderr.write(`${makeT("en")("cli.checkInternalError", { msg: e.message })}\n`);
235
237
  return 0;
236
238
  }
237
239
  }
package/lib/index.d.mts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { t as Runner } from "./repo-DrgptHl1.mjs";
2
2
  import { Context } from "@deepseek-ai/cordis";
3
+ //#region src/types.d.ts
4
+ /** 文案语言: 默认 en; 'zh' 切中文 */
5
+ type Locale = 'en' | 'zh';
6
+ //#endregion
3
7
  //#region src/index.d.ts
4
8
  declare const name = "gitflow-guard";
5
9
  interface PluginConfig {
@@ -23,6 +27,8 @@ interface EvaluateResult {
23
27
  next: string;
24
28
  };
25
29
  segmentCount: number;
30
+ /** 本次评估使用的文案语言(供 formatDeny/审计一致) */
31
+ locale: Locale;
26
32
  }
27
33
  interface AuditEntry {
28
34
  time: number;
@@ -36,7 +42,7 @@ declare function stateDir(repoRoot: string): string;
36
42
  declare function appendAudit(repoRoot: string, entry: AuditEntry): Promise<void>;
37
43
  /** 解析一条命令: 分类 → git 事实 → 门禁 → allow/deny */
38
44
  declare function evaluateCommand(command: string, opts: EvaluateOptions): Promise<EvaluateResult>;
39
- declare function formatDeny(why: string, next: string): string;
45
+ declare function formatDeny(locale: Locale, why: string, next: string): string;
40
46
  declare function apply(ctx: Context, pluginConfig?: PluginConfig): void;
41
47
  //#endregion
42
48
  export { AuditEntry, EvaluateOptions, EvaluateResult, PluginConfig, appendAudit, apply, evaluateCommand, formatDeny, name, stateDir };
package/lib/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as name, i as formatDeny, n as apply, o as stateDir, r as evaluateCommand, t as appendAudit } from "./src-Dv6jKDsO.mjs";
1
+ import { a as name, i as formatDeny, n as apply, o as stateDir, r as evaluateCommand, t as appendAudit } from "./src-LvZrsHEn.mjs";
2
2
  export { appendAudit, apply, evaluateCommand, formatDeny, name, stateDir };
@@ -217,7 +217,8 @@ const CONFIG_FILE = "gitflow-guard.config.json";
217
217
  const DEFAULT_CONFIG = {
218
218
  enabled: false,
219
219
  featurePattern: "feature/[\\w-]+",
220
- ci: { enabled: true }
220
+ ci: { enabled: true },
221
+ locale: "en"
221
222
  };
222
223
  const REGEX_CHARS = /[\\^$.*+?()[\]{}|]/;
223
224
  /** 一条分支条目: 含正则元字符按正则对待, 否则精确匹配 */
@@ -248,14 +249,14 @@ function normalizeRole(raw, defaultUpdate, defaultMergeBy) {
248
249
  mergeBy = o.mergeBy;
249
250
  } else return {
250
251
  role: { branches: [] },
251
- errors: ["分支角色必须是数组或 { branches: [...] }"]
252
+ errors: ["Branch role must be an array or { branches: [...] }"]
252
253
  };
253
- if (!Array.isArray(arr) || arr.length === 0 || !arr.every((x) => typeof x === "string" && x !== "")) errors.push("branches 必须是非空字符串数组");
254
+ if (!Array.isArray(arr) || arr.length === 0 || !arr.every((x) => typeof x === "string" && x !== "")) errors.push("branches must be a non-empty array of strings");
254
255
  const role = { branches: (Array.isArray(arr) ? arr : []).filter((x) => typeof x === "string" && x !== "") };
255
256
  if (update === void 0 || update === "pr" || update === "flexible") role.update = update === void 0 ? defaultUpdate : update;
256
- else errors.push("update 必须是 \"pr\" \"flexible\"");
257
+ else errors.push("update must be \"pr\" or \"flexible\"");
257
258
  if (mergeBy === void 0 || mergeBy === "user" || mergeBy === "anyone") role.mergeBy = mergeBy === void 0 ? defaultMergeBy : mergeBy;
258
- else errors.push("mergeBy 必须是 \"user\" \"anyone\"");
259
+ else errors.push("mergeBy must be \"user\" or \"anyone\"");
259
260
  return {
260
261
  role,
261
262
  errors
@@ -266,7 +267,7 @@ function mergeConfig(raw) {
266
267
  const errors = [];
267
268
  if (typeof raw !== "object" || raw === null) return {
268
269
  config: null,
269
- errors: ["配置文件必须是 JSON 对象"]
270
+ errors: ["Config file must be a JSON object"]
270
271
  };
271
272
  const r = raw;
272
273
  const config = {
@@ -280,12 +281,14 @@ function mergeConfig(raw) {
280
281
  };
281
282
  if (typeof r.enabled === "boolean") config.enabled = r.enabled;
282
283
  if (typeof r.featurePattern === "string" && r.featurePattern !== "") config.featurePattern = r.featurePattern;
284
+ if (r.locale === "en" || r.locale === "zh") config.locale = r.locale;
285
+ else if (r.locale !== void 0) errors.push("locale must be \"en\" or \"zh\"");
283
286
  const b = r.branches ?? {};
284
287
  if ("integration" in b) {
285
288
  const { role, errors: e } = normalizeRole(b.integration, "pr", "anyone");
286
289
  config.branches.integration = role;
287
290
  errors.push(...e);
288
- } else errors.push("branches.integration 必填");
291
+ } else errors.push("branches.integration is required");
289
292
  if (b.preview !== void 0) {
290
293
  const { role, errors: e } = normalizeRole(b.preview, "pr", "anyone");
291
294
  config.branches.preview = role;
@@ -312,11 +315,11 @@ function mergeConfig(raw) {
312
315
  /** 配置校验: 角色分支重叠 / 正则合法等 */
313
316
  function validateConfig(config) {
314
317
  const errors = [];
315
- if (config.branches.integration.branches.length === 0) errors.push("branches.integration.branches 必填");
318
+ if (config.branches.integration.branches.length === 0) errors.push("branches.integration.branches is required");
316
319
  try {
317
320
  new RegExp(config.featurePattern);
318
321
  } catch {
319
- errors.push(`featurePattern 不是合法正则: ${config.featurePattern}`);
322
+ errors.push(`featurePattern is not a valid regex: ${config.featurePattern}`);
320
323
  }
321
324
  const allRoles = [
322
325
  "integration",
@@ -330,7 +333,7 @@ function validateConfig(config) {
330
333
  for (let j = i + 1; j < allRoles.length; j++) {
331
334
  const bb = config.branches[allRoles[j]];
332
335
  if (!bb) continue;
333
- if (a.branches.some((s) => bb.branches.includes(s))) errors.push(`branches.${allRoles[i]} branches.${allRoles[j]} 含有相同条目`);
336
+ if (a.branches.some((s) => bb.branches.includes(s))) errors.push(`branches.${allRoles[i]} and branches.${allRoles[j]} share the same entries`);
334
337
  }
335
338
  }
336
339
  return errors;
@@ -347,19 +350,183 @@ async function loadConfig(repoRoot) {
347
350
  };
348
351
  return {
349
352
  config: null,
350
- errors: [`读取配置文件失败: ${e.message}`]
353
+ errors: [`Failed to read config file: ${e.message}`]
351
354
  };
352
355
  }
353
356
  }
354
357
  //#endregion
358
+ //#region src/i18n.ts
359
+ const en = {
360
+ "role.integration": () => "integration branch",
361
+ "role.preview": () => "preview branch",
362
+ "role.production": () => "production branch",
363
+ "role.archive": () => "archive branch",
364
+ "role.feature": () => "feature branch",
365
+ "role.other": () => "other branch",
366
+ "head.unknown": () => "current branch",
367
+ "denyDeleteOrForce.why": (v) => `Protected branch "${v.branch}" may not be deleted or force-pushed`,
368
+ "denyDeleteOrForce.next": () => "Delete/force-push on a feature branch outside the protected branches; protected branches are managed by you.",
369
+ "pushAll.why": () => "--all/--mirror push would include protected branches",
370
+ "pushAll.next": () => "Push branch by branch with an explicit refspec.",
371
+ "pushDetached.why": () => "Cannot determine the push target (detached HEAD?)",
372
+ "pushDetached.next": () => "Specify the refspec explicitly, e.g. `git push origin <branch>`.",
373
+ "pushProtectedDelete.why": (v) => `Protected branch "${v.branch}" may not be deleted`,
374
+ "pushProtectedDirect.why": (v) => `Protected branch "${v.branch}" forbids direct push`,
375
+ "pushProtectedDirectForce.why": (v) => `Protected branch "${v.branch}" forbids direct push (force)`,
376
+ "mergeProtected.why": (v) => `Merging into ${v.role} is allowed only by the user`,
377
+ "mergeProtected.next": () => `Do the merge in your own terminal (or UI); the agent can't do it for you.`,
378
+ "mergeFeature.why": (v) => `${v.role} forbids local merge of a feature: use PR/MR`,
379
+ "mergeFeature.next": (v) => `Push the feature branch first, then open a PR/MR into ${v.branch}.`,
380
+ "prCreateNoTarget.why": () => "Cannot determine the PR/MR target branch",
381
+ "prCreateNoTarget.next": (v) => `Specify --base/--target-branch explicitly (e.g. \`gh pr create --base ${v.base}\`).`,
382
+ "prCreateArchive.why": () => "Archive branches are user-managed; PRs/MRs into them are not allowed",
383
+ "prCreateArchive.next": () => "Do the release/archive yourself in your terminal or UI.",
384
+ "prCreateHead.why": (v) => `Current branch (${v.head}) is not a feature branch, so it cannot be the source of a PR/MR into ${v.role}`,
385
+ "prCreateHead.next": () => "Open the PR/MR into integration/preview/production from a feature/topic branch.",
386
+ "prMergeProduction.why": () => "Merging into production is allowed only by you (click merge)",
387
+ "prMergeProduction.next": () => "Click merge yourself on the PR/MR page.",
388
+ "prMergeArchive.why": () => "Merging into an archive branch is allowed only by the user",
389
+ "prMergeArchive.next": () => "Let the user do the archive merge in their terminal or UI.",
390
+ "prMergeUnknown.why": () => "Cannot confirm the PR/MR target branch",
391
+ "prMergeUnknown.next": () => "Retry once gh/glab is available, or let the user handle it.",
392
+ "prMergeHead.why": () => "Cannot confirm the PR/MR target, and the head is not a feature branch",
393
+ "prMergeHead.next": () => "Confirm the platform CLI is available, or let the user handle it.",
394
+ "next.integration": (v) => `Integration branch (${v.branch}) is updated via PR/MR from a feature branch: push the feature first, then \`gh pr create --base ${v.branch}\` / \`glab mr create --target-branch ${v.branch}\`.`,
395
+ "next.preview": (v) => `Preview branch (${v.branch}) only accepts PR/MRs: open a PR/MR into it from a feature/release branch (content of ${v.base} etc. first goes into a feature release branch).`,
396
+ "next.production": (v) => `Production branch (${v.branch}) is PR/MR-only, and you click the merge yourself.`,
397
+ "next.archive": (v) => `Archive branch (${v.branch}) is user-managed only.`,
398
+ "next.unspecified": () => "Retry once the target branch is clear.",
399
+ "deny.header": (v) => `[gitflow-guard] blocked: ${v.why}`,
400
+ "deny.next": (v) => `Next: ${v.next}`,
401
+ "cli.unknownCommand": (v) => `[gitflow-guard] unknown subcommand: ${v.cmd}`,
402
+ "cli.cannotLocate": () => "Cannot locate a git repository",
403
+ "cli.statusTitle": (v) => `[gitflow-guard] status — ${v.repo}`,
404
+ "cli.statusDisabled": () => "Config: not enabled (no gitflow-guard.config.json or enabled=false)",
405
+ "cli.statusConfigError": (v) => ` config error: ${v.err}`,
406
+ "cli.statusEnabled": (v) => `Config: enabled | featurePattern: ${v.pattern}`,
407
+ "cli.statusIntegration": (v) => `Integration: ${v.list} (update=${v.mode})`,
408
+ "cli.statusPreview": (v) => `Preview: ${v.list} (update=${v.mode})`,
409
+ "cli.statusProduction": (v) => `Production: ${v.list} (update=${v.mode}, merge=${v.merge})`,
410
+ "cli.statusArchive": (v) => `Archive: ${v.list}`,
411
+ "cli.statusCurrentBranch": (v) => `Current branch: ${v.branch}`,
412
+ "cli.statusUnknownBranch": () => "(unknown)",
413
+ "cli.statusLocalBranches": () => "Local branches (by role):",
414
+ "cli.auditEmpty": () => " No audit entries yet",
415
+ "cli.checkInternalError": (v) => `[gitflow-guard] check internal error, allowed through: ${v.msg}`,
416
+ "usage.text": () => `gitflow-guard — GitFlow guard CLI
417
+
418
+ Usage:
419
+ gitflow-guard status [--repo <path>]
420
+ gitflow-guard audit [--lines <count>] [--repo <path>]
421
+ gitflow-guard check [--platform <claude|auto>] [--command "<cmd>"] [--repo <path>]
422
+ gitflow-guard --help
423
+
424
+ Notes:
425
+ status/audit are read-only; the agent can self-inspect.
426
+ check reads the hook payload on stdin (exit 0 = allow / 2 = block) and is meant for
427
+ pre/post hooks of agents such as Claude Code.`
428
+ };
429
+ const zh = {
430
+ "role.integration": () => "集成分支",
431
+ "role.preview": () => "预览分支",
432
+ "role.production": () => "生产分支",
433
+ "role.archive": () => "归档分支",
434
+ "role.feature": () => "feature 分支",
435
+ "role.other": () => "普通分支",
436
+ "head.unknown": () => "当前分支",
437
+ "denyDeleteOrForce.why": (v) => `受保护分支「${v.branch}」禁止删除或强推`,
438
+ "denyDeleteOrForce.next": () => "删除/强推请到受保护分支外的 feature 分支上操作; 受保护分支由用户亲手管理",
439
+ "pushAll.why": () => "--all/--mirror 推送会包含受保护分支",
440
+ "pushAll.next": () => "请逐分支推送并显式指定 refspec",
441
+ "pushDetached.why": () => "无法确定推送目标分支(可能处于 detached HEAD)",
442
+ "pushDetached.next": () => "请显式指定 refspec, 如 git push origin <分支名>",
443
+ "pushProtectedDelete.why": (v) => `受保护分支「${v.branch}」禁止删除`,
444
+ "pushProtectedDirect.why": (v) => `受保护分支「${v.branch}」禁止直推`,
445
+ "pushProtectedDirectForce.why": (v) => `受保护分支「${v.branch}」禁止直推(含强推)`,
446
+ "mergeProtected.why": (v) => `合入${v.role}仅允许用户亲手执行`,
447
+ "mergeProtected.next": () => "请在你自己终端(或 UI)完成该合并; agent 不能替你操作",
448
+ "mergeFeature.why": (v) => `${v.role}禁止本地合入 feature: 须通过 PR/MR`,
449
+ "mergeFeature.next": (v) => `先推 feature 分支, 再创建指向 ${v.branch} 的 PR/MR`,
450
+ "prCreateNoTarget.why": () => "无法确定 PR/MR 目标分支",
451
+ "prCreateNoTarget.next": (v) => `请显式指定 --base/--target-branch(如 gh pr create --base ${v.base})`,
452
+ "prCreateArchive.why": () => "归档分支(archive)仅用户亲手操作, 不允许创建指向它的 PR/MR",
453
+ "prCreateArchive.next": () => "发布/归档由你自己在终端或 UI 完成",
454
+ "prCreateHead.why": (v) => `当前分支(${v.head})不是 feature 分支, 不能作为指向${v.role}的 PR/MR 源`,
455
+ "prCreateHead.next": () => "请从 feature/topic 分支上创建指向集成/预览/生产分支的 PR/MR",
456
+ "prMergeProduction.why": () => "合入生产(production)分支仅允许用户亲手点合并",
457
+ "prMergeProduction.next": () => "请在 GitLab/GitHub 的 MR/PR 页面上由你本人点击合并",
458
+ "prMergeArchive.why": () => "合入归档分支(archive)仅允许用户亲手执行",
459
+ "prMergeArchive.next": () => "请让用户在自己终端或 UI 完成归档合并",
460
+ "prMergeUnknown.why": () => "无法确认 PR/MR 的目标分支",
461
+ "prMergeUnknown.next": () => "请确认 gh/glab 可用后重试, 或让用户亲手处理",
462
+ "prMergeHead.why": () => "无法确认 PR/MR 目标, 且 head 不是 feature 分支",
463
+ "prMergeHead.next": () => "请确认平台 CLI 可用, 或让用户亲手处理",
464
+ "next.integration": (v) => `集成分支(${v.branch})由 PR/MR 合入 feature: 先推 feature 分支, 再 gh pr create --base ${v.branch} / glab mr create --target-branch ${v.branch}`,
465
+ "next.preview": (v) => `预览分支(${v.branch})只收 PR/MR: 从 feature/发布分支创建指向它的 PR/MR(${v.base} 等集成分支内容先进 feature 发布分支)`,
466
+ "next.production": (v) => `生产分支(${v.branch})只能 PR/MR, 且合并由你亲手点击`,
467
+ "next.archive": (v) => `归档分支(${v.branch})仅用户亲手操作`,
468
+ "next.unspecified": () => "请明确目标分支后重试",
469
+ "deny.header": (v) => `[gitflow-guard] 已拦截: ${v.why}`,
470
+ "deny.next": (v) => `下一步: ${v.next}`,
471
+ "cli.unknownCommand": (v) => `[gitflow-guard] 未知子命令: ${v.cmd}`,
472
+ "cli.cannotLocate": () => "无法定位 git 仓库",
473
+ "cli.statusTitle": (v) => `[gitflow-guard] status — ${v.repo}`,
474
+ "cli.statusDisabled": () => "配置: 未启用(不存在 gitflow-guard.config.json 或 enabled=false)",
475
+ "cli.statusConfigError": (v) => ` 配置错误: ${v.err}`,
476
+ "cli.statusEnabled": (v) => `配置: 已启用 | featurePattern: ${v.pattern}`,
477
+ "cli.statusIntegration": (v) => `集成分支: ${v.list} (update=${v.mode})`,
478
+ "cli.statusPreview": (v) => `预览分支: ${v.list} (update=${v.mode})`,
479
+ "cli.statusProduction": (v) => `生产分支: ${v.list} (update=${v.mode}, 合并=${v.merge})`,
480
+ "cli.statusArchive": (v) => `归档分支: ${v.list}`,
481
+ "cli.statusCurrentBranch": (v) => `当前分支: ${v.branch}`,
482
+ "cli.statusUnknownBranch": () => "(未知)",
483
+ "cli.statusLocalBranches": () => "本地分支(按角色):",
484
+ "cli.auditEmpty": () => " 暂无审计记录",
485
+ "cli.checkInternalError": (v) => `[gitflow-guard] check 内部错误, 已放行: ${v.msg}`,
486
+ "usage.text": () => `gitflow-guard — GitFlow 流程守卫 CLI
487
+
488
+ 用法:
489
+ gitflow-guard status [--repo <路径>]
490
+ gitflow-guard audit [--lines <数量>] [--repo <路径>]
491
+ gitflow-guard check [--platform <claude|auto>] [--command "<cmd>"] [--repo <路径>]
492
+ gitflow-guard --help
493
+
494
+ 说明:
495
+ status/audit 只读, agent 可自查。
496
+ check 读 stdin hook payload 做门禁(exit 0=放行 / 2=拦截), 供 Claude Code 等 agent 的 pre/post hook 调用。`
497
+ };
498
+ const MESSAGE_KEYS = Object.keys(en);
499
+ /** key 合法性与 en/zh 双字典完整性检查(开发期一次) */
500
+ if (Object.keys(zh).length !== MESSAGE_KEYS.length || MESSAGE_KEYS.some((k) => !(k in zh))) throw new Error("i18n: en/zh 字典键不一致");
501
+ /**
502
+ * 生成翻译函数。未知 key 回退英文原文(开发/防御性)。
503
+ */
504
+ function makeT(locale) {
505
+ const dict = locale === "zh" ? zh : en;
506
+ return (key, vars = {}) => {
507
+ const entry = dict[key] ?? en[key];
508
+ if (!entry) return key;
509
+ try {
510
+ return entry(vars);
511
+ } catch {
512
+ return key;
513
+ }
514
+ };
515
+ }
516
+ /** 解析配置里的 locale 值: 仅 'zh' 视为中文, 其余(含未定义)一律英文 */
517
+ function resolveLocale(v) {
518
+ return v === "zh" ? "zh" : "en";
519
+ }
520
+ //#endregion
355
521
  //#region src/gate.ts
356
- const FEATURE_UNKNOWN = "当前分支";
357
522
  const PROTECTED_ROLES = /* @__PURE__ */ new Set([
358
523
  "integration",
359
524
  "preview",
360
525
  "production",
361
526
  "archive"
362
527
  ]);
528
+ /** 判别的默认英文(纯函数裸调用/测试时默认; 运行时由 evaluateCommand 按配置 locale 注入) */
529
+ const defaultT = makeT("en");
363
530
  /** 判定分支角色: 优先角色配置, 其次 featurePattern, 其余 other */
364
531
  function roleOfBranch(branch, config) {
365
532
  if (!branch) return "other";
@@ -375,97 +542,98 @@ function roleOfBranch(branch, config) {
375
542
  function isProtected(role) {
376
543
  return PROTECTED_ROLES.has(role);
377
544
  }
378
- function decide(classified, facts, config) {
545
+ function deny(why, next) {
546
+ return {
547
+ kind: "deny",
548
+ reason: why,
549
+ next
550
+ };
551
+ }
552
+ /** 角色名展示: 集成分支(main) 等 */
553
+ function roleLabel(role, t, branch) {
554
+ const label = t(`role.${role}`);
555
+ return branch ? `${label}(${branch})` : label;
556
+ }
557
+ function decide(classified, facts, config, t = defaultT) {
379
558
  switch (classified.kind) {
380
- case "push": return decidePush(classified, facts, config);
381
- case "local-merge": return decideMerge(classified, facts, config);
382
- case "pr-create": return decidePrCreate(classified, facts, config);
383
- case "pr-merge": return decidePrMerge(classified, facts, config);
384
- case "branch-delete": return isProtected(roleOfBranch(classified.branch, config)) ? deny(`受保护分支「${classified.branch}」禁止删除或强推`, "删除/强推请到受保护分支外的 feature 分支上操作; 受保护分支由用户亲手管理") : { kind: "allow" };
559
+ case "push": return decidePush(classified, facts, config, t);
560
+ case "local-merge": return decideMerge(classified, facts, config, t);
561
+ case "pr-create": return decidePrCreate(classified, facts, config, t);
562
+ case "pr-merge": return decidePrMerge(classified, facts, config, t);
563
+ case "branch-delete": return isProtected(roleOfBranch(classified.branch, config)) ? deny(t("denyDeleteOrForce.why", { branch: classified.branch ?? "" }), t("denyDeleteOrForce.next")) : { kind: "allow" };
385
564
  case "guard-cli": return { kind: "allow" };
386
565
  case "checkout": return { kind: "allow" };
387
566
  default: return { kind: "allow" };
388
567
  }
389
568
  }
390
- function decidePush(c, facts, config) {
391
- if (c.all) return deny("--all/--mirror 推送会包含受保护分支", "请逐分支推送并显式指定 refspec");
569
+ function decidePush(c, facts, config, t) {
570
+ if (c.all) return deny(t("pushAll.why"), t("pushAll.next"));
392
571
  const dst = c.dst ?? facts.currentBranch;
393
- if (dst == null) return deny("无法确定推送目标分支(可能处于 detached HEAD)", "请显式指定 refspec, 如 git push origin <分支名>");
572
+ if (dst == null) return deny(t("pushDetached.why"), t("pushDetached.next"));
394
573
  const role = roleOfBranch(dst, config);
395
574
  if (isProtected(role)) {
396
575
  const flexRole = role === "integration" ? config.branches.integration : config.branches.preview;
397
576
  if ((role === "integration" || role === "preview") && flexRole?.update === "flexible" && !c.delete) return { kind: "allow" };
398
- return deny(c.delete ? `受保护分支「${dst}」禁止删除` : `受保护分支「${dst}」禁止直推${c.force ? "(含强推)" : ""}`, branchNext(dst, config));
577
+ if (c.delete) return deny(t("pushProtectedDelete.why", { branch: dst }), branchNext(dst, config, t));
578
+ return deny(t(c.force ? "pushProtectedDirectForce.why" : "pushProtectedDirect.why", { branch: dst }), branchNext(dst, config, t));
399
579
  }
400
580
  return { kind: "allow" };
401
581
  }
402
- function decideMerge(c, facts, config) {
582
+ function decideMerge(c, facts, config, t) {
403
583
  const currentRole = roleOfBranch(facts.currentBranch, config);
404
584
  const source = c.source;
405
585
  const sourceRole = source ? roleOfBranch(source, config) : null;
406
- if (currentRole === "production" || currentRole === "archive") return deny(`合入${roleLabel(currentRole, facts.currentBranch)}仅允许用户亲手执行`, "请在你自己终端(或 UI)完成该合并; agent 不能替你操作");
586
+ if (currentRole === "production" || currentRole === "archive") return deny(t("mergeProtected.why", { role: roleLabel(currentRole, t, facts.currentBranch) }), t("mergeProtected.next"));
407
587
  if (currentRole === "integration" || currentRole === "preview") {
408
588
  if (source == null) return { kind: "allow" };
409
589
  if (sourceRole != null && isProtected(sourceRole)) return { kind: "allow" };
410
590
  if ((currentRole === "integration" ? config.branches.integration : config.branches.preview)?.update === "flexible") return { kind: "allow" };
411
- return deny(`${roleLabel(currentRole, facts.currentBranch)}(${facts.currentBranch})禁止本地合入 feature: 须通过 PR/MR`, `先推 feature 分支, 再创建指向 ${facts.currentBranch} PR/MR`);
591
+ return deny(t("mergeFeature.why", { role: roleLabel(currentRole, t, facts.currentBranch) }), t("mergeFeature.next", { branch: facts.currentBranch ?? "" }));
412
592
  }
413
593
  return { kind: "allow" };
414
594
  }
415
- function decidePrCreate(c, facts, config) {
416
- if (c.target == null) return deny("无法确定 PR/MR 目标分支", `请显式指定 --base/--target-branch( gh pr create --base ${config.branches.integration.branches[0]})`);
595
+ function decidePrCreate(c, facts, config, t) {
596
+ if (c.target == null) return deny(t("prCreateNoTarget.why"), t("prCreateNoTarget.next", { base: config.branches.integration.branches[0] ?? "" }));
417
597
  const targetRole = roleOfBranch(c.target, config);
418
598
  const head = facts.currentBranch;
419
599
  const headRole = roleOfBranch(head, config);
420
- if (targetRole === "archive") return deny("归档分支(archive)仅用户亲手操作, 不允许创建指向它的 PR/MR", "发布/归档由你自己在终端或 UI 完成");
600
+ if (targetRole === "archive") return deny(t("prCreateArchive.why"), t("prCreateArchive.next"));
421
601
  if (targetRole === "integration" || targetRole === "preview" || targetRole === "production") {
422
- if (headRole !== "feature") return deny(`当前分支(${head ?? FEATURE_UNKNOWN})不是 feature 分支, 不能作为指向${roleLabel(targetRole, c.target)}的 PR/MR 源`, "请从 feature/topic 分支上创建指向集成/预览/生产分支的 PR/MR");
602
+ if (headRole !== "feature") return deny(t("prCreateHead.why", {
603
+ head: head ?? t("head.unknown"),
604
+ role: roleLabel(targetRole, t, c.target)
605
+ }), t("prCreateHead.next"));
423
606
  return { kind: "allow" };
424
607
  }
425
608
  return { kind: "allow" };
426
609
  }
427
- function decidePrMerge(c, facts, config) {
610
+ function decidePrMerge(c, facts, config, t) {
428
611
  const resolved = facts.resolvePrTarget?.(c.pr) ?? null;
429
612
  const role = resolved?.role ?? null;
430
613
  const head = resolved?.head ?? facts.currentBranch;
431
614
  if (role === "production") {
432
- if (config.branches.production?.mergeBy === "user") return deny("合入生产(production)分支仅允许用户亲手点合并", "请在 GitLab/GitHub 的 MR/PR 页面上由你本人点击合并");
615
+ if (config.branches.production?.mergeBy === "user") return deny(t("prMergeProduction.why"), t("prMergeProduction.next"));
433
616
  return { kind: "allow" };
434
617
  }
435
- if (role === "archive") return deny("合入归档分支(archive)仅允许用户亲手执行", "请让用户在自己终端或 UI 完成归档合并");
618
+ if (role === "archive") return deny(t("prMergeArchive.why"), t("prMergeArchive.next"));
436
619
  if (role === "integration" || role === "preview") return { kind: "allow" };
437
620
  if (role === "feature" || role === "other") return { kind: "allow" };
438
- if (head == null) return deny("无法确认 PR/MR 的目标分支", "请确认 gh/glab 可用后重试, 或让用户亲手处理");
621
+ if (head == null) return deny(t("prMergeUnknown.why"), t("prMergeUnknown.next"));
439
622
  if (roleOfBranch(head, config) === "feature") return { kind: "allow" };
440
- return deny("无法确认 PR/MR 目标, 且 head 不是 feature 分支", "请确认平台 CLI 可用, 或让用户亲手处理");
441
- }
442
- function deny(reason, next) {
443
- return {
444
- kind: "deny",
445
- reason,
446
- next
447
- };
448
- }
449
- function roleLabel(role, branch) {
450
- const map = {
451
- integration: "集成分支",
452
- preview: "预览分支",
453
- production: "生产分支",
454
- archive: "归档分支",
455
- feature: "feature 分支",
456
- other: "普通分支"
457
- };
458
- return branch ? `${map[role]}(${branch})` : map[role];
623
+ return deny(t("prMergeHead.why"), t("prMergeHead.next"));
459
624
  }
460
625
  /** 受保护分支被拦后的下一步引导 */
461
- function branchNext(branch, config) {
462
- if (branch == null) return "请明确目标分支后重试";
463
- const base = config.branches.integration.branches[0] ?? "集成分支";
626
+ function branchNext(branch, config, t) {
627
+ if (branch == null) return t("next.unspecified");
628
+ const base = config.branches.integration.branches[0] ?? "";
464
629
  switch (roleOfBranch(branch, config)) {
465
- case "integration": return `集成分支(${branch})由 PR/MR 合入 feature: 先推 feature 分支, 再 gh pr create --base ${branch} / glab mr create --target-branch ${branch}`;
466
- case "preview": return `预览分支(${branch})只收 PR/MR: 从 feature/发布分支创建指向它的 PR/MR(${base} 等集成分支内容先进 feature 发布分支)`;
467
- case "production": return `生产分支(${branch})只能 PR/MR, 且合并由你亲手点击`;
468
- default: return `归档分支(${branch})仅用户亲手操作`;
630
+ case "integration": return t("next.integration", { branch });
631
+ case "preview": return t("next.preview", {
632
+ branch,
633
+ base
634
+ });
635
+ case "production": return t("next.production", { branch });
636
+ default: return t("next.archive", { branch });
469
637
  }
470
638
  }
471
639
  //#endregion
@@ -607,8 +775,11 @@ async function evaluateCommand(command, opts) {
607
775
  const { config } = await loadConfig(opts.repoRoot);
608
776
  if (!config?.enabled) return {
609
777
  outcome: "skipped",
610
- segmentCount: 0
778
+ segmentCount: 0,
779
+ locale: "en"
611
780
  };
781
+ const locale = resolveLocale(config.locale);
782
+ const t = makeT(locale);
612
783
  const branch = opts.currentBranch ?? await currentBranch(runner, opts.repoRoot);
613
784
  const env = {
614
785
  repoRoot: opts.repoRoot,
@@ -625,7 +796,7 @@ async function evaluateCommand(command, opts) {
625
796
  ...env,
626
797
  branch: simulatedBranch
627
798
  });
628
- const decision = decide(seg, facts, config);
799
+ const decision = decide(seg, facts, config, t);
629
800
  if (decision.kind === "deny") {
630
801
  await appendAudit(env.repoRoot, {
631
802
  time: Date.now(),
@@ -639,7 +810,8 @@ async function evaluateCommand(command, opts) {
639
810
  why: decision.reason,
640
811
  next: decision.next
641
812
  },
642
- segmentCount: segments.length
813
+ segmentCount: segments.length,
814
+ locale
643
815
  };
644
816
  }
645
817
  await logCiReference(seg, env);
@@ -647,7 +819,8 @@ async function evaluateCommand(command, opts) {
647
819
  }
648
820
  return {
649
821
  outcome: "allow",
650
- segmentCount: segments.length
822
+ segmentCount: segments.length,
823
+ locale
651
824
  };
652
825
  }
653
826
  /** CI 参考(可选适配器): gh pr checks 状态记入审计日志, 查不到自动跳过 */
@@ -685,8 +858,9 @@ function commandText(exec) {
685
858
  const args = exec.arguments;
686
859
  return typeof args?.command === "string" ? args.command : "";
687
860
  }
688
- function formatDeny(why, next) {
689
- return `[gitflow-guard] 已拦截: ${why}\n下一步: ${next}`;
861
+ function formatDeny(locale, why, next) {
862
+ const t = makeT(locale);
863
+ return `${t("deny.header", { why })}\n${t("deny.next", { next })}`;
690
864
  }
691
865
  function apply(ctx, pluginConfig = {}) {
692
866
  const toolNames = new Set(pluginConfig.toolNames ?? ["pwsh", "bash"]);
@@ -703,7 +877,7 @@ function apply(ctx, pluginConfig = {}) {
703
877
  });
704
878
  if (result.outcome === "deny" && result.reason) return {
705
879
  kind: "deny",
706
- reason: formatDeny(result.reason.why, result.reason.next)
880
+ reason: formatDeny(result.locale, result.reason.why, result.reason.next)
707
881
  };
708
882
  return next();
709
883
  } catch (e) {
@@ -713,4 +887,4 @@ function apply(ctx, pluginConfig = {}) {
713
887
  });
714
888
  }
715
889
  //#endregion
716
- export { name as a, findRepoRoot as c, roleMatches as d, classify as f, formatDeny as i, gitRunner as l, apply as n, stateDir as o, evaluateCommand as r, currentBranch as s, appendAudit as t, loadConfig as u };
890
+ export { name as a, findRepoRoot as c, resolveLocale as d, loadConfig as f, formatDeny as i, gitRunner as l, classify as m, apply as n, stateDir as o, roleMatches as p, evaluateCommand as r, currentBranch as s, appendAudit as t, makeT as u };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agents-gitflow-guard",
3
- "version": "0.0.2",
4
- "description": "DSH plugin: a configurable branch-role guard — integration/preview/production/archive, each with its own update rules",
3
+ "version": "0.0.4",
4
+ "description": "A configurable branch-role guard for AI coding agents (DSH / Claude Code / Codex) — integration/preview/production/archive, each with its own update rules",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.mjs",
@@ -26,7 +26,10 @@
26
26
  "keywords": [
27
27
  "dsh",
28
28
  "deepseek-harness",
29
+ "claude-code",
30
+ "codex",
29
31
  "plugin",
32
+ "hook",
30
33
  "gitflow",
31
34
  "git",
32
35
  "workflow",
@@ -46,7 +49,8 @@
46
49
  "build": "tsdown",
47
50
  "prepack": "npm run build",
48
51
  "test": "vitest run",
49
- "typecheck": "tsc --noEmit"
52
+ "typecheck": "tsc --noEmit",
53
+ "verify:matrix": "npm run build && node scripts/verify-matrix.mjs"
50
54
  },
51
55
  "peerDependencies": {
52
56
  "@deepseek-ai/cordis": "^4.0.1",