definition-write-gate.js 4.63 KB
/**
 * 改**本体定义文件**(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 };