definition-write-gate.js
4.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/**
* 改**本体定义文件**(agent.md / soul.md / SKILL.md)之前的一道闸门:
* **确认这份文件的当前内容已经在 git 历史里**,否则不许改。
*
* ── 为什么需要它(29-DREAM 决策 B)────────────────────────────────────
* dream 会直接改本体的定义文件。而 28 号的核心裁决是「本体只有一份」——
* 改 `agent.md` / `soul.md` 影响的是**所有雇了这个本体的公司**,不是提炼出这条经验的那一家。
*
* 覆盖一份**尚未进入 git 历史**的内容,等于让上一版永久消失:
* · 文件没被跟踪 → 原内容只存在于工作区,覆盖即灭失;
* · 文件有未提交改动 → 那些改动同样只存在于工作区,覆盖即灭失。
*
* ── 失效方向:拒绝写,不是先提交再写 ──────────────────────────────────
* 「脏就先替它提交一把」看着更顺手,但那正是本仓库记过的一类事故:
* **多会话共用同一个 worktree**(见 27-P3C §15.4 / §17.2),
* 一份未提交的改动很可能是**另一个会话正在写的东西**,替它提交等于把别人半成品的
* 工作进度写进 main,而且署上不是它的名字。
* 所以这里一律**拒绝并报告**,把「要不要提交」留给人。
*
* ── 判据 ──────────────────────────────────────────────────────────────
* 两条都要满足才放行:
* ① 文件被 git 跟踪(`git ls-files --error-unmatch`);
* ② 该文件没有未提交改动(`git status --porcelain <file>` 为空)。
* 判不出来(不在 git 仓库里 / git 不可用)一律**当成不满足** —— fail-closed:
* 拿不准的时候少改一个文件,比赌一把把别人的东西覆盖掉便宜。
*/
const path = require('path');
const { execFileSync } = require('child_process');
const REPO_ROOT = path.resolve(__dirname, '../..');
/** 跑一条 git 命令,返回 {ok, stdout};失败不抛(判据要 fail-closed,不是崩) */
function git(args, { cwd = REPO_ROOT } = {}) {
try {
const stdout = execFileSync('git', args, {
cwd, encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'],
});
return { ok: true, stdout };
} catch (e) {
return { ok: false, stdout: String((e && e.stdout) || '') };
}
}
/**
* 这份文件现在能不能安全覆盖?
*
* @param {string} absPath 定义文件的绝对路径
* @returns {{allowed: boolean, reason: string, rel: string}}
* reason 是给人读的一句话,拒绝时必须说清**为什么**和**下一步做什么**。
*/
function canOverwriteDefinition(absPath) {
const rel = path.relative(REPO_ROOT, absPath);
// 仓库之外的路径直接拒 —— 这道闸门守的是「git 历史」,仓库外没有历史可言
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return { allowed: false, rel, reason: `不在仓库内(${absPath}),没有 git 历史可依托` };
}
const tracked = git(['ls-files', '--error-unmatch', '--', rel]);
if (!tracked.ok) {
return {
allowed: false, rel,
reason: `${rel} 没有被 git 跟踪:现在的内容只存在于工作区,覆盖即永久丢失。`
+ `先 git add + commit 一次,让它有历史,再改。`,
};
}
const status = git(['status', '--porcelain', '--', rel]);
if (!status.ok) {
return { allowed: false, rel, reason: `读不到 ${rel} 的 git 状态,判不出有没有历史 —— 按 fail-closed 拒绝` };
}
if (status.stdout.trim()) {
return {
allowed: false, rel,
reason: `${rel} 有未提交改动(${status.stdout.trim().split('\n')[0]}):`
+ `那些改动只存在于工作区,覆盖即丢失,而且它可能是**另一个会话正在写的东西**。`
+ `先提交或还原,再改。`,
};
}
return { allowed: true, rel, reason: `${rel} 已跟踪且工作区干净,上一版可从 git 取回` };
}
/**
* 断言版:不满足就抛。给「宁可不写也不能悄悄覆盖」的调用方用。
* @param {string} absPath
*/
function assertOverwritable(absPath) {
const v = canOverwriteDefinition(absPath);
if (!v.allowed) {
const err = new Error(`[definition-write-gate] 拒绝改写本体定义:${v.reason}`);
err.code = 'DEFINITION_NOT_IN_GIT_HISTORY';
err.rel = v.rel;
throw err;
}
return v;
}
module.exports = { canOverwriteDefinition, assertOverwritable, REPO_ROOT };