@kira905/dsh-butler-core
v0.3.0
Published
Minimal, dependency-free skeleton of a self-maintaining ops loop for DSH: tick scheduler + autonomy matrix + queue notifications + daily report + audit log. Zero runtime dependencies.
Maintainers
Readme
dsh-butler-core · 最小可跑运维骨架
本仓是命令行/库(3 个命令),其中
ops-tickd是常驻调度 —— 装完不是"跑一次就完",要起进程并常驻;另外两个(ops-autonomy/ops-changelog)才是一次性命令。
装完自检(三条只读验法)
<profile> 换成你实际用的 profile(默认 web),<数据根> 换成你的 DSH 数据根(默认 ~/.dsh)。
# ① 组合树里有没有它(只读、约 0.6 秒、不启服务)
dsh --profile <profile> --dump-config | Select-String "dsh-butler-core"
# 期望:bundle 层出现一行 "# == <登记名>"、insert 层出现 "- id: <entry>"(问的都是「它挂上没」);
# 本仓包名 @kira905/dsh-butler-core —— 带 scope 装的写带 scope 名、老工装 / 手写的可能是裸名,两种都搜一遍
# ⚠️ 若该包会 insert 一行,还要数一遍"同一个 id 出现了几次",必须 == 1
# ② 组合列表里有没有它(只读)
Get-Content <数据根>/profiles/<profile>/package.json | Select-String "@kira905/dsh-butler-core"
# 期望:dependencies 与 dsh.profile.bundles 两处都有(CLI 类只需要 dependencies;bundles 里也可能写裸名 dsh-butler-core)
# ③ 命令在不在 —— 【只查存在,不执行】
Test-Path <数据根>/profiles/<profile>/node_modules/.bin/ops-autonomy.CMD # 装进 profile 的
Test-Path <npm 全局 bin 目录>/ops-autonomy.CMD # npm i -g 装的
# 期望:True。两条路径按你实际用的装法挑一条
# <npm 全局 bin 目录> 用 `npm prefix -g` 查(Windows 上通常是 node 安装目录旁边那个 npm 目录)探针只做存在性检查(只看命令文件在不在,不执行任何命令)。 要不要用
--help验,见本仓当前说明与该命令的参数表。
一个零运行时依赖的 Node.js 小骨架:定时调度 + 自主闸门 + 通知队列 + 日报 + 审计链。 它给「长期在自己机器上跑 agent」的人用——先让一个循环能跑起来、能留下可查的痕迹,再谈自动化。
English README: README.en.md
0. 先说边界(很重要)
这是一份 distilled skeleton —— 提炼重写的最小骨架,不是生产全家桶的逐字副本。
- 代码是独立重写的:与任何一个"生产版大管家"里同名文件的 sha 都不同,多数没有同名源文件。 它保留的是同一套纪律(原子写 / 单例锁 / 哈希链审计 / fail-safe 闸门 / 幂等判据), 而不是同一批代码。
- 它不依赖 DSH 主包,也不需要宿主进程:
node直接跑,零dependencies。 - 它只做调度与记账,不做会话管理、看板、记忆、插件装载、面板 UI、跨机同步。
如果你要的是"装完就能接管一整套 DSH 生态",那不是一个包的事,看生态里的其它组件; 如果你要的是"读得完、改得动、能嵌进自己脚本里"的最小运维环,这个包就是。
1. 给谁看
| 你 | 你会用到它做什么 |
|---|---|
| 在自有机器上跑长期 agent 的人 | 让"每天该做的事"有个调度骨架:出日报、发通知、清过期锁 |
| 想读一个不依赖框架的调度器的人 | 全套 17 个运行期 .mjs、一个文件一件事,没有隐式依赖 |
| 想给自己的项目加"可追责自动化"的人 | 审计链(每条带 prev/hash)+ 双闸放行,抄走即可 |
不适合:要开箱即用的运维平台、要图形界面、要跨机编排的人。
2. 三分钟跑起来
需要 Node.js >= 20。不需要 npm install(零依赖)。
Windows(PowerShell)
git clone <本仓地址>
cd dsh-butler-core
powershell -ExecutionPolicy Bypass -File .\examples\quickstart.ps1Linux / macOS
git clone <本仓地址>
cd dsh-butler-core
sh ./examples/quickstart.sh或者直接一条命令
node bin/ops-tickd.mjs --once --home ./.ops-demo-home
quickstart默认把状态写到当前目录下的.ops-demo-home,不会碰你的~/.dsh。 想换个位置:-StateHome <目录>(PowerShell)或OPS_CORE_HOME=<目录>(sh)。
跑完你会看到类似:
ops-tickd:home=.../.ops-demo-home · 档位=observe · 强制
job 状态 耗时ms 说明
queue-notify ok 3 ok
self-heal ok 5 ok
daily-report ok 8 ok
产物:.../.ops-demo-home/ops-reports/2026-10-20.md
审计:.../.ops-demo-home/ops-audit
档位:observe(只观察:写日报/审计/本地 outbox,不做修复)· 自主动作:全部关闭再跑一次同一条命令:日报不会重写(内容逐字节相同就跳过),这就是幂等判据。
3. 一轮 tick 会留下什么
| 产物 | 位置 | 一句话 |
|---|---|---|
| 日报 | ops-reports/<日期>.md | 档位 / 上一次 job 状态 / 状态文件健康 / 队列统计 / 告警条数 |
| 审计流水 | ops-audit/<年-月>.jsonl | append-only,一行一条 JSON,带 prev + hash 哈希链 |
| 通知产物 | ops-notify-outbox.jsonl | 默认通道 = 本地文件,一行一条"本该发出去的通知" |
| 告警 | ops-alert.log | 组件自身故障(不是业务告警) |
| 调度状态 | ops-tickd.json | 最近 50 次 tick 的读数 + 每个 job 的上次状态 |
| 自愈流水 | ops-heal-log.jsonl | 初始化了哪些状态文件、清了哪些过期锁 |
想让它真的有东西可发:往 ops-queue.json 里放一条
{ "items": [{ "level": "info", "message": "hello" }] },下一轮就会进 ops-notify-outbox.jsonl,
并记进 ops-notify-state.json 的 processed[](同一条不会发第二次)。
4. 状态文件清单(缺了会怎样)
全部状态都在一个根目录下(--home > OPS_CORE_HOME > DSH_HOME > ~/.dsh)。
逐项的完整说明(谁写 / 是什么 / 备份 / 能不能出机器)见
docs/状态文件清单.md。
| 文件 | 谁写 | 缺了会怎样 |
|---|---|---|
| ops-autonomy.json | ops-autonomy / ops-tickd | 自动建默认(observe + 全关);删了等于丢放行记录(安全但麻烦) |
| ops-tickd.json | ops-tickd | 自动建空状态;只影响"上次跑到哪"的参考值 |
| ops-queue.json | 你(唯一由使用者喂数据的文件) | 自动建空队列;没有它就没有通知可发 |
| ops-notify-state.json | queue-notify job | 自动建空去重表;手删会重发历史通知 |
| ops-notify-outbox.jsonl | queue-notify job | 首次通知时自动创建;是产物,不是状态 |
| ops-audit/<年-月>.jsonl | 所有写动作 | 首次写审计时自动建;别删,排障与追责的唯一凭据 |
| ops-alert.log | 组件自身 | 首次告警时自动建;是产物 |
| ops-reports/<日期>.md | daily-report job | 下一轮自动重建当天那份 |
| ops-locks/*.lock | 调度器 | 首次加锁时自动建;进程崩溃会留过期锁(由自愈清理) |
| ops-locks/ops-tickd-instance.lock | scheduler.ensure() | 别手删(删了等于允许起第二个调度器);孤儿锁由下一次 ensure() 按"pid 已死 / pid 被复用"回收,见 §7.1 |
| ops-locks/ops-tickd-instance.lock.beat | scheduler.heartbeat() | 心跳只作证据:缺了不代表实例死了,新了也不代表它活着(见 §7.3) |
| ops-heal-log.jsonl | self-heal job | 首次修复动作时自动建;是产物 |
| ops-changelog/<年-月>.md | ops-changelog add | 第一次记账时自动建 |
本包不读不写任何凭据文件(例如宿主 home 里的
.credentials.yaml); 仓库与默认运行路径里没有任何外发地址。
5. 自主矩阵:谁可以自己动手
默认值是刻意保守的:能写产物,不能修东西。
| 项 | 默认 | 含义 |
|---|---|---|
| mode | observe | 只观察:写日报 / 审计 / 本地 outbox |
| mode=act | 需显式切 | 允许"实际修复动作"发生(但还受下一行管) |
| auto.selfHeal | false | 即使 act,也要这一项为 true 才清过期锁 |
也就是说,自愈的实际动手需要双闸:mode=act 且 auto.selfHeal=true。
判据 fail-safe:文件缺失 / 解析失败 / 字段不是 true,一律按"关"处理。
node bin/ops-autonomy.mjs status # 看档位与开关
node bin/ops-autonomy.mjs mode act --why "手动清一次过期锁"
node bin/ops-autonomy.mjs auto on selfHeal --why "同上"
node bin/ops-autonomy.mjs module off daily-report # 关掉某个 job(改开关,不改代码)每次开关写盘都带 by / why,并落一行审计。
6. 接自己的通知通道
默认通道只写本地 outbox。要发到自己的系统,显式传入地址(仓库内零默认地址):
import { makeQueueNotifyJob, webhookNotifier } from '@kira905/dsh-butler-core';
const job = makeQueueNotifyJob({
home: '/path/to/home',
notifier: webhookNotifier({ url: process.env.MY_HOOK_URL, headers: { authorization: `Bearer ${process.env.MY_HOOK_TOKEN}` } }),
});webhookNotifier 只发一行 JSON(source / level / message / at),失败返回 { ok:false },不抛错;
发送失败不会标记 processed,下一轮自动重试(同时落一条告警)。
7. 三条工程纪律(为什么它敢动你的目录)
- 原子写:先写同目录临时文件 →
fsync→rename覆盖;失败重试 3 次,仍失败就保留.tmp并抛错, 绝不退化为直接写目标。 - 哈希链审计:每条审计记录带
prev(上一条 hash)与自身hash;任何删除/改写都会在ops-tickd --state的链校验里暴露。 - 单例锁 + 幂等:每个 job 执行前用
wx原子抢锁;抢不到 = 本轮跳过(不是失败)。 日报"内容相同就不写",通知"同一条只发一次" —— 重复跑不产生重复记账。 ⚠️ 这只是job 锁(别让两个 tick 同时跑同一个 job)。调度器进程本身的互斥是另一件事, 见下一节 —— 它比"过期就回收"复杂得多。
7.1 调度器单实例:锁为什么不能只看 TTL
ops-tickd --interval 是长跑进程。如果它被起了两份,通知会重复发、日报会互相覆盖。
最常见的翻车方式不是"忘了加锁",而是加错了判据:
锁主还活着,但 TTL 到期了 ⇒ 被判"过期" ⇒ 删锁 ⇒ 起第二个 ⇒ 两个一起跑。
TTL 是"多久没动静算死",可它答不了"那个进程还在不在"。所以 src/lock.mjs 把判定拆成两条互不冒充的问题:
| 问题 | 判据 | 答不了时怎么办 |
|---|---|---|
| 锁主还在不在? | process.kill(pid, 0):成功 / EPERM = 活,ESRCH = 死 | 判不了 ⇒ fail-open 判活(不删锁) |
| 这个 pid 还是当初那个进程吗? | 只看方向:进程启动时间 晚于 锁 startedAt(超容差)才可能是 pid 复用;早于 ⇒ 同一个进程 | 取不到启动时间 ⇒ fail-open 判活 + 写审计 |
三条写死的口径(改之前先读 src/lock.mjs 文件头):
- 方向,不是绝对差。 两个时间戳都来自墙钟:宿主休眠 / NTP 校时 / 跨时区迁移之后,
差值会变负或变成巨值。按
|差| > 阈值判 ⇒ 负值判"不是撞号"(于是删锁起第二个,正是上面那条翻车路径), 巨值判"是撞号"(判活/判死反向)。按方向判,两种异常都落在安全侧。 - 判不清就判活,但要写审计。 出现负差、读不到时钟、pid 存活判不了 —— 一律
fail-open判 alive, 并各写一行审计(instance.lock-fail-open/instance.lock-clock-skew)。这是明面行为,不是隐式兜底。 - 容差可配,默认 120 s。 它只需要盖住「同一进程的启动时刻」与「它写锁的时刻」之间的自然差
(量级 < 3 s),不是盖住 tick 间隔;默认取生产侧 TTL 的最小档 120 s,并允许派生
min(ttlMs, 120s)。
7.2 fail-open 的出口:连续 N 个周期没写出新状态 ⇒ 告警
fail-open 的代价是:锁主真的死了却被判活 ⇒ 第二个实例永不启动 ⇒ 调度静默停摆。 这比"双实例"更贵,因为它是静默的。所以配了一条只看"状态有没有在推进"的出口:
const lv = scheduler.checkLiveness(); // 读 ops-tickd.json 的 lastStateWriteAt
if (lv.stalled) { /* 已自动 alert(source=ops-instance-liveness)+ 写审计 instance.stalled */ }- N 可配:
createScheduler({ livenessCycles: 3, livenessIntervalMs: 60_000 })(默认 3 个周期)。 - 同一段停摆只喊一次(否则每轮一条,人会直接把通道关掉);状态一推进就自动解除。
- 从没写出过状态 ⇒ 判
never-started,不喊"停摆"(新装第一天不该误报)。 - 出口的三条读数:
ops-alert.log(告警)、ops-audit/(instance.stalled)、ops-tickd --state(人看)。
7.3 心跳只作证据,不作判据
ops-locks/<实例>.lock.beat 记录"上次见到这个实例动是什么时候"。它不参与任何判定 ——
否则又变回"用一个会停的时钟判生死"(心跳停了不等于进程死了;心跳很新也不等于进程还活着)。
它存在只为两件事:排障时回答"它最后一次动是什么时候"、给审计留证据。
7.4 Windows 上的已知限制(如实写)
取进程启动时间需要读 /proc/<pid>/stat(Linux)。Windows 上没有零依赖、零子进程的等价读法,
所以本组件在 Windows 上默认返回「取不到」⇒ 判定走 fail-open 判活(安全侧),由 7.2 的失败环兜底。
想让 Windows 上也判 pid 撞号,把 startTimeOf 注入进来即可:
// 例:调用方自己(在你那边、用你愿意的方式)先取一张「pid → 启动时刻(ms)」表,
// 再把它注入进来。组件本身仍然零子进程 —— 起子进程这件事由调用方决定,不藏在判定路径里。
const startedAtByPid = new Map([
[12345, Date.parse('2026-10-30T08:00:00+08:00')],
]);
createScheduler({
home,
startTimeOf: (pid) => startedAtByPid.get(pid) ?? null, // 表里没有 ⇒ null ⇒ 照旧 fail-open 判活
});反过来,本进程自己("同一个进程反复 ensure"这个最常见的场景)在任何平台都取得准(process.uptime()),
所以"自己手里的锁会不会被自己判死"这条铁律在两个平台上都成立。
8. 目录结构
.
├── bin/
│ ├── ops-tickd.mjs # 跑一轮 / 常驻 / 只看状态
│ ├── ops-autonomy.mjs # 档位与开关
│ └── ops-changelog.mjs # 一句话记账(带幂等键)
├── src/
│ ├── paths.mjs # 路径解析单点(home / 各状态文件)
│ ├── store.mjs # 原子写 / 读改写 / 单例锁
│ ├── audit.mjs # 审计链唯一写入口
│ ├── alert.mjs # 告警通道
│ ├── autonomy.mjs # 自主矩阵(档位 / 模块 / 自动动作)
│ ├── scheduler.mjs # 轻量调度器(无常驻进程)+ 实例锁 ensure / 失败环 checkLiveness
│ ├── lock.mjs # 实例级单例锁:pid 存活 / pid 撞号(方向判据)/ fail-open / 心跳证据
│ ├── registry.mjs # 三个 job 的装配与顺序
│ ├── state-files.mjs # 状态文件清单的单一数据源
│ ├── notify.mjs # 通知适配器(file / webhook)
│ ├── cli.mjs # 极简参数解析与表格
│ ├── index.mjs # 程序化入口
│ └── jobs/
│ ├── queue-notify.mjs # 队列 → outbox(去重)
│ ├── self-heal.mjs # 初始化状态 / 清过期锁(双闸)
│ └── daily-report.mjs # 日报(同日幂等)
├── docs/
│ ├── 架构一页.md
│ └── 状态文件清单.md
├── examples/
│ ├── quickstart.ps1
│ └── quickstart.sh
├── scripts/
│ ├── verify-source.mjs # 发布前自检(语法 / 绝对路径 / 私有依赖 / 文件清单)
│ ├── selfcheck-replica.mjs # 装完之后自检(13 项,见 §9)
│ └── lib/ # 自检用的零依赖件(清单校验 / 版本区间 / 路径解析 / dump 解析 / 探针计划)
│ ├── manifest.mjs # 清单校验(与安装器同源;每条错误带行号)
│ ├── semver.mjs # 版本区间三态判定(yes / no / unknown)
│ ├── global-paths.mjs # 全局安装位置解析(**零 spawn**,只读盘)
│ ├── dump-parse.mjs # --dump-config 解析 + 挂载四格判定(纯函数)
│ ├── probe-plan.mjs # 探针该不该跑(禁跑未核 --help)+ 整批墙钟
│ └── default-manifest.mjs # 内置兜底清单(3 项)
└── test/
├── run-all.mjs # 夹具总入口(假 home / 假 profile / 假包 / 假 dump,全部临时目录)
├── test-instance-lock.mjs # 单实例守卫:活锁 / pid 撞号 / 孤儿双跑 / fail-open / 失败环
└── test-mutation-lock.mjs # 把单实例判据改反 ⇒ 上面的夹具必须红程序化用法:
import { createOpsScheduler } from '@kira905/dsh-butler-core';
const { scheduler } = createOpsScheduler({ home: process.env.OPS_CORE_HOME });
const run = await scheduler.tickOnce({ force: true });
console.log(run.executed, run.failed);9. 自检
node scripts/verify-source.mjs它会检查四类事,任一不满足即 rc != 0:
- 所有
.mjs过node --check; - 源码与脚本里没有写死的绝对路径(盘符 / 用户主目录 / 临时目录);
- 没有私有依赖:
dependencies为空,所有import只指向node:内置或本仓相对路径; package.json与文件清单一致:files/bin/main/exports指到的文件都存在。
装完之后:13 项自检
verify-source 管的是"这份源码能不能发";装完之后还想知道**"这套环境装成了没有"**,跑这个:
node scripts/selfcheck-replica.mjs --home <你的状态根>
# 自动化/机读:
node scripts/selfcheck-replica.mjs --home <你的状态根> --json
# 按装机清单逐项判(推荐:清单跟安装器共用同一份)
node scripts/selfcheck-replica.mjs --home <你的状态根> --manifest <suite-manifest.json> --profile web它按"装完能不能用"排了 13 项 —— 前 10 项判"这套骨架本身",后 3 项按清单判"你装的那些组件":
| # | 检查 | 判据 | 找不到时 |
|---|---|---|---|
| 1 | Node 版本 | ≥ engines(20) | — |
| 2 | 状态根可写 | 能建/删临时文件(真实 home 只做 W_OK 只读探测) | FAIL |
| 3 | DSH 主包在 | --dsh-root / 读全局 node_modules 的 package.json / dsh --version / npm ls -g | SKIP |
| 4 | DSH 端口活着 | 任意 HTTP 响应都算活着(401 = 服务在但要凭据) | SKIP |
| 5 | 看板插件加载 | 插件 entry 存在 + API 返回 tasks 数组(无凭据时 401 也算 entry 通过) | SKIP |
| 6 | 记忆引擎可用 | --memory-cmd 的 --probe 退出码 0 | SKIP |
| 7 | 会话工具在 | session-mark --help 退出码 0 | SKIP |
| 8 | 状态文件就位 | 按 docs/状态文件清单.md 逐项判(必需件 + 坏 JSON) | FAIL |
| 9 | tickd 能跑一轮 | --once --json 退出码 0、job 齐、日报产出 | SKIP(真实 home 需 --allow-production) |
| 10 | 生产/夹具隔离 | 写闸 3 条自测 + 本次写入全在 home 内 | FAIL |
| 11 | 包在不在 / 版本对不对 | 分两路:plugin 类读 profile 的 dependencies + 实体 package.json 的 version;cli 类查全局(也看 profile)。两处都没有才 FAIL | FAIL |
| 12 | 挂载生效没 | 四格:包自己的 dsh.bundle × profile 的 dsh.profile.bundles × --dump-config 的组合树;外加"登记了但包没声明""撞 id/同 name""抢同一个记忆位置" | FAIL |
| 13 | 关键能力能不能跑 | 每项探针由清单给:existence(只查存在,零执行)/ help(仅限核过 --help 的命令)/ cmd(清单指定的只读命令);统一 8 秒超时 + 整批墙钟 30 秒 | FAIL |
第 11/12/13 项是清单驱动的,清单优先取 --manifest;不给就自动找全局装着的
@kira905/dsh-butler-install/suite-manifest.json(和安装器读同一份真相);都没有才用内置 3 项兜底,
输出里会写明用的是哪一份。旧 10 项不读清单,行为与以前一致。
第 11 项的 cli 类 detail 会写清装在哪(全局 / profile / 都没有),因为"不知道该往哪看"正是它要回答的问题。
版本判定是三态:区间内 / 区间外 / 无法判定(带预发布后缀的实体版本本工具不猜,一律不算通过)。
第 12 项的两条诚实边界(结论要跟这两句一起读):
- 它判的是组合,不是运行时 ——
--dump-config只证明"组合树里有这一行", 不证明"运行时没崩"。真要验"没崩",只能人工起一次dsh web --no-open看日志。 --dump-config抓不到**"撞 id"以外的运行时冲突** —— 两个不同id抢同一位置时 dump 里是两行、 宿主也不报错(要等插件自己抛 "already registered")。所以第 12 项只覆盖"同 id / 同 name"这一种, 别把它当万能判据。
# 第 12 项相关的两个开关(离线核对 / 不想起子进程时用)
node scripts/selfcheck-replica.mjs --dump-config-file <一份 dump 文本> # 零执行,只解析
node scripts/selfcheck-replica.mjs --no-dump # 组合树维度直接降级 SKIP退出码:0 = 无 FAIL(SKIP 不阻断)· 3 = 有 FAIL · 2 = 用法错 / 安全拒跑。
只读为主:默认唯一的写是 home 内一个临时探测文件(写完立刻删);真实 home 连它都不写。
要初始化缺失的状态文件用 --fix —— 它只跑一遍 self-heal,写入全在 home 内;
对真实 home(<用户主目录>/.dsh)做写入型动作,必须再显式加 --allow-production。
找不到不是失败,指定了才算断言:没装那件就是 SKIP(带原因 + 建议)。
但只要显式指定了 --dsh-root / --plugin-dir / --memory-cmd / --session-tool,无效就报 FAIL
—— 静默降级会骗人。
它自己会红的三个样例(都是实测输出,路径用 <…> 隐去):
# ① 缺件:home 里没有 ops-autonomy.json,且 job 注册表少了 daily-report
8 状态文件就位 FAIL 必需件缺失 1 个(ops-autonomy.json)
9 tickd 能跑一轮 FAIL 跑通了,但缺 job:daily-report(实际 queue-notify、self-heal)
结果:PASS 3 · SKIP 5 · FAIL 2 ⇒ 有未通过的检查项
# ② 显式指定了坏目标(显式即断言)
3 DSH 主包在 FAIL --dsh-root 下没有 @deepseek-ai/dsh:<dir>
4 DSH 端口活着 FAIL http://127.0.0.1:39871:连接失败(ECONNREFUSED)
5 看板插件加载 FAIL --plugin-dir 下没有可加载的 entry:<dir>
6 记忆引擎可用 FAIL 召回探针 rc=4:<probe 的 stderr>
结果:PASS 3 · SKIP 0 · FAIL 7 ⇒ 有未通过的检查项
# ③ 写闸:对真实 home 加 --fix(不带 --allow-production)必须拒跑、零写入
[REFUSE] --fix 拒写:home = <真实 home>(判定 production,原因 HOME_IS_PRODUCTION_WITHOUT_ALLOW)
[REFUSE] 真实 home 需要显式 --allow-production;夹具 home 走 REPLICA_GUARD 执行器。零写入退出。
(退出码 2)隔离自守:在夹具/演练环境里跑时,执行器会注入 REPLICA_GUARD=1 + REPLICA_FIXTURE_ROOT;
本脚本据此断言 home 落在夹具树内,否则 rc=2 拒跑。直接裸跑(普通安装)时这两个变量不存在,按普通安装处理。
改了这个仓的判据之后:先跑夹具,再谈交付。
node test/run-all.mjs166 条断言,覆盖:清单校验 / 版本三态 / 段解析与撞车口径 / 跳过归因 / 四格每一格 / W1 的零 spawn 探测 / 旧 10 项不受清单影响 / 第 11–13 项端到端(假 home + 假包 + 假 dump)/ 变异测试(把四格任意一条判定改反 ⇒ 夹具必须红)。夹具只用系统临时目录里的假件, 不读也不写任何真实安装。
10. 它不做什么(避免误会)
- 不提供外发通道默认值(企业微信 / 钉钉 / Slack 之类一概不带,只留一个显式 webhook 适配器);
- 不提供定时器守护:常驻循环只有
--interval,进程崩了要靠系统的计划任务 / systemd 拉起; - 不管理宿主生命周期(不启动、不重启、不杀任何服务);
- 不做跨机同步、不做数据迁移;
- 不扫描、不读取、不上传你的任何文件——它只写自己那几个
ops-*文件。
11. 许可
AGPL-3.0-only + 商业授权(双许可),见 LICENSE。
- 个人、学习、开源项目:按 AGPL-3.0 自由使用与修改(衍生作品需同样以 AGPL 开源)。
- 需要在闭源 / 商业场景使用(AGPL 的传染条款不适用):可另行获取商业授权。 本仓暂无公开联系邮箱,请通过本仓 Issue 联系。
说明:AGPL / MIT / Apache 本身都不禁止商用;这里的"双许可"含义是 「要么按 AGPL 开源衍生作品,要么购买商业授权」,与"文档侧禁商用"不是一回事。
本仓是「最小可跑骨架」:先把循环跑起来并留下痕迹,再往上加东西。
