@brightliu/ai-control 2.1.0 → 2.2.0

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
@@ -33,7 +33,8 @@ AI:(实现 → 跑测试)测试报告 4 条全绿,ai ship 发布检查
33
33
 
34
34
  ```bash
35
35
  # 1. 装工具(一次性)
36
- npm install -g github:BrightLiu4917/ai-control
36
+ npm install -g @brightliu/ai-control
37
+ # 网络装不了 npm 官方源时的备选:npm install -g github:BrightLiu4917/ai-control
37
38
 
38
39
  # 2. 装进你的项目
39
40
  cd 你的项目
@@ -55,6 +56,60 @@ ai sync # 只有用 Claude Code / WorkBuddy 才需要:为它
55
56
  | `ai test 名字` | 跑你项目自己的测试,报告自动按变更隔离存放 | "跑测试" |
56
57
  | `ai ship 名字` | 核对测试报告:确认过没有、报告新不新、全绿没有 | "能交付了吗"(秒级出结果) |
57
58
 
59
+ ## 完整流程(一张图看懂)
60
+
61
+ ```text
62
+ 你对 AI 说需求
63
+
64
+ AI 先看代码,判断这事有多大
65
+
66
+ ┌──────────────────┬────────────┴───────────┬──────────────────────┐
67
+ ▼ ▼ ▼ ▼
68
+ 【改错别字级】 【小需求 lite】 【正常需求】 【要动数据库】
69
+ 改注释/文档/typo 改文案、调样式 新功能、改接口 加字段、建表、迁移
70
+ │ │ │ │
71
+ 直接改完 AI 建变更单 AI 建变更单 完整流程再多一步:
72
+ 不走流程 (2 个文件) (3 个文件) AI 先给表结构设计
73
+ │ │ │ → 你点头 → 再给
74
+ │ └──────────┬─────────────┘ 变更包(SQL+回滚)
75
+ │ ▼ → 你再点头才许执行
76
+ │ ①【确认点】看变更单:改什么、 │
77
+ │ 动哪些文件、验收标准是哪几条用例 │
78
+ │ │ │
79
+ │ 你说"确认",然后【你本人】跑: │
80
+ │ ai confirm 变更名 ◄─────────────────────────┘
81
+ │ (留痕:谁、几点、哪个版本确认的;Claude Code 下 AI 代跑会被钩子拦)
82
+ │ │
83
+ │ AI 开始写代码
84
+ │ (确认之前它想写一行业务代码都会被钩子拦住)
85
+ │ │
86
+ │ ai test 变更名
87
+ │ (跑你项目的测试,报告自动按变更隔离存放)
88
+ │ │
89
+ │ ai ship 变更名
90
+ │ ┌─────────── 秒级自动核对 4 件事 ────────────┐
91
+ │ │ ① 确认过没有?确认后变更单被偷改过没有? │
92
+ │ │ ② 测试报告在不在?是不是本次新跑出来的? │
93
+ │ │ ③ 有没有挂掉的测试? │
94
+ │ │ ④ 每条用例在报告里有没有通过记录? │
95
+ │ └────────┬──────────────────┬───────────────┘
96
+ │ 全过 ✅ 有一条不过 ❌
97
+ ▼ │ │
98
+ 完事 ▼ ▼
99
+ 交付(动过库的附回滚 SQL) AI 回去补,修好重新 ship
100
+ ```
101
+
102
+ 你全程只干三件事:**提需求 → 看变更单点头(自己敲 ai confirm)→ 等结果**。按事情大小分流:
103
+
104
+ | 事情多大 | 例子 | 流程 | 你花几分钟 |
105
+ |---|---|---|---|
106
+ | 芝麻 | 错别字、注释 | 没有流程,直接改 | 0 |
107
+ | 小 | 改文案、调样式 | lite:2 个文件,看一眼确认 | ~30 秒 |
108
+ | 中 | 新功能、改接口 | 完整:3 个文件 + 自动化测试 | 2~5 分钟 |
109
+ | 大 | 动数据库 | 完整 + 数据库两次点头 + 建议独立审查 | 5~10 分钟 |
110
+
111
+ AI 以"小需求"开工、中途发现要动表或接口?门禁直接拦下、强制升级完整流程——**快速通道不是逃生通道**。
112
+
58
113
  ## 它靠什么管住 AI(三层,由软到硬)
59
114
 
60
115
  1. **规矩**(`AGENTS.md` + `.ai/rules/`):63 行契约每次对话自动生效 + 20 份中文工程规则按需加载——禁止编造字段、SQL 必须带 WHERE、枚举必须 code+desc 禁止魔法值、JOIN 不许先连后分页……**这些是踩过生产事故总结的具体规则,不是"请写好代码"式的空话**。
@@ -91,7 +146,7 @@ ai sync # 只有用 Claude Code / WorkBuddy 才需要:为它
91
146
  不用。纯文档/注释/typo 直接改;小功能走 `--lite`(两个文件、30 秒确认);只有动数据库、接口、权限才走完整流程。管控力度和风险成正比。
92
147
 
93
148
  **Q:AI 不守规矩怎么办?**
94
- 说一句"按控制系统流程来"即可拽回;关键环节(越级偷渡、报告缺失或有失败)有自动检查兜底。诚实说明边界:AI 理论上可以伪造报告文件绕过检查,门禁的作用是把"张嘴谎报"变成"必须留下可查的假证据"——成本和暴露风险完全不是一个量级。
149
+ 说一句"按控制系统流程来"即可拽回;关键环节(越级偷渡、报告缺失或有失败、diff 含密钥)有自动检查兜底。诚实说明三条边界:① AI 理论上可伪造报告文件——门禁把"张嘴谎报"变成"必须留下可查的假证据",成本和暴露风险不是一个量级;② 门禁验证"有叫这个名的通过测试",验证不了测试写得好不好(空壳测试查不出来)——高风险变更的独立审查就是为这个准备的;③ 整套流程的启动键在 AI 手里,它压根不进流程时只有契约文字在管——所以契约做成每次对话自动加载,你发现不对说一句就能拽回。
95
150
 
96
151
  **Q:测试报告是什么格式?**
97
152
  JUnit XML——`ai test` 会按栈自动搞定:Maven 自带;Go 的输出自动转成报告(零配置,无需 gotestsum);Vitest 自动注入内置 junit reporter;PHPUnit 自动加 `--log-junit`;仅 Jest 需要 `npm i -D jest-junit`(会提示)。测试方法名里带上用例编号(如 `test_TC01_xxx`)即可被核对。多变更并行时把报告输出到 `test-results/<变更名>/` 可互相隔离;报告必须比确认时间新——旧报告顶包会被拦下。
@@ -121,6 +176,6 @@ jobs:
121
176
 
122
177
  ## 工程质量
123
178
 
124
- 零运行时依赖(只需 Node ≥18.17 + git);22 个端到端验收测试 + 8 个单元测试 + 规则库引用自检(防悬空引用);代码量硬预算写进 CI(内核 ≤1500 行、契约 ≤100 行、文档 1 份),超支即红——防止工具本身变臃肿。
179
+ 零运行时依赖(只需 Node ≥18.17 + git);26 个端到端验收测试 + 8 个单元测试 + 规则库引用自检(防悬空引用);代码量硬预算写进 CI(内核 ≤1500 行、契约 ≤100 行、文档 1 份),超支即红——防止工具本身变臃肿。
125
180
 
126
181
  前身 [ai-coding-fun](https://github.com/BrightLiu4917/ai-coding-fun)(v1)经 15 批真实项目迭代后彻底重构:5000 行 bash → 600 行 JS,五份文档的流程 → 两份,学习成本压缩到本 README 一页。
@@ -13,6 +13,12 @@ function sync(root, args) {
13
13
 
14
14
  function write(p, content) {
15
15
  if (fs.existsSync(p) && !force) { skipped.push(path.relative(root, p)); return; }
16
+ if (fs.existsSync(p) && force && fs.readFileSync(p, "utf8") !== content) {
17
+ // 覆盖前备份:用户手改过的生成物不许静默丢失
18
+ const bak = `${p}.bak-${new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14)}`;
19
+ fs.copyFileSync(p, bak);
20
+ written.push(path.relative(root, bak) + "(原文件备份)");
21
+ }
16
22
  fs.mkdirSync(path.dirname(p), { recursive: true });
17
23
  fs.writeFileSync(p, content);
18
24
  written.push(path.relative(root, p));
@@ -97,9 +103,9 @@ function mergeClaudeSettings(root, written, skipped) {
97
103
  const p = path.join(root, ".claude", "settings.json");
98
104
  const OURS = [
99
105
  { matcher: "Write|Edit|MultiEdit|NotebookEdit",
100
- hooks: [{ type: "command", command: "node .ai/hooks/guard-write.js" }] },
106
+ hooks: [{ type: "command", command: "node \"${CLAUDE_PROJECT_DIR:-.}/.ai/hooks/guard-write.js\"" }] },
101
107
  { matcher: "Bash",
102
- hooks: [{ type: "command", command: "node .ai/hooks/guard-bash.js" }] },
108
+ hooks: [{ type: "command", command: "node \"${CLAUDE_PROJECT_DIR:-.}/.ai/hooks/guard-bash.js\"" }] },
103
109
  ];
104
110
  let obj = {};
105
111
  if (fs.existsSync(p)) {
@@ -111,10 +117,15 @@ function mergeClaudeSettings(root, written, skipped) {
111
117
  }
112
118
  obj.hooks = obj.hooks || {};
113
119
  obj.hooks.PreToolUse = obj.hooks.PreToolUse || [];
114
- const have = JSON.stringify(obj.hooks.PreToolUse);
115
120
  let added = 0;
116
121
  for (const entry of OURS) {
117
- if (!have.includes(entry.hooks[0].command)) { obj.hooks.PreToolUse.push(entry); added++; }
122
+ const fname = entry.hooks[0].command.match(/guard-\w+\.js/)[0];
123
+ const cur = obj.hooks.PreToolUse.filter((e) => JSON.stringify(e).includes(fname));
124
+ if (cur.length === 1 && JSON.stringify(cur[0]) === JSON.stringify(entry)) continue; // 已是最新
125
+ // 去掉旧版条目(如相对路径写法)再放入最新——按文件名去重,其余用户条目不动
126
+ obj.hooks.PreToolUse = obj.hooks.PreToolUse.filter((e) => !JSON.stringify(e).includes(fname));
127
+ obj.hooks.PreToolUse.push(entry);
128
+ added++;
118
129
  }
119
130
  if (added) {
120
131
  fs.mkdirSync(path.dirname(p), { recursive: true });
package/lib/doctor.js CHANGED
@@ -3,6 +3,7 @@ const fs = require("fs");
3
3
  const path = require("path");
4
4
  const { execSync } = require("child_process");
5
5
  const { findProjectRoot, loadConfig, exists } = require("./core");
6
+ const { contractHash, isShipped } = require("./gate");
6
7
 
7
8
  function cmdDoctor() {
8
9
  const ok = (b, label, fix) => console.log(`${b ? "OK " : "FAIL"} ${label}${b || !fix ? "" : `——${fix}`}`);
@@ -46,20 +47,21 @@ function cmdDoctor() {
46
47
  const ids = exists(changesDir)
47
48
  ? fs.readdirSync(changesDir).filter((d) => { try { return fs.statSync(path.join(changesDir, d)).isDirectory(); } catch { return false; } })
48
49
  : [];
49
- let confirmed = 0, stale = 0;
50
+ let confirmed = 0, stale = 0, shipped = 0;
50
51
  for (const id of ids) {
51
- const cp = path.join(changesDir, id, "confirmed.json");
52
+ const cdir = path.join(changesDir, id);
53
+ if (isShipped(cdir)) { shipped++; continue; } // 已交付的不再体检
54
+ const cp = path.join(cdir, "confirmed.json");
52
55
  if (!exists(cp)) continue;
53
56
  confirmed++;
54
57
  try {
55
- const at = Date.parse(JSON.parse(fs.readFileSync(cp, "utf8")).at) || 0;
56
- for (const f of ["proposal.md", "test-cases.md"]) {
57
- const p = path.join(changesDir, id, f);
58
- if (exists(p) && fs.statSync(p).mtimeMs > at) { stale++; console.log(`FAIL 变更 ${id}: 确认后 ${f} 被修改——确认已失效,需重新 ai confirm`); bad++; break; }
58
+ const conf = JSON.parse(fs.readFileSync(cp, "utf8"));
59
+ if (conf.hash && conf.hash !== contractHash(cdir)) {
60
+ stale++; console.log(`FAIL 变更 ${id}: 契约内容在确认后被修改——确认已失效,需重新 ai confirm`); bad++;
59
61
  }
60
62
  } catch { console.log(`FAIL 变更 ${id}: confirmed.json 无效`); bad++; }
61
63
  }
62
- console.log(`OK 变更: ${ids.length} 个(已确认 ${confirmed},确认失效 ${stale})`);
64
+ console.log(`OK 变更: ${ids.length} 个(进行中已确认 ${confirmed},确认失效 ${stale},已交付 ${shipped})`);
63
65
 
64
66
  console.log(bad ? `\nDOCTOR_FAIL:${bad} 项需处理。` : "\nDOCTOR_OK:全部体检通过。");
65
67
  if (bad) process.exit(1);
package/lib/gate.js CHANGED
@@ -103,7 +103,40 @@ function cmdCheck(args) {
103
103
  console.log("CHECK_PASSED:可向用户输出确认单(标注级别与判级理由)。");
104
104
  }
105
105
 
106
- // ── ai confirm:把"用户点头"变成可审计留痕(by/at/sha)────
106
+ // 契约内容哈希:proposal + test-cases + specs 的内容指纹。
107
+ // 用哈希而非 mtime 判定"确认后是否被改动"——git clone/换机器/checkout 会刷新
108
+ // mtime 导致全体确认假失效(团队场景不可用),内容哈希两个方向都准。
109
+ function contractHash(dir) {
110
+ const crypto = require("crypto");
111
+ const h = crypto.createHash("sha256");
112
+ const files = [];
113
+ for (const f of fs.readdirSync(dir, { recursive: true })) {
114
+ const rel = String(f).replace(/\\/g, "/");
115
+ const base = path.basename(rel);
116
+ const isContract = base === "proposal.md" || base === "test-cases.md" || rel.startsWith("specs");
117
+ const p = path.join(dir, rel);
118
+ if (isContract && p.endsWith(".md") && fs.statSync(p).isFile()) files.push(rel);
119
+ }
120
+ for (const rel of files.sort()) {
121
+ h.update(rel).update("\0").update(fs.readFileSync(path.join(dir, rel))).update("\0");
122
+ }
123
+ return h.digest("hex");
124
+ }
125
+
126
+ function isShipped(dir) {
127
+ return exists(path.join(dir, "shipped.json"));
128
+ }
129
+
130
+ // 活跃变更 = 目录存在且尚未交付
131
+ function activeChanges(root) {
132
+ const base = path.join(root, ".ai", "changes");
133
+ if (!exists(base)) return [];
134
+ return fs.readdirSync(base).filter((d) => {
135
+ try { return fs.statSync(path.join(base, d)).isDirectory() && !isShipped(path.join(base, d)); } catch { return false; }
136
+ });
137
+ }
138
+
139
+ // ── ai confirm:把"用户点头"变成可审计留痕(by/at/sha/契约哈希)────
107
140
  function cmdConfirm(args) {
108
141
  const { id, root, dir } = requireChange(args, "用法: ai confirm <change-id>(用户确认变更单后执行)");
109
142
  const errors = runCheck(root, dir, id);
@@ -116,50 +149,88 @@ function cmdConfirm(args) {
116
149
  by: git("git config user.name") || process.env.USER || "unknown",
117
150
  at: new Date().toISOString(),
118
151
  sha: git("git rev-parse HEAD"),
152
+ hash: contractHash(dir),
119
153
  };
120
154
  fs.writeFileSync(path.join(dir, "confirmed.json"), JSON.stringify(record, null, 2) + "\n");
121
155
  console.log(`CONFIRM_OK:已留痕(by=${record.by})。开始实现;proposal/test-cases 再改动需重新确认。`);
122
156
  }
123
157
 
124
- // 读取确认留痕;proposal/test-cases/specs 在确认后被改动 确认失效
158
+ // 读取确认留痕;契约文件内容在确认后被改动确认失效(按哈希,不受 mtime 影响)
125
159
  function requireFreshConfirm(dir, id) {
126
160
  const confPath = path.join(dir, "confirmed.json");
127
161
  if (!exists(confPath)) die(`变更未确认:向用户输出确认单,用户点头后运行 ai confirm ${id}`, 2);
128
- let confAt = 0;
129
- try { confAt = Date.parse(JSON.parse(read(confPath)).at) || 0; } catch { /* 落入下方校验 */ }
162
+ let conf = {};
163
+ try { conf = JSON.parse(read(confPath)); } catch { /* 落入下方校验 */ }
164
+ const confAt = Date.parse(conf.at) || 0;
130
165
  if (!confAt) die("confirmed.json 无效;重新运行 ai confirm", 2);
131
- for (const f of fs.readdirSync(dir, { recursive: true })) {
132
- const rel = String(f);
133
- const p = path.join(dir, rel);
134
- const base = path.basename(rel);
135
- const isContract = base === "proposal.md" || base === "test-cases.md" || rel.startsWith("specs");
136
- if (!isContract || !p.endsWith(".md") || !fs.statSync(p).isFile()) continue;
137
- if (fs.statSync(p).mtimeMs > confAt) {
138
- die(`确认已过期:${rel} 在确认后被修改;重新向用户确认并运行 ai confirm ${id}`, 2);
166
+ if (conf.hash) {
167
+ if (contractHash(dir) !== conf.hash) {
168
+ die(`确认已过期:proposal/test-cases/specs 在确认后被修改;重新向用户确认并运行 ai confirm ${id}`, 2);
139
169
  }
170
+ } else {
171
+ die(`confirmed.json 缺少契约哈希(旧版本产物);重新运行 ai confirm ${id}`, 2);
140
172
  }
141
- return confAt;
173
+ return conf;
174
+ }
175
+
176
+ // 密钥扫描:交付前扫描本变更引入的 diff(禁止密钥入库/外发是红线)。
177
+ // 只用低误报的具体格式;关闭:config.json "secretScan": "off"
178
+ const SECRET_RES = [
179
+ [/AKIA[0-9A-Z]{16}/, "AWS Access Key"],
180
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "私钥"],
181
+ [/gh[pos]_[A-Za-z0-9]{36}/, "GitHub Token"],
182
+ [/npm_[A-Za-z0-9]{36}/, "npm Token"],
183
+ [/xox[baprs]-[A-Za-z0-9-]{10,}/, "Slack Token"],
184
+ [/sk-[A-Za-z0-9_-]{32,}/, "API Key(sk- 前缀)"],
185
+ [/jdbc:[^\s'"]*password=[^\s'"&]+/i, "带密码的连接串"],
186
+ ];
187
+
188
+ function changeDiff(root, sinceSha) {
189
+ const git = (cmd) => { try { return execSync(cmd, { cwd: root, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 }).toString(); } catch { return ""; } };
190
+ let out = git("git diff HEAD"); // 未提交部分
191
+ if (sinceSha) out += git(`git diff ${sinceSha} HEAD`); // 确认以来已提交部分
192
+ return out;
193
+ }
194
+
195
+ function scanSecrets(root, diffText) {
196
+ if ((loadConfig(root).secretScan || "") === "off") return [];
197
+ const hits = [];
198
+ for (const line of diffText.split("\n")) {
199
+ if (!line.startsWith("+") || line.startsWith("+++")) continue; // 只看新增行
200
+ for (const [re, label] of SECRET_RES) {
201
+ if (re.test(line)) hits.push(`${label}: ${line.slice(0, 80)}`);
202
+ }
203
+ }
204
+ return [...new Set(hits)];
142
205
  }
143
206
 
144
207
  // ── ai ship ──────────────────────────────────────────────
145
208
  function cmdShip(args) {
146
209
  const { id, root, dir } = requireChange(args, "用法: ai ship <change-id> [junit-报告目录...]");
147
210
  const { proposalText, casesText } = collect(dir);
148
- const confAt = requireFreshConfirm(dir, id);
211
+ const conf = requireFreshConfirm(dir, id);
212
+ const confAt = Date.parse(conf.at);
149
213
  const rows = tcRows(casesText);
150
214
  const autoRows = rows.filter((l) => !MANUAL_RE.test(l));
151
215
 
216
+ // 红线:本变更引入的 diff 不得含密钥(入库即泄露;独立审查外发前也靠这道拦)
217
+ const diffText = changeDiff(root, conf.sha);
218
+ const secrets = scanSecrets(root, diffText);
219
+ if (secrets.length) {
220
+ secrets.forEach((s) => console.error(`SECRET_HIT: ${s}`));
221
+ die('diff 中检测到疑似密钥(见上)。移除或改用环境变量后重试;确属测试假数据可在 .ai/config.json 设 "secretScan": "off"', 2);
222
+ }
223
+
152
224
  if (autoRows.length === 0) {
153
225
  console.log("全部用例为手动验证:跳过 JUnit 报告核对,验收结果须逐条写入交付说明。");
154
226
  } else {
155
227
  const extraDirs = args.filter((a) => !a.startsWith("--") && a !== id);
156
- // N-3 串号收口:存在多个变更时,全局报告目录无法证明"这是本变更的证据"——
157
- // 必须用隔离目录(ai test <id> 自动生成)或显式传入报告路径
228
+ // N-3 串号收口:存在多个"活跃"变更时(已交付的不算——防止历史变更永久触发强制隔离),
229
+ // 全局报告目录无法证明"这是本变更的证据"——必须用隔离目录或显式传报告路径
158
230
  const scoped = path.join(root, "test-results", id);
159
- const allChanges = fs.readdirSync(path.join(root, ".ai", "changes"))
160
- .filter((d) => { try { return fs.statSync(path.join(root, ".ai", "changes", d)).isDirectory(); } catch { return false; } });
161
- if (!extraDirs.length && !exists(scoped) && allChanges.length > 1) {
162
- die(`存在多个变更(${allChanges.join(", ")}):证据必须按变更隔离。运行 ai test ${id}(报告自动写入 test-results/${id}/),或显式传入报告目录`, 2);
231
+ const active = activeChanges(root);
232
+ if (!extraDirs.length && !exists(scoped) && active.length > 1) {
233
+ die(`存在多个进行中变更(${active.join(", ")}):证据必须按变更隔离。运行 ai test ${id}(报告自动写入 test-results/${id}/),或显式传入报告目录`, 2);
163
234
  }
164
235
  const reportDirs = extraDirs.length ? extraDirs : findReportDirs(root, id);
165
236
  if (!reportDirs.length) die(`未找到 JUnit 报告目录;请先运行 ai test ${id}`, 2);
@@ -180,6 +251,14 @@ function cmdShip(args) {
180
251
  console.log(`EVIDENCE_OK:报告 ${parsed} 份,非手动用例 ${autoRows.length} 条全部有通过记录。`);
181
252
  }
182
253
 
254
+ // 声明诚实性软核验:实际改动 vs affected_files(只警告不拦——硬拦会造成
255
+ // "改文件→补声明→确认失效→重确认"的死循环式打扰;清单供用户与独立审查参考)
256
+ const undeclared = driftFiles(root, dir, conf.sha, proposalText);
257
+ if (undeclared.length) {
258
+ console.log(`注意:以下改动未在影响范围声明(软核验,不拦截):`);
259
+ undeclared.forEach((f) => console.log(` ? ${f}`));
260
+ }
261
+
183
262
  // 高风险提示(结构化判定,防 v1 的 grep 越界误报)
184
263
  if ((scopeItems(proposalText, "affected_tables") || []).length) {
185
264
  console.log("提示:本变更涉及数据库,建议在新会话用 .ai/templates/review-prompt.md 做一次独立审查。");
@@ -187,7 +266,29 @@ function cmdShip(args) {
187
266
  console.log("提示:本变更涉及支付/状态流,建议在新会话用 .ai/templates/review-prompt.md 做一次独立审查。");
188
267
  }
189
268
 
190
- console.log("SHIP_GATES_PASSED:门禁全部通过。手动用例结果与残余风险写入交付说明。");
269
+ // 交付留痕:变更生命周期到此终结——guard-write 白名单与多变更隔离计数不再包含它
270
+ fs.writeFileSync(path.join(dir, "shipped.json"),
271
+ JSON.stringify({ at: new Date().toISOString(), evidence: autoRows.length ? "junit" : "manual" }, null, 2) + "\n");
272
+ console.log("SHIP_GATES_PASSED:门禁全部通过(已写入 shipped.json)。手动用例结果与残余风险写入交付说明。");
273
+ }
274
+
275
+ // 确认以来实际改动、但未在 affected_files 声明的业务文件
276
+ function driftFiles(root, dir, sinceSha, proposalText) {
277
+ const git = (cmd) => { try { return execSync(cmd, { cwd: root, stdio: ["ignore", "pipe", "ignore"] }).toString(); } catch { return ""; } };
278
+ let names = git("git diff --name-only HEAD");
279
+ if (sinceSha) names += git(`git diff --name-only ${sinceSha} HEAD`);
280
+ const changed = [...new Set(names.split("\n").map((s) => s.trim()).filter(Boolean))];
281
+ if (!changed.length) return [];
282
+ const declared = scopeItems(proposalText, "affected_files") || [];
283
+ const FREE = [/\.md$/i, /(^|\/)\.(ai|claude|workbuddy|github)\//, /(^|\/)(tests?|__tests__|test-results)\//, /\.(test|spec)\.[jt]sx?$/, /Test\.(java|kt|php)$/, /_test\.go$/];
284
+ return changed.filter((f) => {
285
+ if (FREE.some((re) => re.test(f))) return false;
286
+ return !declared.some((e) => {
287
+ const d = e.replace(/^\.\//, "");
288
+ if (d.endsWith("/")) return f.startsWith(d) || f.includes("/" + d);
289
+ return f === d || f.endsWith("/" + d) || d.endsWith("/" + f) || f.endsWith(d);
290
+ });
291
+ });
191
292
  }
192
293
 
193
- module.exports = { cmdCheck, cmdConfirm, cmdShip };
294
+ module.exports = { cmdCheck, cmdConfirm, cmdShip, contractHash, activeChanges, isShipped };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brightliu/ai-control",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "AGENTS.md 标准之上的中文全栈 AI 工程控制层:规格先行、DB 两阶段确认、证据验收。支持 Codex / Claude Code / Cursor / Kimi / Qoder / WorkBuddy。",
5
5
  "bin": {
6
6
  "ai": "bin/ai.js"
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
- // Claude Code PreToolUse 钩子(Write/Edit):把“change 确认前禁止写业务代码”从
3
- // 提示词约束前移为写入瞬间的机械拦截——违规不再是“写完被 check 打回”,而是第一行就写不进去。
2
+ // Claude Code PreToolUse 钩子(Write/Edit):把"change 确认前禁止写业务代码"从
3
+ // 提示词约束前移为写入瞬间的机械拦截。
4
4
  // 协议:stdin 收 JSON;exit 0 放行,exit 2 拦截(stderr 反馈给 Claude)。
5
- // 原则:fail-open——解析失败/结构异常一律放行;只拦最明确的两种违规:
6
- // 1) 存在变更单但一个都没确认 → 禁止写业务代码(文档/测试/.ai 内除外)
7
- // 2) 已有确认的变更 业务代码路径必须落在某个已确认变更的 affected_files 内
5
+ // 原则:
6
+ // - fail-open——解析失败/结构异常一律放行;
7
+ // - 只看"活跃"变更(已 shipped 的不算——防止白名单随历史变更膨胀);
8
+ // - 简单任务通道——未确认草稿只锁"它自己声明要改的文件",无关小改不拦;
9
+ // - 已确认变更实现期间,业务代码写入须落在其声明的影响范围内。
8
10
  // 逃生阀:AI_CONTROL_HOOKS=off,或 .ai/config.json 里 "hooks": "off"。
9
11
 
10
12
  const fs = require("fs");
@@ -22,10 +24,50 @@ process.stdin.on("end", () => {
22
24
  }
23
25
  });
24
26
 
27
+ // 项目根:Claude Code 注入的 CLAUDE_PROJECT_DIR 优先;否则从 cwd 向上找 .ai/
28
+ // (子目录启动 Claude 时 cwd 不是项目根,相对路径会失效——B 修复)
29
+ function findRoot() {
30
+ if (process.env.CLAUDE_PROJECT_DIR && fs.existsSync(path.join(process.env.CLAUDE_PROJECT_DIR, ".ai"))) {
31
+ return process.env.CLAUDE_PROJECT_DIR;
32
+ }
33
+ let dir = process.cwd();
34
+ while (true) {
35
+ if (fs.existsSync(path.join(dir, ".ai"))) return dir;
36
+ const parent = path.dirname(dir);
37
+ if (parent === dir) return null;
38
+ dir = parent;
39
+ }
40
+ }
41
+
42
+ function affectedFiles(changesDir, id) {
43
+ try {
44
+ const text = fs.readFileSync(path.join(changesDir, id, "proposal.md"), "utf8");
45
+ const m = text.match(/^[ \t]*affected_files:[ \t]*([^\n]*)\n?((?:[ \t]*-[ \t]*[^\n]*\n?)*)/m);
46
+ if (!m) return [];
47
+ const items = [];
48
+ if (m[1] && m[1].trim()) items.push(m[1].trim());
49
+ for (const l of (m[2] || "").split("\n")) {
50
+ const v = l.replace(/^[ \t]*-[ \t]*/, "").replace(/[`'"]/g, "").trim();
51
+ if (v && !/^(none|无|暂无|n\/a)$/i.test(v)) items.push(v);
52
+ }
53
+ return items;
54
+ } catch { return []; }
55
+ }
56
+
57
+ function matches(rel, entries) {
58
+ const norm = (s) => s.replace(/^\.\//, "").replace(/\\/g, "/");
59
+ return entries.some((e) => {
60
+ const d = norm(e);
61
+ if (d.endsWith("/")) return rel.startsWith(d) || rel.includes("/" + d);
62
+ return rel === d || rel.endsWith("/" + d) || d.endsWith("/" + rel) || rel.endsWith(d);
63
+ });
64
+ }
65
+
25
66
  function main(input) {
26
67
  const fp = (input.tool_input || {}).file_path || (input.tool_input || {}).notebook_path || "";
27
68
  if (!fp) process.exit(0);
28
- const root = process.cwd();
69
+ const root = findRoot();
70
+ if (!root) process.exit(0);
29
71
  try {
30
72
  if ((JSON.parse(fs.readFileSync(path.join(root, ".ai", "config.json"), "utf8")).hooks || "") === "off") process.exit(0);
31
73
  } catch { /* 无配置则默认开启 */ }
@@ -46,47 +88,41 @@ function main(input) {
46
88
  const changesDir = path.join(root, ".ai", "changes");
47
89
  let ids = [];
48
90
  try {
49
- ids = fs.readdirSync(changesDir).filter((d) => fs.statSync(path.join(changesDir, d)).isDirectory());
50
- } catch { process.exit(0); } // 未安装/无变更目录:不拦(契约仍然管)
91
+ ids = fs.readdirSync(changesDir).filter((d) => {
92
+ try {
93
+ return fs.statSync(path.join(changesDir, d)).isDirectory()
94
+ && !fs.existsSync(path.join(changesDir, d, "shipped.json")); // 已交付不再约束
95
+ } catch { return false; }
96
+ });
97
+ } catch { process.exit(0); }
51
98
  if (!ids.length) process.exit(0);
52
99
 
53
100
  const confirmed = ids.filter((id) => fs.existsSync(path.join(changesDir, id, "confirmed.json")));
54
- if (!confirmed.length) {
55
- console.error(
56
- `[ai-control] 已拦截:存在变更单(${ids.join(", ")})但均未确认。` +
57
- `change 确认前禁止写业务代码——先 ai check 通过、向用户输出确认单,用户点头后 ai confirm <id> 再实现。`
58
- );
59
- process.exit(2);
60
- }
101
+ const drafts = ids.filter((id) => !confirmed.includes(id));
61
102
 
62
- // 汇总已确认变更声明的影响文件
63
- const declared = [];
64
- for (const id of confirmed) {
65
- try {
66
- const text = fs.readFileSync(path.join(changesDir, id, "proposal.md"), "utf8");
67
- const m = text.match(/^[ \t]*affected_files:[ \t]*([^\n]*)\n?((?:[ \t]*-[ \t]*[^\n]*\n?)*)/m);
68
- if (!m) continue;
69
- if (m[1] && m[1].trim()) declared.push(m[1].trim());
70
- for (const l of (m[2] || "").split("\n")) {
71
- const v = l.replace(/^[ \t]*-[ \t]*/, "").replace(/[`'"]/g, "").trim();
72
- if (v && !/^(none|无|暂无|n\/a)$/i.test(v)) declared.push(v);
73
- }
74
- } catch { /* 单个变更读取失败不影响其他 */ }
103
+ // 未确认草稿只锁"它自己声明要改的文件"——防 AI 先实现后确认;
104
+ // 无关文件放行(简单任务通道:改个配置/错别字不该被烂尾草稿连坐)
105
+ for (const id of drafts) {
106
+ if (matches(rel, affectedFiles(changesDir, id))) {
107
+ console.error(
108
+ `[ai-control] 已拦截:${rel} 是变更 ${id} 声明要改的文件,但该变更尚未确认。` +
109
+ `先 ai check 通过、向用户输出确认单,用户点头后由用户运行 ai confirm ${id},再开始实现。`
110
+ );
111
+ process.exit(2);
112
+ }
75
113
  }
76
- if (!declared.length) process.exit(0); // 声明为空/异常:fail-open
77
114
 
78
- const norm = (s) => s.replace(/^\.\//, "").replace(/\\/g, "/");
79
- const ok = declared.some((e) => {
80
- const d = norm(e);
81
- if (d.endsWith("/")) return rel.startsWith(d) || rel.includes("/" + d);
82
- return rel === d || rel.endsWith("/" + d) || d.endsWith("/" + rel) || rel.endsWith(d);
83
- });
84
- if (!ok) {
85
- console.error(
86
- `[ai-control] 已拦截:${rel} 不在任何已确认变更的 affected_files 内` +
87
- `(已确认: ${confirmed.join(", ")})。禁止修改无关文件;确需修改则更新 proposal 影响范围并重新经用户确认(ai confirm)。`
88
- );
89
- process.exit(2);
115
+ // 已确认变更实现期间:业务代码写入须落在某个已确认变更的影响范围内
116
+ if (confirmed.length) {
117
+ const declared = confirmed.flatMap((id) => affectedFiles(changesDir, id));
118
+ if (!declared.length) process.exit(0); // 声明为空/异常:fail-open
119
+ if (!matches(rel, declared)) {
120
+ console.error(
121
+ `[ai-control] 已拦截:${rel} 不在任何进行中变更的 affected_files 内(进行中: ${confirmed.join(", ")})。` +
122
+ `属本变更的改动请更新 proposal 影响范围并重新经用户确认;与变更无关的简单任务请先交付当前变更,或让用户以 AI_CONTROL_HOOKS=off 临时放行。`
123
+ );
124
+ process.exit(2);
125
+ }
90
126
  }
91
127
  process.exit(0);
92
128
  }