# nightshift — 设计说明 > 把「给 agent 一晚上」从一个信任动作,变成一份可审计的契约。 ## 一、它解决什么问题 `~/260913` 下那 7 个工具回答的是同一个问题的一个瞬间: | 工具 | 回答的问题 | 时间尺度 | |---|---|---| | `ctx-budget` | 这次运行要花多少上下文? | 运行前 | | `dsh-blastradius` | 这条命令会毁掉什么、能恢复吗? | 命令前 | | `dsh-deadend` | 这条路以前走死过吗? | 尝试前 | | `mcp-cap` | 这个 MCP server 到底能干什么、变了没? | 接入前 | | `skillnotary` | 这个 skill 被允许做什么? | 装载前 | | `dsh-rewind` | 怎么把目录退回去? | 出事前后 | 七个「动手前的体检」,**每一个都是一次性的、无状态的、单点的**。 而「给 agent 一晚上」是**长时的、有状态的、连续的**。人离开之后,没有人守着这七个检查点。中间发生的漂移——目标悄悄改了、预算悄悄超了、某一步静默失败了、循环空转了两小时——**没有任何一个工具在看**。 **nightshift 就是那个「看着」的层。** ## 二、核心想法(the one idea) > **manifest 是你在还清醒时签下的契约;晨报是这份契约的审计结果。** 运行开始前,你声明四件事: 1. **意图** —— 这一晚到底要产出什么(`intent.objective` / `deliverables`) 2. **验收** —— 什么算成功,且必须是**机器可执行的检查**(`acceptance[]`,每条是个 shell 命令 + 期望) 3. **禁忌** —— 什么绝对不能做(`forbidden[]`,模式 + 理由) 4. **预算** —— 上下文 token、墙钟时间、最大步数 然后 agent 去干活。nightshift 在每个动作前后记录,早上交出的晨报是**「声明」与「实际」的差异**: - 哪些验收项**真的通过**了(跑了命令,贴了输出) - 哪些**没验证**(诚实标注,不假装) - 哪些**禁忌被触碰**过 - 预算用了多少 - **怎么撤回**(`rewind` 的快照 id) 关键性质:**晨报不是人写的总结,是从日志生成的。** 一个不能从日志生成的晨报,就是一段不可信的散文。 ## 三、为什么它是这个家族的合法成员,而不是第八个点工具 三个证据,全部来自既有代码: 1. **跨工具组合已有先例**。`ctx-budget` 的 `cordis.patch.yml` 里明确写着它读取「a `tools/list` dump that `mcp-cap` already produced, or an `mcp-cap` lock file」。工具之间已经在传递工件了,nightshift 只是把这件事一般化。 2. **退出码已经是家族通用语言**。每个 CLI 都导出自己的 `EXIT` 常量表。nightshift 的第一个技术任务就是把这套方言**归一化**(见第五节)。 3. **家族铁律天然覆盖它**。惰性 `cordis.patch.yml`(不在 DSH 进程里跑代码)、零运行时依赖、skill + CLI 载荷。nightshift 是最需要遵守这条的铁律的——一个「安全编排器」如果自己扩大 boot 图谱,就是自相矛盾。 ## 四、架构 ``` nightshift/ src/ cli.js 参数解析、命令分发、退出码 tools.js ★ 适配层:定位同级工具、调用、归一化退出码 manifest.js 契约的 schema、读写、校验 policy.js 把「发现」映射成「决定」:proceed / warn / refuse preflight.js ★ 预检链:按序组合 7 个工具,产出发现集合 step.js ★ 步骤执行:快照 → 执行 → 验证 → (失败则)回滚 journal.js 只追加的事件日志(jsonl) brief.js ★ 从 manifest + journal 生成晨报 util.js skills/nightshift/SKILL.md cordis.patch.yml (惰性,`[]`) test/ ``` ★ = 这个项目的实质所在。 ## 五、退出码归一化(适配层的核心) 七个工具的退出码语义各不相同,且**互相冲突**——同一个 `3` 在三个工具里是三个意思: | 工具 | 3 | 4 | 5 | 6 | |---|---|---|---|---| | `ctx-budget` | OVER_BUDGET | — | UNKNOWN | NOTHING | | `dsh-blastradius` | LOST | COSTLY | UNVERIFIABLE | — | | `dsh-deadend` | DRIFT | NEEDS_YES | REFUSED | NOOP | | `dsh-rewind` | CHANGED | ESCALATED | REFUSED | REFUSED | nightshift 定义一层**归一化词汇**,把每家的方言翻译过来: | 归一化 | 含义 | 对 policy 的意义 | |---|---|---| | `ok` | 没问题 | 放行 | | `error` | 工具自己出错 | 降级并标注 | | `usage` | 调用方式错 | 降级并标注 | | `attention` | 有情况,需要人看 | 按 policy 决定 warn/refuse | | `refused` | 工具**拒绝执行**(什么都没发生) | 必须尊重,不得绕过 | | `noop` | 无事可做 | 放行 | | `unverifiable` | 工具无法判断 | **不当作 ok** —— 单独一类 | | `unavailable` | 工具不在 | 降级(见第七节) | `unverifiable` 单独成类,是因为这个家族反复强调的一句话:**「I cannot tell you」是一个真实的答案。** 把它折叠进 `ok` 是这个项目最可能犯的错,也是最危险的错。 ## 六、一条命令的生命周期 ``` nightshift init 写下契约(意图/验收/禁忌/预算) nightshift preflight 跑预检链 → 发现集合 → policy 判定 → 允许开工吗 nightshift step -- [--verify ] 快照 → 执行 → 验证 → 失败自动回滚到本步快照 nightshift status 当前进度、预算消耗 nightshift brief 生成晨报(从日志,不是从记忆) nightshift rollback 回到某个检查点 ``` ## 七、诚实的降级策略 不是每个工具都一定装好。nightshift 的规则: - **`rewind` 缺席是唯一不可接受的。** 没有回滚能力的通宵运行不该开始 —— 这直接退回 `REFUSED`。 - 其它工具缺席 → 该项标为 `unavailable`,晨报里明确写「这一项没查」,整体退出码降为 `DEGRADED`。 - **不假装查过。** 覆盖度(coverage)是晨报的一等公民:`7/7 tools ran` 或 `5/7 tools ran(缺:mcp-cap, skillnotary)`。 ## 八、执行边界的门(`src/guard.js`) 预检只在**会话启动时**判断一次,而且只能判断 agent **声明过**的命令。但 `step` 能执行任何东西,而无人值守的运行有整个夜晚可以用来改主意。 所以同一套判断被下移到了**执行点**:每次 `step` 在执行前都会过一次 `guardCommand`, 1. **禁止模式** —— 常驻指令,最先匹配,且不可绕过 2. **不可逆分级** —— 用 `blastradius` 定价,按**退出码**(不是 verdict 字符串)读成等级 3. **宵禁** —— 跑到第 N 分钟后,不再允许发起新的单向动作 4. **分级策略** —— manifest 的 policy 一条拒绝在这里的含义和家族其他地方一致:**命令没有运行,什么都没变**。 ### 8.1 一个真实发现的语义冲突:快照 vs git 把 guard 接上去之后,一个原本通过的测试失败了。原因是: ``` $ blastradius check --cmd "echo changed > data.txt" { "findings": [{ "recoverability": "lost", "why": "not inside a git repository, so nothing can restore it", "samples": ["data.txt"] }], "verdict": "lost" } ``` `blastradius` 说 `lost`,意思是「**git** 救不回来」。但 `rewind` 存在的全部理由就是恢复「git 从未跟踪过的文件」,**有 git 没 git 都行**。所以对**保护根之内**的路径,紧随其后的那个快照确实能把它救回来。 两个工具没有矛盾——它们在**不同前提下**回答同一个问题。直接采信任一方都是错的: - 全信 `blastradius` → 几乎每个破坏性操作都被拒,工具没法用来做真正的重构 - 全信快照 → 根**之外**的破坏(`rm -rf ~/别的东西`)会被当成可恢复,而快照根本覆盖不到它 正确做法是用 `blastradius` 自己的 `samples` 去判断,而不是猜: ``` lost 且 assumeCheckpoint ├── 所有 at-risk 路径都在根内 → 降级为 one_way_recoverable(警告,放行) ├── 有路径在根外 → 保持 one_way_terminal(拒绝) └── findings 没有给出任何路径 → 保持 terminal(拒绝) ``` 最后一条是刻意的:**"判断不了"永远不是升级。** 无法证明覆盖 = 没有覆盖。 `assumeCheckpoint` 默认 `false`,只有 `step.js` 传 `true`——因为只有它保证「放行则必先快照」。单独调用 `guardCommand` 的调用方拿不到这个假设,因此更严格。 这一条已被端到端验证:根内的 `rm` 放行且可回滚;根外的 `rm -rf` 被拒且文件仍在。 ### 8.2 宵禁 `manifest.curfew.afterMinutes` 之后,**新的**单向动作一律不许开始。两向动作仍然允许——因为「凌晨 6 点因为跑太久所以不许保存文件」是比宵禁本来要防的更糟的失败。 等级为 `unknown` 的动作在宵禁期间也会被拦:不知道是不是两向,就不当作两向。 ## 九、晨报里的「被拒绝的动作清单」 研究结论里有一条直接改进了晨报: > 早晨交付的不是"完成了",而是一个**复核包**:签名凭证链 + diff + oracle 结果 + **全部被拒绝的动作清单**(最易被忽略,也最能证明门真的触发了)。 **一份只报告"做了什么"的晨报,无法证明门曾经工作过。** 一个从未被触发的门,和一个对什么都放行的门,从外面看完全一样。把拒绝的清单列出来,才是区分它们的东西。 所以晨报有一节 `## Refused actions`,分两处来源:预检时的拒绝(针对声明过的命令)和执行边界的拒绝(针对实际要跑的每一个)。同时 `counts.refusedAtBoundary` / `counts.refusedAtPreflight` 进 JSON,方便脚本消费。 ## 十、边界(明确不做的事) - **不是 agent 框架**。它不决定干什么,只记录和监督已经决定的事。 - **不是调度器**。它不自己醒来,靠调用者(DSH goal / cron / 人)驱动。 - **不是沙箱**。它靠 `blastradius` 预判 + `rewind` 兜底,不提供隔离。 - **不是备份**。`rewind` 的存储和目录同盘,防的是误改,不是坏盘。