/** * 改**本体定义文件**(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 ` 为空)。 * 判不出来(不在 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 };