cordis-plugin-loop
Verified@xinvxueyuan/cordis-plugin-loop · v0.4.0 · MIT OR Apache-2.0 · Web UI
Cordis (DeepSeek Harness) plugin: the /loop command — run a task now, then keep it going on a fixed cadence or a model-chosen one
Install
dsh plugin add @xinvxueyuan/cordis-plugin-loop Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
@xinvxueyuan/cordis-plugin-loop
English | 中文
DeepSeek Harness(DSH)插件:实现 /loop 指令 —— 立刻跑一轮,随后按固定节奏(墙钟 cron + 抖动)或自定节奏(模型用 loop_next 续拍 / loop_stop 结束)继续,直到 7 天过期、用户停止、或被安全网叫停。
本插件复用宿主已有的 @deepseek-ai/dsh-schedule(持久提醒服务)承载每一条 loop:一行任务的标题与正文是 loop 的全部可变状态,因此 loop_next、/loop stop 与遗漏安全网都只需 ctx.schedule.update 就地替换同一条记录,会话内 loop 记录数在正常路径下恒为 1(唯一的例外是替换时旧记录删除被存储层拒绝——那时两条并存并如实告知,见自定节奏)。
- 包名:
@xinvxueyuan/cordis-plugin-loop - 版本:
0.4.0 - 依赖:
inject里的硬依赖是tools(@deepseek-ai/dsh-tools)与schedule(@deepseek-ai/dsh-schedule);commands(@deepseek-ai/dsh-commands)是可选(reactive)。三个包在peerDependencies里都声明为可选 peer,旧版 DSH 上不阻塞安装。
目录
安装与装配
plugins:
'@xinvxueyuan/cordis-plugin-loop': {}
schedule 是 inject 里的硬依赖:真实装配下它缺失时,Cordis 会让本插件不激活 —— 零命令、零工具、零记录,profile 照常启动(本插件起不来不会拖垮整个 profile)。
apply 内另保留一条防御性惰性路径:若某个组合强行挂载本插件而没有 schedule(例如覆盖 inject,测试 harness 正是这么挂的),它只写一条警告日志、不注册任何东西,fiber 保持 ACTIVE。两条路径都不注册任何表面,区别只在"是否激活"。
命令行表面需要 ctx.commands(@deepseek-ai/dsh-commands)。缺少它时工具照常注册,并额外写一条警告说明命令面缺失。
两个运行期依赖(都是宿主的包):注入到会话里的每一轮由 createUserMessage(@deepseek-ai/dsh-llm)铸造;配置校验用 @deepseek-ai/schemastery(src/config.ts 运行期 import)。任何 DSH 安装都带这两个包(schemastery 同时是 dsh-tools/dsh-schedule 的依赖,dsh-llm 是 dsh-schedule 的 peer),所以不需要在 profile 里额外装配,运行期由宿主安装树解析。
package.json 把两者声明为可选 peer(peerDependencies + peerDependenciesMeta.optional: true,与本仓对 dsh-schedule/dsh-commands 的写法一致):这样依赖关系是机器可读的,又不会在旧版 DSH 上阻塞安装(必需 peer 会)。
产物的 .d.ts 也引用这两个包,所以隔离消费方的类型检查需要宿主已装它们:lib/index.d.ts 有 import { createUserMessage } from '@deepseek-ai/dsh-llm',lib/config.d.ts 有 import z from '@deepseek-ai/schemastery'。隔离环境实测会报 lib/index.d.ts … TS2307: Cannot find module '@deepseek-ai/dsh-llm' 与 lib/config.d.ts … TS2307: Cannot find module '@deepseek-ai/schemastery'——这是声明缺失的表现,在本仓已通过上面的可选 peer + devDependency 表达清楚;只要宿主的 node_modules 可解析,消费方类型检查即通过。
组合节点(官方)
本包以官方 bundle 形态发布:package.json 声明 dsh.bundle.patch,cordis.patch.yml 用一行 Loader entry(id: loop,name 为本包名)把它装进 profile。既然是 bundle,它消费的官方服务节点就必须在 patch 里显式声明,装载顺序才是确定的,而不是靠“服务碰巧先到”:
| 服务键 | 官方提供方(row / 包) | 必需性 | 用途 |
|---|---|---|---|
tools |
row tools / @deepseek-ai/dsh-tools |
必需 | 注册 loop_* 工具 |
schedule |
row schedule / @deepseek-ai/dsh-schedule |
必需 | 记录与投递轮次 |
commands |
row commands / @deepseek-ai/dsh-commands |
可选(reactive) | 注册 /loop;缺它时插件仍激活但无 /loop |
tools与schedule是硬依赖:patch 的inject: [tools, schedule]与src/index.ts的export const inject = ['tools', 'schedule']一致,插件会等这两个服务就绪再apply;缺任一个时不会在半装配状态先跑起来。commands故意不进inject:命令面是可选的,src/index.ts:316用ctx.inject(['commands'], …)反应式注册——命令注册表晚到也能在不需要重载的情况下拿到/loop;没有它时插件照常激活,只写一条警告。- patch 与
src/index.ts的inject必须一致:前者决定 Loader 的装载顺序,后者决定 Cordis 的服务注入,两者是同一份事实的两处投影。test/manifest.test.ts用两条正则分别从 patch 与源码提取名单并断言集合相等,任何一侧单独漂移都会让npm test红——所以改一处必须同时改另一处。
语法
七种调用形式,保留字仅在整段输入精确匹配时生效:
| 形式 | 示例 | 行为 |
|---|---|---|
裸 /loop |
/loop |
用默认任务文件,固定节奏 10m |
/loop <interval> |
/loop 10m |
用默认任务文件,固定节奏 10m |
/loop <task> |
/loop 盯住构建队列 |
自定节奏:初始待唤醒 = safetyCheckSeconds |
/loop <interval> <task> |
/loop 10m 盯住构建队列 |
固定节奏 + 指定任务 |
/loop <task> every <interval> |
/loop 盯住构建队列 every 10 minutes |
同上(后缀写法) |
/loop status|stop [<短id>|all] |
/loop status、/loop stop 1f0f9a3c-7d2b-4a56-9f41-0c3b7a2e5d18、/loop stop all |
只读状态 / 删除记录 |
/loop next |
/loop next |
立刻投递一轮(用户与模型起的 loop 都可用;固定节奏只投递这一轮、不改写墙钟计时,下一轮仍按原 cron 触发) |
保留字规则(status、stop 大小写不敏感):
status只在整段输入是status时是保留字;status report是任务。stop只在整段输入是stop,或后跟all/ 单个标识符(^[a-z0-9][a-z0-9:.-]*$)时是保留字;stop the world是任务。- interval 只在第一个 token 完整匹配
<数值> <单位>(允许10m、10 m、10 min、10 minutes、1.5h、.5h)时才被当作 interval;every单独一个词是任务。
interval 单位:s/sec/secs/second/seconds、m/min/mins/minute/minutes、h/hr/hrs/hour/hours、d/day/days。
明确错误:0m → interval must be a positive duration(零时长不是时长,与"太短"是两回事);30s → interval must be at least 1 minute;>31d → interval must be at most 31 days;空任务且无默认任务文件 → 列出两处候选路径;inline 任务超过 maxTaskBytes → 指出上限并建议改用任务文件。
只预览不落地:/loop --dry-run
--dry-run 是前置标志:只在第一个 token 且精确等于 --dry-run 时被剥离;/loop 10m check the --dry-run flag 里的那个词是任务文本,不会被吞(同一条规则也适用于 loop_start 的 dryRun)。
/loop --dry-run # 预览裸 /loop 形态(默认任务文件 + 10m 固定档)
/loop --dry-run 10m # 预览固定档
/loop --dry-run 盯住构建队列 # 预览自定档
/loop --dry-run 10m 盯住构建队列 # 预览固定档 + 指定任务
预览文本的标签与顺序是固定契约(缺项不打印,且绝不打印 Id:——此刻没有记录):
Loop preview (nothing was armed):
Mode: …
Cron: … (仅固定档)
Jitter: … (仅固定档)
First fire: … (仅固定档)/ First wait: …(自定档)
Expires: …
Round limit: …
Task: …
Title: …
Would replace: … (仅当本会话已有 loop)
Blockers: none (无条件时恰好一行;否则**每个条件各占一行**)
This was a preview: no record was created and no round was delivered.
Blockers: 是唯一可能重复的标签:无条件时是单行 Blockers: none;有 N 个条件时就是 N 行,每行一个条件,顺序与真实启动的求值顺序一致(子代理 → 计划模式 → kill switch → config.enabled)——所以首行就是"真启动最先撞上的那条"拒绝。与真实启动不同的是:startLoop 撞到第一条就返回,预览把全部条件都报出来(这正是预览值得读的地方),且无论如何都不写。
--dry-run 与 status/next/stop 组合会被拒绝,文案固定:
--dry-run previews starting a loop; it cannot be combined with status, next or stop. Run /loop --dry-run <interval> [<task>] instead.
预览是只读的,因此在"起不了 loop"的条件下依然允许执行——计划模式、子代理会话、kill switch(DSH_DISABLE_LOOP)、config.enabled: false。此时返回 ok: true,并把条件逐条列进 Blockers:,不做任何写操作(不 create、不 delete、不做全局 catalog() 扫描)。这正是本功能的价值点:先看清会发生什么,再决定要不要起。
请求本身描述不出一个 loop 时才是失败(ok: false),且用的是真实路径同一句拒绝:缺任务(missingTaskMessage)、任务超长、非法间隔(0m/30s/>31d)、工具 cadence: "fixed" 但缺 seconds。
节奏归一与抖动
归一规则:取最近的可用档位,平局向上。 可用档位是一条升序阶梯:
- 分钟档:
1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30(分钟) - 小时档:
1, 2, 3, 4, 6, 8, 12(小时) - 日档:
1, 2, 3, …, 31(天)
所以 7m→6m、8m→10m(平局向上)、11m→12m(平局向上)、13m→12m、17m→15m、31m→30m、45m→1h(平局向上)、18h→1d(平局向上)。归一后的周期也可能略短于请求(例如 31m→30m),这是"最近档位"的必然结果,确认文本会打印实际节奏。
抖动:cap = floor(min(10% × 归一后周期, 900s) / 60) 分钟;实际偏移 o = floor(u × (cap + 1)),u 由稳定指纹 sha256(sessionId ‖ 归一周期 ‖ 任务)[0..4] / 2^32 派生,因此 o ∈ [0, cap] 且同输入恒同值。cap = 0 时 o = 0,并在确认文本如实写明原因(见下表两行 "Jitter: 0" 的区别)。
抖动真实生效在表达式的相位上,不是只写进 header:o > 0 时分钟字段由星号步进改为显式列表。
下表是逐档真值表(请求区间 → 归一档位 → 存储后的规范表达式 → cap)。区间边界取相邻档位的中点,平局归上:
| 请求区间(含上界) | 归一 | 表达式(o=0,存储后规范形式) | 表达式(o>0) | cap |
|---|---|---|---|---|
| 60–89s | 1 分钟 | * * * * * |
不可达(cap=0) | 0 |
| 90–149s | 2 分钟 | */2 * * * * |
不可达 | 0 |
| 150–209s | 3 分钟 | */3 * * * * |
不可达 | 0 |
| 210–269s | 4 分钟 | */4 * * * * |
不可达 | 0 |
| 270–329s | 5 分钟 | */5 * * * * |
不可达 | 0 |
| 330–479s | 6 分钟 | */6 * * * * |
不可达 | 0 |
| 480–659s | 10 分钟 | */10 * * * * |
1-51/10 * * * *(相位 :01/:11/…) |
1 |
| 660–809s | 12 分钟 | */12 * * * * |
1-49/12 * * * * |
1 |
| 810–1049s | 15 分钟 | */15 * * * * |
1-46/15 * * * * |
1 |
| 1050–1499s | 20 分钟 | */20 * * * * |
1-41/20 * * * * |
2 |
| 1500–2699s | 30 分钟 | */30 * * * * |
1-31/30 * * * * |
3 |
| 2700–5399s(45m–1.5h) | 1 小时 | 0 * * * * |
o * * * * |
6 |
| 5400–8999s(1.5–2.5h) | 2 小时 | 0 */2 * * * |
o */2 * * * |
12 |
| 9000–12599s(2.5–3.5h) | 3 小时 | 0 */3 * * * |
o */3 * * * |
15 |
| 12600–17999s(3.5–5h) | 4 小时 | 0 */4 * * * |
o */4 * * * |
15 |
| 18000–25199s(5–7h) | 6 小时 | 0 */6 * * * |
o */6 * * * |
15 |
| 25200–35999s(7–10h) | 8 小时 | 0 */8 * * * |
o */8 * * * |
15 |
| 36000–64799s(10–18h) | 12 小时 | 0 */12 * * * |
o */12 * * * |
15 |
| 64800–129599s(18h–1.5d) | 1 天(本地午夜) | 0 0 * * * |
o 0 * * * |
15 |
| 129600–215999s(1.5–2.5d) | 2 天 | 0 0 1-31/2 * * |
o 0 1-31/2 * * |
15 |
| 216000–302399s(2.5–3.5d) | 3 天 | 0 0 1-31/3 * * |
o 0 1-31/3 * * |
15 |
| …(每个整数天一档,边界同为中点、平局向上) | N 天 | 0 0 1-31/N * * 或短列表(如 10 天 → 0 0 1-31/10 * *) |
o 0 <同上> * * |
15 |
| 2592000–2678400s(30–31d) | 30/31 天 | 0 0 1-31/30 * *、0 0 1 * * |
o 0 … * * |
15 |
| >2678400s(>31d) | 拒绝 | — | — | — |
典型断言(单测逐档覆盖):7m→6m、8m→10m(平局)、11m→12m(平局)、13m→12m、17m→15m、31m→30m、45m→1h(平局)、18h→1d(平局)、10d→0 0 1-31/10 * *、31d→0 0 1 * *。
上表 o 为 0..cap 之间的整分钟数。o = 0 的两种情形在确认文本里区分:
Jitter: 0 (minute precision cannot express a smaller offset for this cadence; fingerprint …)——cap = 0,分精度表达不了。Jitter: 0 (the fingerprint drew no offset; this cadence could carry up to N minute(s). fingerprint …)——cap > 0但这次抽到 0。
存储后的规范化表达式:dsh-schedule 在写入时把均匀列表写成 a-b/n(1,11,21,31,41,51 → 1-51/10,1,11,21,31 → 1-31/10)。两者选择完全相同的分钟/日期集合,确认文本打印的是存储后的规范形式(create 的返回值),避免文档表格与落盘文本不一致。
cron.time_zone 永远显式传入:优先 ctx.timeContext.timeZone,否则取进程 IANA 时区,最后回退 UTC。
loop 记录格式
每条 loop 是 dsh-schedule 的一条任务:
title:DSH /loop: <任务摘录>(任务来自文件时是DSH /loop: read <路径>),超过 120 字符截断加...,便于在任务列表里区分多条 loop。kind:固定节奏是cron;自定节奏是every(见自定节奏)。prompt:一行 JSON header + 正文。
header 形状(唯一的可解析判据):
DSH_LOOP {"v":1,"owner":"<12位十六进制>","mode":"fixed|self","cadence":<秒>,"cadenceLabel":"…","fingerprint":"<16位十六进制>","jitter":<秒>,"iteration":N,"quiet":N,"omissions":N,"expiresAt":<epoch ms>,"lastRunAt":<epoch ms>,[taskFile|truncated|task]}
解析判据(decodeLoopRecord):prompt 的第一行以 DSH_LOOP 开头且其后是合法 JSON;v 必须严格等于 1(当前 header 修订号;缺失、"1"、2、0、非整数一律判为外来记录——未来修订号的字段含义不属于本版本);owner 是非空字符串、mode ∈ {fixed, self}、cadence/iteration/quiet/omissions/expiresAt 是非负安全整数、cadenceLabel/fingerprint 非空;否则 undefined。
正文第一行是机器可读的 round_context,随后是这一轮的说明、任务、以及"怎么续拍 / 怎么安静 / 怎么停止":
round_context: '{"loop":true,"round":2,"iteration":1,"quietStreak":0,"omissions":0,"cadence":"self-paced"}'
字段语义:iteration = 已经跑过的轮数(写入 header 时的值);quiet = 连续被标记 quiet 的轮数;omissions = 已被安全网处理的遗漏次数;expiresAt = 本次 loop 的终点(epoch ms);lastRunAt = 当前等待的起算时刻。
边界(外来记录):header 缺失、JSON 损坏、v 不是 1、或任一字段非法的记录不是本插件的记录 —— /loop status 不列出它、/loop stop all 不会删除它、遗漏安全网也不碰它。判据即上面那一行,README 之外没有第二种解释。
固定节奏
- 立刻跑一轮:命令返回前,第一轮任务文本(
firstRoundPrompt)已经进入会话收件箱,不需要等第一次 cron 触发。投递方式是宿主 agent 的followup,它是原型方法(followup(input) { this.send(input, "next-turn", true) }),必须以接收者身份调用——本插件用.bind(agent)绑定,绝不把方法取下来裸调。 - 原子性:先投递第一轮、成功后才替换旧记录。若首轮投递抛错,刚创建的记录会立即回滚删除,命令返回失败文案(
Could not deliver the first round, so no loop was armed: <原因>),存储回到调用前的状态:原本没有 loop → 零条;原本有 loop → 旧 loop 原样存活(id 与任务不变)。不存在「命令报失败但记录已武装」的中间态。 - 记录是真实 cron 表达式且被
canonicalizeCronExpression接受(集成测试断言canonicalizeCronExpression(expr) === expr)。 - 到期投递走
dsh-schedule的[SCHEDULE REMINDER BATCH]框架,任务文本以reminders_json数据到达,不是指令。 - 7 天过期(
lifetimeDays,默认 7):到点后最后跑一轮(安全网投递一次结束说明),再删除记录。/loop status打印expiresAt。
iteration 在固定节奏下不自增:cron 由宿主自己触发,不重写这一行。真实轮次以 /loop status 打印的 ctx.schedule.history 回执数为准(同时披露保留窗口与条数上限,见偏差与边界)。
自定节奏
- 初始待唤醒 =
safetyCheckSeconds(默认 1800 秒)。 - 记录是周期
every任务,不是一次性任务:一次性记录被投递后会变成inactive,随后update返回schedule_ended、list也不再返回它,模型永远无法重新武装它。周期记录则始终 active,loop_next用ctx.schedule.update({ change: { kind: 'every', every_seconds } })就地替换同一条记录(id 不变,createCalls不变,正常路径下会话内 loop 记录数恒 1 —— 替换时若旧记录删除被拒,则两条并存,见下)。 loop_next(seconds, reason[, quiet]):seconds ∈ [minNextSeconds, maxNextSeconds](默认 60–3600);quiet: true表示这一轮没发现需要处理的事情,quietStreak+1,非 quiet 轮把它归零;omissions归零。- 下一轮框架如实包含
iteration/quietStreak/omissions与停止办法(round_context行 + 散文)。 - 一个会话最多一个自定节奏 loop:新建时替换旧的。替换以删除结果为准:只有旧记录真的被删除(返回
{deleted:true})才写Loop restarted (replaced N previous loop);若删除被拒(返回{deleted:false}),改为Loop restarted, but N previous record(s) could not be removed and are still armed (see the log); remove them with /loop stop.并写一条 warn —— 此时会话里确实并存两条记录,绝不宣称已替换;/loop status会如实列出全部记录。
遗漏安全网
在 agent/turn-stopping(serial 事件)里检测「本次投递已被 ctx.schedule.history 回执证实,但本轮既没有 loop_next 也没有 loop_stop」:
- 第 1 次遗漏:把同一个记录改为
omissionCheckSeconds(默认 20 分钟)的等待,并把框架文本改成写明「上一轮未续拍」——WARNING: the previous round of this loop ended without a continuation … omission 1 of 2。 - 第 2 次遗漏:结束 loop —— 删除记录并向会话投递最终说明(
not continued twice in a row, so it has been stopped and removed)。 - 已续拍 / 已停止 / 没有新回执时,这一轮什么都不做(不写、不删、不打扰)。
不影响轮次收尾:监听器整体 try/catch,异常只记日志;history 读取失败在引擎层被吞掉并退化为"没有欠账"(不会误删 loop)。所有 loop 写入都走同一个串行队列,不会互相交错。
停止与状态
| 路径 | 行为 |
|---|---|
/loop status |
列出本会话的 loop:短 id、节奏、下次触发、过期时间、真实投递数、header 里的 iteration/quiet/omissions、任务来源。无 loop 时明确说明 |
/loop stop <短id> |
删除该记录并回报;id 不存在时明确报错并列出当前可用 id |
/loop stop all / 裸 /loop stop |
停止本会话全部 loop 并回报条数 |
模型 loop_stop(reason) |
删除记录并回报原因;没有 loop 时是失败调用(isError),不是静默成功 |
/loop next |
立刻投递一轮并回报;固定节奏的记录计时不被改写(下一轮仍按原 cron),自定节奏的记录重锚到 60s |
模型 loop_start(cadence, task, dryRun?) |
起一个 loop,与 /loop 共享同一实现(startLoop);回执写明 [agent]、真实落盘节奏与轮数上限。dryRun: true 时只预览、不落地(见模型工具) |
模型 loop_status() |
只读返回六字段(active/mode/cadence/nextAt/round/startedBy),与 /loop status 同源 |
| 控制条「停止」/「立即下一轮」 | 走 ctx.remote.commands.execute(sessionId, '/loop stop'|'/loop next', []),即上面这些命令本身(不是第二条实现) |
重复停止给出明确提示(No loop is active in this session; nothing to stop.),不会假装成功。
短 id 是调度记录 id 去掉 schedule- 前缀后的原样 UUID(36 字符、含连字符),也就是 /loop status 每行开头显示的那个值:直接复制它即可,不必手写。
控制条
位置:输入框上方的 conversation.input.dock 座位(宿主对该座位的官方措辞是 "Full-width entries above the composer card"),id=loop、order=5,排在 todo(0) 与 goal(10) 之间、queue(20) 之前。
它显示什么:节奏(固定档打印归一后的墙钟节奏,自定档打印"每轮重定")、下一轮倒计时(每秒重算)、轮次(round)、发起者(你发起 / 模型发起)。没有活动 loop 时整块隐藏——一个空控制条只会占地方。
它的两个操作:
| 按钮 | 走什么 | 效果 |
|---|---|---|
| 「停止」 | ctx.remote.commands.execute(sessionId, '/loop stop', []) |
与手打 /loop stop 完全相同:删除本会话记录并回报 |
| 「立即下一轮」 | ctx.remote.commands.execute(sessionId, '/loop next', []) |
与手打 /loop next 完全相同:立刻投递一轮;固定节奏不改写墙钟计时 |
按钮不是第二条实现:它发的是同一批命令,因此拒绝面(子代理、kill switch、参数校验)与手打命令逐字一致。命令失败时控制条原样显示宿主回的文本(message (code)),不吞错。
观感(0.2.1 起与 /goal 同级):控制条现在是卡片容器 + 图标按钮,直接照抄本页 /goal 卡片的视觉配方——36px 卡片、--dsw-radius-md、--dsw-elevation-panel(描边色 --dsw-alias-border-l1)、底色 --dsw-specific-menu + 菜单背景滤镜、padding: 4px 5px 4px 12px、gap: 10px;结构为 dock > bar > 字形 + 第 N 轮 + 读数行 + 两个 28×28 圆形图标按钮(内联 SVG,各带 title 与 aria-label)。同页并排实测(scratch 会话同时挂 /goal 与 /loop):圆角 12px、阴影逐字相同、padding/height/gap/border-width/display 全同、字形与文字尺寸全同、按钮 28×28/999px/border 0/padding 0 全同、两卡同宽 896px(composer 928px 未受挤压)。0.2.0 时它还是一行朴素控制条,视觉对齐是本版(0.2.1)的内容。
两处如实记录的小差异(不影响同级观感):
/goal用自身.bar::before+isolation承载底色与模糊,控制条用自身计算值达到同样的视觉效果(实测计算值一致:rgba(67,69,74,0.45)+blur(40px) saturate(1.5))。/goal的图标按钮带 Chrome 专有的corner-shape: round,控制条没有复制这一条;border-radius: 999px本身仍渲染为正圆。
主题 token:控制条共引用 14 个——11 个真 --dsw-* 主题 token + 3 个 --dsh-composer-* 布局量(后者的定义包与准入理由逐条写在 test/client-capsule.test.ts 的 LAYOUT_TOKEN_ALLOWLIST 里)。不存在的 --dsh-* 假 token 与 var(--x, fallback) 都由守卫拦住(含变异自检)。
模型工具
模型可以自己起 loop,不必等人在输入框打字。四个工具与 /loop 共享同一实现(startLoop),零复制逻辑:
| 工具 | 参数 | 行为 |
|---|---|---|
loop_start |
task、cadence(fixed/self)、seconds(固定档)、dryRun(只预览) |
立刻投递第 1 轮并武装节奏;回执写明 [agent]、真实落盘节奏与轮数上限。dryRun: true 时只预览、不落地 |
loop_status |
无 | 只读六字段 {active, mode, cadence, nextAt, round, startedBy},与 /loop status、与会话投影同源(loopFactsOf);nextAt 可空且总是返回该键 |
loop_next |
seconds、reason |
续拍(自定节奏);固定节奏会被明确拒绝并说明理由 |
loop_stop |
reason |
停止并回报原因;没有 loop 时是失败调用(isError) |
没有 rounds 参数:轮数上限由配置 maxRounds(默认 24)决定,工具不能按次覆盖——一个能被调用方随手抬高的上限不是上限。要改上限就改 profile 配置。
dryRun: true 的规则写在参数描述里(工具 description 只加一句指针,不重复):预览是成功调用(isError 不置位),文本首行 Loop preview (nothing was armed):、末行 This was a preview: no record was created and no round was delivered.,不创建记录、不投递任何一轮。它在计划模式 / 子代理 / kill switch / config.enabled: false 下依然可用,把这些条件列进 Blockers:;请求本身描述不出 loop 时才是失败调用,用与真实启动同一句拒绝。工具路径的 Title: 带 [agent] 前缀,与真实 loop_start 落盘记录的标题逐字一致。
轮数上限:loop_start 起的 loop 与 /loop 起的一样受 maxRounds(默认 24)约束——达到上限由守卫结束该 loop,并把 round-limit 事件与最终说明写进会话。上限、7 天生命周期、两次遗漏是三条独立的结束路径,互不掩盖。
子代理边界:子代理会话不能起/停/续 loop(宿主设计如此),loop_start/loop_stop/loop_next 都会明确报错;只读的 loop_status 与 /loop status 不设拒绝——读是安全的,持父会话 id 的子代理需要它查看父会话状态(源码注释见 src/registry.ts);子代理以自己会话读时如实得到"本会话没有 loop"。/loop --dry-run 与 loop_start({dryRun:true}) 同样放行:它只描述、不落地。
默认任务文件
优先级:<cwd>/.dsh/loop.md → $DSH_HOME/loop.md(DSH_HOME 未设时回退 ~/.dsh)。
- 空文件(仅空白)忽略并继续尝试下一个候选。
- 超过
maxTaskBytes(默认 25000 字节)按字节截断且不切开码点,header 记truncated: true。 - 两处都缺失时明确报错并列出两条候选路径。
- 真正的每轮重读:记录里存的是指针(
taskFile)+ 指令「每一轮开始前重新读取该文件,它是权威来源,可能已经变了」,不存文件内容副本;模型每轮读盘,文件改动立刻生效。
拒绝面
| 条件 | 行为 |
|---|---|
计划模式(planMode.get(agent).active === true,取自 ctx.reflect.get('planMode', false)) |
拒绝启动,零记录、零 create、零 delete,命令返回 Plan mode is active … |
config.enabled: false |
拒绝新建,并取消本插件已有 loop;取消前把任务文本写进 logger.warn |
环境变量 DSH_DISABLE_LOOP |
同上(任何非 ''/0/false/off/no/n/disabled 的取值都视为开启);只删除 owner 匹配的记录,不匹配的残留需手动清除,见下 |
ctx.schedule 缺失 |
真实装配:不激活(inject 硬依赖 ⇒ 零命令、零工具、零记录,profile 照常启动);若组合覆盖 inject 强行挂载,则走 apply 的防御性惰性路径:零命令、零工具、一条警告日志,fiber 仍 ACTIVE |
ctx.commands 缺失 |
工具照常注册,一条警告说明命令面缺失 |
子代理会话调用 /loop(启动/停止) |
明确拒绝 |
子代理会话调用 loop_next / loop_stop |
明确错误:A subagent session has no loop of its own to continue/stop.(即使伪造了父会话 id 也拒绝) |
子代理会话调用 loop_status / /loop status |
不拒绝:读是安全的,持父会话 id 的子代理需要它查看父会话状态(依据源码注释 src/registry.ts);以自己会话读时如实得到"本会话没有 loop" |
子代理会话调用 /loop --dry-run / loop_start({dryRun:true}) |
不拒绝:预览只描述、不落地,条件列进 Blockers: |
--dry-run 在计划模式 / 子代理 / kill switch / config.enabled: false 下 |
允许执行(只读),把这些条件逐条列进 Blockers:;零 create、零 delete、零全局 catalog() 扫描 |
--dry-run 与 status / next / stop 组合 |
拒绝,文案固定(见语法) |
计划模式契约:宿主 @deepseek-ai/dsh-plan-mode 按 agent 取状态——get(agent) 返回 { active }(有待定选择时另带 pending),set(agent, active) 选择;服务本身没有 active 属性。因此谓词是 planMode.get(agentRef)?.active === true,agentRef 是调用命令的那个 live agent 对象(get 内部走 agent.session)。
为什么不改用会话投影绕过隔离(已按本 profile 的挂载形态实测):plan-mode 自己会注册一个会话投影 plan(dsh-plan-mode/lib/index.js:179 的 ctx.sessionProjections.register(planProjectionDefinition),视图 { active, pending }),而支撑服务 session-projection 在根层是启用的——看上去像一条能绕开隔离的路。实测结论是不可行,但机制比上一版写的更细,必须分两种作用域形态说清楚(ctx.isolate 的语义是按服务名隔离,不是「作用域一定有自己的实例」):
| 形态 | ctx.sessionProjections 解析到 |
登记表 | 从该作用域 provide 覆盖 |
|---|---|---|---|
子作用域未隔离该服务名(本 profile 的形态:include:session-projection 已在根层) |
与根层同一张登记表(实测 shared registrations table: true) |
共享 | 被拒:service "sessionProjections" has been registered at <SessionProjectionRegistry> |
| 子作用域隔离了该服务名 | 自己的实例(scope resolves to the ROOT instance: false、shared registrations table: false) |
独立 | 接受(覆盖只在该作用域内生效) |
- 我上一版写的「作用域会得到自己的实例/根层登记表仍为空」只在不存在的隔离形态下成立,已按实测改正(见上表;两种形态我都跑了,原始输出在 t24 的证据里)。
- 但结论不变,因为起决定作用的是服务键而不是登记表:在两种形态下
root sees the planMode service: false都成立——plan-mode 作为 isolate 存在于各 preset 组,根层既没有planMode服务,也没有它的 fiber,所以它不会替根层注册任何东西(root registrations AFTER scoped plan-mode mount: [];我在此环境里也无法让 plan-mode 在作用域内激活:plan-mode fiber state in scope: 0,因为它的依赖在真实的 preset 组里,不在探针上下文里)。 - 读 API 的形状同样决定了必须防御式调用:
snapshot(session, ['plan'])需要真实会话日志(对没有事件日志的会话抛SessionSeq must be a non-negative safe integer, got NaN);cachedSnapshot(session, ['plan'])与stateOf(session, 'plan')在 key 未注册时都返回undefined。 - 结论:
planMode.get(agent)?.active === true仍是正确且够用的探测器——plan-mode 与插件同作用域时它生效(计划模式下零 create、零 delete),plan-mode 在别的 preset 组、或宿主根本没装 plan-mode 时它只是取不到 → 不拦截(有意的降级,不是遗漏)。要让拒绝在根层真正可行使,需要宿主侧把 plan-mode 组合到根层(配置问题),或给插件一个跨隔离的读取扩展点(上游特性请求)。 - 遗留不确定(诚实记录):若将来某个 preset 组激活 plan-mode 且该组未隔离
sessionProjections,则它那条register(...)会落到共享(根层)登记表,plan键就可能被根层读到。我在此环境无法激活它来验证这一点,所以不据它改写行为(不写投机代码);届时应重跑上表两组对照再决定是否改用它。
服务取值方式(惰性、可选):插件不直接写 ctx.get('planMode'),而是 optional<PlanModeLike>(ctx, 'planMode') —— 内部是 ctx.reflect.get('planMode', false)(strict = false)并包 try/catch,取不到就返回 undefined。这样没有装 plan-mode 的 profile 不会因为读服务而报错:探测函数直接是 undefined,插件照常工作、不做任何拦截;装了才走上面的 get(agent) 谓词。PlanModeLike 是本插件手写的窄接口(不用宿主的类型:@deepseek-ai/dsh-plan-mode 只是 devDependency,把它写进 src 会让 lib/index.d.ts 引用一个消费者可能没装的包),它与宿主签名的绑定由测试侧的编译期断言负责(见下)。
拒绝位置在任何写操作之前:/loop status 仍可读,/loop stop 仍可停(一个已存在的 loop 必须随时可取消),但新建在计划模式下不触碰存储——即便 kill switch 同时开启,也是先按计划模式拒绝,不会顺手执行取消(那条路径会删记录)。
kill switch 清理的 owner 前提:kill switch 的取消走 catalog() 发现记录,但每一条都要通过 owner 匹配才允许删除——记录 header 里的 owner 前缀必须等于该 entry 所属会话的 ownerOf(sessionId);不匹配的记录只记一行 loop: leaving <id> … alone (…): its header is not a loop record owned by that session,不删除(同一条规则也保护 header 损坏的外来记录与其它 writer 留下的 DSH_LOOP 前缀记录)。
后果必须说清楚:会话绑定不匹配的残留 loop 不会被 DSH_DISABLE_LOOP 自动清除。若这样的记录一直留着,确认文本会追加一句 N record(s) could not be cleared automatically (owner mismatch or a failed delete) and need manual removal with /loop stop; each skipped id was logged.(N = 发现条数 − 实际删除条数,因此也包括删除本身失败的记录;N = 0 时不出现这句)。要清理它,需在该记录所属的会话里执行 /loop stop,或由维护者手动删除该 schedule 行——kill switch 只保证「不再新建」,不保证「清空历史」。
斜杠菜单面的边界(为什么 /loop 没有图标与本地化文案)
这一节写给未来的维护者与复核者:斜杠菜单里 /loop 只显示一行描述、没有图标、不随界面语言切换。这不是遗漏,而是当前宿主客户端能力的边界;真正对齐需要宿主侧的扩展点。
机制(均已按内容锚点在宿主客户端源码中核实,不是推测):
- 宿主客户端的
dsh-client-ui-commands用第一方硬编码表把内置命令接到界面元素上:BUILTINS是六个内置命令 → definitionId 的映射(goal/plan/feedback/compact/permission/export),HOST_FACES再把内置命令映射到图标与翻译字典键(例如description.goal=Set or view the goal for a long-running task)。 - 菜单行合成就是一句回退式展开:
...builtinRowFace(c, this.t) ?? { description: c.description }(builtinRowFace内部HOST_FACES.get(name),命中才有图标/本地化标签)。所以非内置的 host 命令能拿到的只有 host 下发的description原文——这正是本插件当前唯一能控制的部分,也就是为什么description被写成与内置同量级的短句(Run a task now, then keep checking it on a cadence)。 - 想从插件侧补图标/文案,三条路都不通:①
ctx.commandUi.register()注册的是客户端自有命令,贡献若与 host 命令同名会 fail loud——源码里是ui-commands: contribution /<name> collides with a host command(客户端命令在自己的命名空间里重复也会抛ui-commands: duplicate contribution for /<name>),即明确不遮蔽;②ctx.commandUi.decorate()只改该 host 命令被选中时的行为——宿主决策表写明menu column: contribution/decorated-host → popup or action,且dispatch()在菜单选中时同样先查decorations.get(name)(via: "menu");它不改行的文字与图标;③input-trigger.registerSource()只是往来源列表里推一行(仅当 trigger+name 完全相同时才抛slash source "/<name>" is already registered),结果是菜单里出现两条/loop,而不是把 host 那条替换掉。 - 结论:在当前契约下,图标与 i18n 的缺口只能在宿主侧补(例如让 host 描述携带可选图标/字典键,或给第三方 host 命令一个 face 扩展点)。插件侧唯一的「绕过」是冒充某个第一方 definitionId:
builtinCommandName就是一句Object.keys(BUILTINS).find((name) => descriptor.definitionId === BUILTINS[name])的相等匹配,命中即可拿到{ label: t(…), description: t(…), icon }——但那会错显别家的文案与图标,属错误归因而非支持路径,本插件不走。宿主侧的扩展点已作为上游特性请求记录,不属于本插件的实现缺陷。插件侧的承诺仅限于:命令名、参数提示(hint)与描述文案风格与内置命令一致。
配置
| 键 | 默认 | 范围 | 含义 |
|---|---|---|---|
enabled |
true |
boolean | 总开关;false 时拒绝新建并取消已有 loop |
lifetimeDays |
7 |
1–3650 | 一次 loop 的生命周期(天) |
safetyCheckSeconds |
1800 |
60–3600 | 自定节奏的初始待唤醒 |
omissionCheckSeconds |
1200 |
60–3600 | 第 1 次遗漏后的安全检查等待 |
minNextSeconds |
60 |
60–3600 | loop_next 下限 |
maxNextSeconds |
3600 |
60–3600 | loop_next 上限 |
maxRounds |
24 |
1–100000 | 轮数上限:用户起的与模型起的 loop 都适用;达到上限由守卫结束该 loop 并如实说明(见与原文的偏差与边界第 9 条) |
maxTaskBytes |
25000 |
1–1000000 | inline 任务按码点计的上限;同时是默认任务文件按字节计的截断预算 |
校验失败直接抛错(不静默取默认);minNextSeconds > maxNextSeconds 也拒绝。
长度单位(两处口径不同,务必分清):
- inline 任务文本按码点(Unicode code points):
/loop <task>与/loop <interval> <task>的task,以及/loop status里task: N code points inline都以码点计。String.length是 UTF-16 码元,一个星体字符会算 2,所以实现用[...text].length;'😀'.repeat(100)恰好等于 100,刚好落在 25000 的上限内。 - 默认任务文件按字节(UTF-8 bytes):
<cwd>/.dsh/loop.md/$DSH_HOME/loop.md超过预算时按字节截断,并退回码点边界不切开字符;header 记truncated: true,框架文本要求模型自行完整读取。
两处共用一个 maxTaskBytes 键但单位不同,确认文本与 /loop status 都会显式写出单位(code points / file budget: bytes)。
与原文的偏差与边界
- 固定节奏一律墙钟锚定 cron:
/loop 5m在 :00/:05/:10… 触发,而不是"从我开始算的 5 分钟"。理由:只有墙钟 cron 能承载相位抖动;every_seconds由创建时刻锚定,抖动无法生效(会变成"只写进 header 的假抖动")。 - 自定节奏的记录是周期
every,不是一次性:一次性记录被投递后变inactive、update返回schedule_ended、list也不再返回,模型无法续拍。周期记录始终 active,loop_next才能真正"替换同一条记录(id 不变、正常路径下数量恒 1;替换时旧记录删除被拒则两条并存并如实告知)"。代价是两条:模型不续拍时它会按上一次的等待再次触发;因此loop_next(60)之后如果模型连续不续拍,第 2 次遗漏可能几乎无间隔就被判定(两次投递只隔 60 秒)。两种情况都由遗漏安全网兜住(第 1 次补 20 分钟检查、第 2 次结束并给最终说明)。 - 抖动口径:
min(10% × 归一后周期, 900s)向下取整到整分钟,再按指纹在[0, cap]内取整分钟偏移;cap = 0时归零并如实说明"分钟精度无法表达"。归一后周期可能略短于请求(如31m→30m),属"最近档位"语义。 - 日档 N≥2 用月内日列表表达:cron 五字段无法表达"每 N 天",
1,1+N,…是最优近似。当实际频率明显高于请求(偏差 >15%)时,确认文本额外打印Approximation: cron can only spell a day-of-month list …;31d退化为每月 1 次(0 0 1 * *)。 - 固定节奏的 header
iteration不自增:cron 自己触发,本插件不为装饰性计数器引入每轮持久写入与 compare-and-set 重试。真实轮次以/loop status的ctx.schedule.history回执数为准,且披露保留上限:dsh-schedule默认只保留最近 200 条 / 30 天窗口(deliveryHistoryRecords/deliveryHistoryDays),更早的投递不可考,因此打印的是"窗口内 N 次"而不是精确累计值。 - header 用单行 JSON(原文为人类可读一行;此处采用 JSON 以便严格解析):格式、判据与"坏 header = 外来记录"的边界见 loop 记录格式。
- 默认任务文件是"指针 + 每轮重读":记录不存文件副本,只存路径与重读指令;文件超预算时截断并记
truncated: true,同时提示模型自己完整读。 DSH_DISABLE_LOOP的取值语义:只有明确的否定拼写(''/0/false/off/no/n/disabled)算关闭,其它任何取值都算开启 —— kill switch 的失败方向必须是"仍然关闭"。计划模式探测是可选的:走ctx.reflect.get('planMode', false),取不到就不拦截(详见拒绝面的「服务取值方式」)。- 轮数上限的两条诚实边界:① 上限只在守卫能读到回执时执行——
agent/turn-stopping是计数入口,若该事件没有发生(例如会话在轮中被中断),上限不会当场生效,要等下一次有回执的收尾才结算;「立即下一轮」按钮轮计入上限(V1:预算评估已移到"零回执早退"之前),但计数依据仍是回执而不是按钮次数。②maxRounds > 10000时回执读取会报truncated,此时按下界执行(宁可少算也不漏停),回执文本会如实写出这一点。 - 固定节奏下未按按钮时,注入头可能重复上一轮号(V2,v0.1.x 既有偏差):固定档记录的存储 prompt 只在
loop_next/「立即下一轮」时重写,cron 自己触发的那几轮沿用上一次写入的iteration,于是真机可能看到"头里写 2、投影已经是 3"。投影 /loop_status才是诚实值(按ctx.schedule.history回执算),头里的iteration只作提示。 - 自定节奏 loop 的首次到期投递携带起 loop 时渲染的 prompt:记录存的是指针 + 每轮重读指令,但第一次到期投递用的是创建时已渲染好的那份 prompt;任务文件在那之后改动,从第二次投递起才反映(默认任务文件的每轮重读不受影响——这是"首次"这一格的边界)。
- 会话投影的推送边界:
@deepseek-ai/dsh-session-projection只在 state 与 wire view 引用同时变化时推帧。因此投影值必须按镜像revision重建(每次变更换一个新对象);"只给 state 加一个 revision 字段"不够——wire 侧看不到引用变化就不会推。0.2.0 的loop投影按这条实现(真 registry 实测)。 - 重启后的投影自愈延迟(V5-3):宿主重启后,未被本实例服务过的会话即使存储里有它的 loop,投影仍会读到 NO_LOOP,直到该会话发生一次带 sessionId 的入队活动(turn 结束 / 命令 / 工具调用)。控制条因此最多延迟一个周期才显示,之后自愈。
- 已知宿主限制:子代理会话的 client inspect 会被拒。在子代理会话里对
client/Slots、client/Builtin、client/Theme发 inspect 查询会被session/agent-busy: … owned by subagent routing拒绝(或 10 秒超时)。真实浏览器 DOM 不受此限 ⇒ 需要活体取证时用页面 DOM(控制条自己渲染的data-dsh-loop-state/data-dsh-loop-active就是为此留的锚),不要据此判定"控制条没挂上"。 - 预览(
--dry-run)的四条诚实边界,全部写在预览文本里: ①Cron:是 requested form,不是持久文本:dsh-schedule落盘时会把均匀列表规范化为a-b/n(src/cadence.ts:194-197的注释),所以那一行明写"the durable text is the create return value, not this line";预览绝不声称自己打印的是持久文本。 ② 不编造首次触发时刻:精确时刻来自create()返回的scheduledAt(src/engine.ts),预览拿不到。固定档只给规则与确定性抖动偏移,First fire:一行明写"not knowable before the record exists";自定档的首次等待是可知的(safetyCheckSeconds),照给。 ③Would replace:依赖一次读:读到就列条数与短 id;读失败时如实写"could not be read, so this preview cannot confirm what would be replaced",绝不写成"干净"(把读不到当成没有,是这类预览最容易犯的谎)。 ④Expires:用if started now限定:预览与真实启动不是同一瞬间,所以能对齐的是生命周期长度(lifetime N days),不是绝对时刻;文本按此措辞。
其他边界:/loop status 与 stop 只看本会话、本插件自己的记录;header 损坏的外来记录既不列出也不删除;子代理永远没有自己的 loop;固定节奏记录由 cron 自己推进,不依赖本进程存活到下一次触发。
开发
npm run build # tsc -p tsconfig.build.json → lib/
npm run typecheck # tsc --noEmit -p tsconfig.json
npm test # node --test(本版 217 个用例 / 10 suites;实际数字以输出为准:假时钟 + 真实 command/tool/schedule 装配)
npm run test:integration # node test/integration-smoke.ts(真实 1 秒级投递冒烟)
npm run test:smoke:client # node test/client-live-smoke.ts(真 client.js 字节 + 真命令 runtime + 真工具 registry)
主题 token 对账(可选,建议发布前跑一次):test/client-capsule.test.ts 冻结了 403 个主题 token 名做守卫,只有设了 DSH_THEME_CLIENT 时才与实际安装的主题对账。发布前顺手跑一次,主题改名会让守卫指名道姓地红:
DSH_THEME_CLIENT="<profiles>/node_modules/@deepseek-ai/dsh-client-ui-theme/lib/client.js" \
node --test test/client-capsule.test.ts
测试分层(每一层的边界如实标注):
| 覆盖对象 | 真实度 | 位置 |
|---|---|---|
| 语法 / 节奏真值表 / 抖动 | 纯单元(假时钟) | test/grammar.test.ts、test/cadence.test.ts |
| 默认任务文件(优先级 / 空文件 / 按字节截断) | 真实文件系统(临时目录) | test/loopfile.test.ts |
| 平台中立守卫(全测试集扫描:盘符字面量、模板字符串、正斜杠盘符) | 纯静态扫描(读 test/*.ts 源码,先屏蔽注释)+ 三种漏报形态与六种合法形态的自样本用例 |
test/platform-neutral.test.ts(已列入 npm test;变异证明:把 Windows 期望注回 → npm test 120/121 红并指出该行) |
header 编解码 / 外来记录 / v === 1 |
纯单元 | test/state.test.ts |
命令语义(七种形式 + --dry-run + 拒绝面) |
直接驱动 src/registry.ts,假时钟 + catalogue 形状的 double |
test/command.test.ts |
预览(--dry-run / dryRun):标志文法、零副作用计数、防漂移(预览文本逐字段 vs 真实落盘记录解码值,命令面与工具面各一条)、Blockers 全表、Would replace: 读失败、投影零 revision / 零推帧、真实 ScheduleService 上 catalog() 行数与投递窗口 |
假时钟 double(精确计数)+ 真实 @deepseek-ai/dsh-schedule + 真实 JSON 存储 + 真实 @deepseek-ai/dsh-session-projection |
test/preview.test.ts(已列入 npm test) |
| 命令注册面与调用面 | 真实 ctx.commands(CommandRuntime):定义经 normalizeDefinition 接受,调用走 parseCommand + command/run/command/done 生命周期 |
test/register.test.ts |
| 两个工具 | 真实 ctx.tools(ToolRuntime):schema 编译 + execute + isError 语义 |
test/tools.test.ts |
| 自定节奏 / 两次遗漏 / 过期 | 引擎直调(假时钟)+ 真实 cordis 事件派发(ctx.emit('agent/turn-stopping')) |
test/lifecycle.test.ts |
| 惰性 / 计划模式 / kill switch / 时区 | 真实 apply(有/无 schedule、有/无 commands) |
test/register.test.ts |
| 记录生成与真实投递 | 真实 @deepseek-ai/dsh-schedule + 真实 dsh-storage-json 本地后端 + 真实 dsh-tools/dsh-agent,1 秒级 one-shot 走真实 runtime 定时器 |
test/integration.test.ts、test/integration-smoke.ts |
| 客户端半(控制条) | 真跑 client.js 字节:假 window.__ModuleLoader__ + React 桩 + 共享 hook 槽;断言注册项 {id:'loop',order:5}、座位注入、读数逐字段、按钮的真实调用参数、zh/en 键集一致、主题 token 守卫(含变异自检) |
test/client-capsule.test.ts |
| 控制条 → 命令的真实通路 | 真 registry + 真命令 runtime + 真工具 registry + 真 client.js 字节(仅 ctx.schedule 为 double) |
test/client-live-path.test.ts、test/client-live-smoke.ts |
| 投影变更 feed | 真 @deepseek-ai/dsh-session-projection 注册表 |
test/projection-smoke.ts |
两处刻意保留的缺口,都留给发布前闸门:
- 真实周期投递的最短等待是 1 分钟(
every_seconds ≥ 60、cron 最小粒度 1 分钟),所以"周期记录被真实定时器投递 → 安全网在真实投递上判定"这条最慢的路径用确定性 double + 真实事件派发覆盖;真实投递本身由 1 秒级 one-shot 证明(同一ScheduleService、同一条投递与回执链路)。 - 没有挂载真实的 Web 组合(
dsh-api-session-controller等):sessionController/sessions是 stub,只实现本插件实际调用的resolveAgent与flush。发布前应在真机 profile 做一次端到端手测(/loop、loop_next、/loop stop、kill switch)。
替身形状硬规则(F3)
测试替身必须与宿主的真实形状一致,否则「全绿」只是幻觉。本项目已经两次栽在这上面:一次是拿窄适配器当 service 用,导致 kill switch 的 catalog 查找永远失败;一次是把 agent 的原型方法当普通函数属性调,导致首轮投递在真机必抛而套件全绿。规则:
| 替身 | 必须的形状 | 为什么 |
|---|---|---|
| agent | 类实例,followup(input) { this.send(input, "next-turn", true) } 与 send 都是原型方法;caller() 之类的「视图」用 Object.create(agent) 而不是对象展开 |
展开(spread)会丢掉原型方法,于是「方法被取下来裸调」这种缺陷在测试里看不见 |
| planMode | 实现 get(agent) / set(agent, active)。替身类型直接取自宿主:type PlanModeSurface = Pick<PlanModeController, 'get' | 'set'>(import type 自 @deepseek-ai/dsh-plan-mode,已列入 devDependencies),替身写成 interface PlanModeDouble extends PlanModeSurface { … } 并用显式类型标注构造(const double: PlanModeDouble = { … },不是 satisfies)。替身的 get 只返回 { active },set 返回 'committed' |
宿主没有 service 级 active 属性;{ active: boolean } 形状的替身会让恒 false 的谓词照样通过。pending 是可选的,所以替身不构造它——宿主在无待定选择时也只返回 { active };一旦宿主把 pending 改成必需字段、或改了 get/set 的签名,tsc 会立刻报错(防线实测见下) |
| planMode(插件接缝) | src 侧手写的 PlanModeLike 由测试侧的编译期桥钉住:test/register.test.ts 断言 'get' extends keyof PlanModeController、ReturnType<PlanModeController['get']> extends 接缝状态、active extends boolean、Parameters<host get>[0] extends Parameters<接缝 get>[0] |
接缝比宿主宽松(收 unknown、容许服务缺失),所以两者不是互相可赋值;每条断言各覆盖探测真正依赖的一点。宿主改签名/改名 → tsc 红(实测:把接缝的 active 改成 string → error TS2322: Type 'true' is not assignable to type 'never')。宿主的类型不能引进 src:那是 devDependency,写进 lib/index.d.ts 会伤到没装它的消费者 |
| schedule | 真实 ScheduleService(集成/注册面)或实现真机形状(catalog() 含 status 与可选 lastDelivery、cron scheduledAt 由真机工厂算出)的 double |
窄替身会绕过真实发现路径与真机相位 |
| message | 注入的消息必须是宿主消息:用 createUserMessage(@deepseek-ai/dsh-llm)铸 id,source.kind 用本插件自有的 'loop'。替身的 send 校验宿主 inbox 规则:缺 id 必须拒,同一待处理 id 重复注入必须抛(宿主原文 message "<id>" is already pending),并提供 settle() 表示「这一轮已被消费、id 可复用」 |
MessageBase.id 是宿主的必需字段,durable inbox 用它去重与定位;手搓一个没有 id 的对象字面量,第一条能进、第二条就抛 message "undefined" is already pending(这正是本项目第三次栽的同类坑)。{kind:'tool'} 是 tool-role 的来源、且 dsh-experimental-auto-review 会跳过它,所以必须自报 'loop'(照 dsh-schedule 自报 'schedule' 的写法) |
| 失败路径 | 让替身在真机同一点抛错(本插件用 host.failNextDelivery(),错误从 send 内抛出;替换失败用 double.deleteFails,让 delete 返回 {deleted:false}) |
才能证明「投递失败 ⇒ 零 armed 记录」「替换被拒 ⇒ 不谎报 replaced」这类断言不是自说自话 |
形状漂移防线是实测过的,不是声明:把替身破坏两处,npm run typecheck 都会失败(恢复后 exit 0)——
# 去掉 get()
test/register.test.ts(194,9): error TS2741: Property 'get' is missing in type
'{ gets: unknown[]; value: boolean; set(agent: unknown, next: boolean): "committed"; }'
but required in type 'PlanModeDouble'.
# 让 get() 返回 {}
test/register.test.ts(197,5): error TS2322: Type '(agent: unknown) => {}' is not assignable
to type '(agent: Agent) => { active: boolean; pending?: boolean | undefined; }'.
第二条错误信息里出现的 { active: boolean; pending?: boolean | undefined; } 就是宿主自己的签名——这正是「替身必须与宿主真实形状一致」这条规则的机器可验证形式。
GitHub Release 与签名
release.yml 只在已存在的 tag 上创建 Release(gh release create --verify-tag),tag 由维护者以 annotated + GPG 签名推送:
git tag -s v0.1.0 -m "cordis-plugin-loop 0.1.0" # 需要配置 GPG 签名密钥
git push origin v0.1.0 # 触发 publish.yml(staged)与 release.yml
随后 npm stage publish 进入 stage 队列,由维护者用 2FA 批准上线,release.yml 把 npm pack 的 tgz 与 SHA256SUMS 作为 Release 附件并附 actions/attest-build-provenance 来源证明;校验方式 gh attestation verify <file> --repo xinvxueyuan/cordis-plugin-loop。
发布
沿用仓库脚手架:.github/workflows/publish.yml 做 staged 发布(npm stage publish --provenance,OIDC 可信发布 + 人工 2FA 批准后上线;同时可发 GitHub Packages),.github/workflows/release.yml 生成 npm pack 的 tgz + SHA256SUMS 并附 actions/attest-build-provenance 来源证明。CI 只跑 npm ci + npm run typecheck + npm test;npm run test:integration 需要本机真实 runtime,因此在本地发布前手动跑。
发布记录:v0.1.0(2026-10-14)—— npm 侧被 skip(该缺口保留,修复版见 v0.1.1)
| 项 | 值 |
|---|---|
| tag | annotated + GPG v0.1.0;tag 对象 9ae22118903dcf62bcd25c5f0031210063731623,指向提交 ca9cfcd72bd3fd30b3ed432a70c92bf0d2007747;GitHub 侧 verification.verified = true(reason: valid,签名密钥 6C6FD9B2394D19FD9CA1F247D6701FBD72B7B35A) |
| Release | https://github.com/xinvxueyuan/cordis-plugin-loop/releases/tag/v0.1.0,三资产:xinvxueyuan-cordis-plugin-loop-0.1.0.tgz、SHA256SUMS、SHA256SUMS.asc(.asc 由维护者用同一把 GPG 密钥对 SHA256SUMS 做分离签名后附加;release.yml 本身只产出前两者) |
| 构建来源证明 | actions/attest-build-provenance 覆盖 tgz 与 SHA256SUMS;gh attestation verify <file> --repo xinvxueyuan/cordis-plugin-loop --source-ref refs/tags/v0.1.0 退出 0,--source-ref refs/heads/main 被拒(expected SourceRepositoryRef to be refs/heads/main, got refs/tags/v0.1.0) |
| tgz 校验 | 从 Release 下载后重算 SHA256 = 2b9ea26c288852d02f95216eb507dbbe1ce02056333b1d42f9c198acd5a76a6e,与 SHA256SUMS 记录一致;SHA256SUMS.asc 本地 gpg --verify 为 Good signature |
| CI | Release run 38431103573 success;Publish run 38431103637 failure → Stage for npm 与 Publish to GitHub Packages 两个 job 因此 skipped |
| registry 现状 | npmjs.org:@xinvxueyuan/cordis-plugin-loop 为 404(未发布、也没有 stage 队列条目);GitHub Packages:无该包 |
阻塞(已在 v0.1.1 修复):test/loopfile.test.ts 的两条用例——DSH_HOME is resolved from the environment or the user home(:91)与 the candidate list is project first, then DSH_HOME(:96)——断言的是 Windows 路径语义(期望 C:\custom\.dsh 与 C:\project/.dsh/loop.md 原样返回),在 Linux runner 上 path.resolve 会把 runner 的 cwd 前缀上去(实测得到 /home/runner/work/cordis-plugin-loop/cordis-plugin-loop/C:\custom\.dsh),于是 npm test 在 CI 上 115 pass / 2 fail,publish.yml 的 test job 失败、两个 needs: test 的发布 job 被 skip。注意 v0.1.0 已随 Release 存在,tag 不移动,因此修复走新版本号(见下)。该 npm 侧缺口因此从未补上:v0.1.0 在 npmjs 与 GitHub Packages 上都不存在。
发布记录:v0.1.1(2026-10-14)—— 已上线 npm(latest)
| 项 | 值 |
|---|---|
| tag | annotated + GPG v0.1.1;tag 对象 e68b3fa0525fa849d64866907ad97d653ee414f0,指向提交 ce607fe30b0371bb510dfa2e2a331fd850da0757;GitHub 侧 verification.verified = true(reason: valid)。v0.1.0 的 tag 对象仍是 9ae22118…(未移动) |
| Release | https://github.com/xinvxueyuan/cordis-plugin-loop/releases/tag/v0.1.1,三资产:xinvxueyuan-cordis-plugin-loop-0.1.1.tgz(80 883 B)、SHA256SUMS、SHA256SUMS.asc(维护者对 SHA256SUMS 的分离签名,gpg --verify 为 Good signature) |
| 构建来源证明 | gh attestation verify <file> --repo xinvxueyuan/cordis-plugin-loop --source-ref refs/tags/v0.1.1 对 tgz 与 SHA256SUMS 均退出 0;负向对照 refs/heads/main 与 refs/tags/v0.1.0 均被拒(got refs/tags/v0.1.1)⇒ 只命中本 tag |
| tgz 校验 | 从 Release 下载后重算 SHA256 = 4d4e8f9223b0670eb50eb97945a99056952b83a469485476a87710fc91ac6a05,与 SHA256SUMS 一致 |
| CI | Release run 38439675281 success;Publish run 38439675414 在第五次重跑(attempt 5,2026-10-14T12:14:29Z)三个 job 全部 success:Test (node 24)、Publish to GitHub Packages (npm.pkg.github.com)、Stage for npm (trusted publishing + human 2FA approval) |
| npm 上线 | npm install @xinvxueyuan/cordis-plugin-loop 可用:0.1.1 于 2026-10-14T12:29:49.666Z 上线(UTC;= 2026-10-14 20:29:49 +08:00),dist-tags.latest = 0.1.1。入队与来源:stage id a8ad0427-c3d2-4121-bee6-39cbca568ece、shasum a10cb56ac8184bbee69386ef7654f205a551bfbb、provenance logIndex=3250474839;registry 侧 dist.shasum 与之一致,dist.attestations.provenance.predicateType = https://slsa.dev/provenance/v1,dist.integrity = sha512-aAjlzSAc+u75aGYjsPym15PqHTZmHeSl+cL75/+Rtf2YxGlzBV5RIfXB1R5o4QYyW+kP7V+7h975OfeRmqSkyA== |
| registry 现状 | npmjs.org:versions = ["0.0.0-stage", "0.0.1", "0.1.1"],latest = 0.1.1;GitHub Packages:[email protected],包 id 15868485、版本 id 1380584577 |
| bootstrap 遗留 | 两个一次性产物仍在 registry 上,如实保留:0.0.0-stage 是 npm 在 bootstrap 时自动生成的占位;0.0.1 是 bootstrap 的空壳版本(只有一个 package.json,内容与本插件无关)。建议日后清理:npm deprecate @xinvxueyuan/[email protected] "bootstrap placeholder; use 0.1.1"(可选,需维护者 2FA) |
本次卡了四轮 E401 的根因(教训):新包第一次发布时,若把 bootstrap 的 stage 条目 npm stage reject 掉,npm 会级联删除包行与它的 trusted publisher 配置(旧条目 c3565419-… 事后被查明早已不存在:npm trust list 返回 No trust configurations found、npm trust revoke 报 404);此后 CI 的 npm stage publish 会一直以 E401 Unable to authenticate 失败,因为 OIDC 换证找不到任何可信发布配置。正确姿势是 approve bootstrap 条目让包真正存在(本仓后来正是:先 approve 0.0.1 → 维护者重建 trust dd7f5d27-… → 第五次重跑一次通过)。
profile 基线观察(T5-F4 留痕,只读)
profiles/** 是受保护文件(内含明文密钥),本项目对它们只做只读元数据留痕(mtime + sha256 + 字节数):从未读取内容、从未写入。
| 观察时间 | web | desktop | headless |
|---|---|---|---|
| 2026-10-16 22:26:29(T5-F4 原始观察:三份同秒被原子重写,无法归因) | f6825e04…(31103 B) |
d79e2323…(17253 B) |
9fb81c32…(16676 B) |
| 2026-10-17(现状基线;来源待用户确认,未读取内容) | e92747a6…(30343 B,14:26:16) |
62138067…(16518 B,06:53:25) |
f890aae4…(15941 B,06:53:25) |
旁证(不改变挂载结论):宿主 14:20:27 重启(PID 22836),web patch 的 mtime 晚于它 5 分 49 秒(配置层热生效);改后 host/Config entry=include:loop 仍正常(schema 含 maxRounds default 24),四个工具 4/4 在列 ⇒ 挂载行与依赖面未受影响。
发布记录:v0.2.0(2026-10-17)—— 已上线 npm(latest)
| 项 | 值 |
|---|---|
| tag | annotated + GPG v0.2.0;tag 对象 84dbce4b58f784a71cd56d572c53650120eb8669,指向提交 1615530ab5fff217f77e995cea66e5b5eb4bd73c;本地 git tag -v 为 Good signature from "xinvxueyuan (github)"(密钥 6C6FD9B2394D19FD9CA1F247D6701FBD72B7B35A)。v0.1.0(9ae22118…)与 v0.1.1(e68b3fa0…)未移动 |
| 提交 | 1615530a「release: v0.2.0 — agent-startable loops and a control capsule above the composer」,26 条显式路径(无 lib/、无 node_modules/、未用 git add -A/.),26 files changed, 5929 insertions(+), 117 deletions(-);push main 后该 sha 命中 0 条 workflow run(未触发发布) |
| Release | https://github.com/xinvxueyuan/cordis-plugin-loop/releases/tag/v0.2.0,三资产:xinvxueyuan-cordis-plugin-loop-0.2.0.tgz(132 840 B)、SHA256SUMS(107 B)、SHA256SUMS.asc(887 B,维护者对 SHA256SUMS 的分离签名,gpg --verify 为 Good signature) |
| 构建来源证明 | gh attestation verify <file> --repo xinvxueyuan/cordis-plugin-loop --source-ref refs/tags/v0.2.0 对 tgz 与 SHA256SUMS 均退出 0;负向对照 refs/heads/main 被拒(Error: expected SourceRepositoryRef to be refs/heads/main, got refs/tags/v0.2.0)⇒ 只命中本 tag |
| tgz 校验 | 从 Release 下载后重算 SHA256 = 2a5c399f28f14966ff0b9e736d5d571ad368745a712eefaee7310781b52f07e3,与 SHA256SUMS 逐字一致;该 tgz 的 SHA1 = 884cd49ebb6c0350fbbd1bb49b856fb972bb2ec2,与 CI 的 stage shasum 相同 ⇒ Release 的字节与入队的是同一份 |
| CI | Release run 39015255952 success;Publish run 39015255957 success,三个 job 全绿:Test (node 24)(tests 200 · suites 10 · pass 200 · fail 0 · duration_ms 3116.794768)、Publish to GitHub Packages、Stage for npm (trusted publishing + human 2FA approval) |
| npm stage | stage id 90e2b658-16a9-45c6-84c6-296ee5906df6、shasum 884cd49ebb6c0350fbbd1bb49b856fb972bb2ec2、provenance logIndex=3291648377;npm stage list 只读复核为 staged by: GitHub Actions (trusted automation)、tag: latest、access: public(与 CI 日志逐字一致) |
| npm 上线 | npm install @xinvxueyuan/cordis-plugin-loop 可用:0.2.0 于 2026-10-17T07:57:15.364Z 上线(UTC;= 2026-10-17 15:57:15 +08:00),dist-tags.latest = 0.2.0、dist.shasum = 884cd49ebb6c0350fbbd1bb49b856fb972bb2ec2(与 stage 回执逐字一致)、dist.attestations.provenance.predicateType = https://slsa.dev/provenance/v1。批准动作由维护者本人在 2FA 下执行(npm stage approve <stage-id>),发布者未代做 |
| registry 现状 | npmjs.org:versions = ["0.0.0-stage", "0.0.1", "0.1.1", "0.2.0"]、latest = 0.2.0(批准后 npm stage list 返回 No staged versions of package name … ⇒ 队列已清);GitHub Packages:[email protected],包 id 15868485、版本 id 1400920214(2026-10-17T07:52:54Z) |
| 版本固化 | package.json + package-lock.json 两处(根 version 与 packages[""].version)同步为 0.2.0;npm ci --dry-run exit 0;npm pack --dry-run 含 client.js(26.6 kB)与 14 条 src/,凭据类条目 0 |
| 验证 | 冻结字节上:npm run build / npm run typecheck / npm test(200/200 · 10 suites · 3395.3193 ms · 自然退出)/ npm run test:integration(integration smoke: OK)全 exit 0;主题对账(DSH_THEME_CLIENT)41/41;版本固化后复跑 build/typecheck/test 仍全绿(200/200 · 3052.2314 ms) |
| 推送前密钥扫描 | 43 文件 × 12 类厂商形状(ghp_/npm_/AKIA/glpat-/sk-/Bearer/私钥块/基本认证 URL 等)0 命中 |
| 已知后续 | 控制条的观感(卡片化外壳、图标按钮)与 /goal 尚未对齐,列为 0.2.1 的后续项;"子代理会话的 client inspect 会被拒"这条宿主限制见上面的边界第 14 条 |
发布记录:v0.2.1(2026-10-17)—— 控制条视觉对齐 /goal 卡片(已上线 npm latest)
| 项 | 值 |
|---|---|
| 内容 | 控制条改为卡片容器 + 图标按钮,与 /goal 同级观感(详见控制条);无行为变化(无 loop 隐藏、next 后 label +1、stop 后消失、零轮询、console Errors 0) |
| 代码 delta | 恰两个文件:client.js(26612 B → 30021 B)、test/client-capsule.test.ts(59781 B → 61172 B),由质量门 r6 绑定(428d0f58…f2ef / a7bffc60…2bd8);本任务只动 version/lock/README |
| tag | annotated + GPG v0.2.1;tag 对象 c12352ffc7ea38808b28559321e9b3cffdbc338f,指向提交 ab3ae85a8c164993e0783288c2b7f296ee521691;git tag -v = Good signature(xinvxueyuan (github),密钥 6C6FD9B2…B35A)。v0.2.0 84dbce4b…、v0.1.1 e68b3fa0…、v0.1.0 9ae22118… 未移动 |
| 提交 | ab3ae85a「release: v0.2.1 — align the control bar with the /goal card (card container + icon buttons)」,4 条显式路径(client.js、test/client-capsule.test.ts、package.json、package-lock.json;无 lib/、无 node_modules/、未用 git add -A/.),4 files changed, +212/−89;push main 后该 sha 命中 0 条 run(未触发发布) |
| CI | Publish run 39017822042 success:Test (node 24)(pass 200 · fail 0 · duration_ms 2528.681014)、Publish to GitHub Packages、Stage for npm 三个 job 全绿;Release run 39017822045 success |
| npm 上线 | 已上线 2026-10-17T09:25:12.220Z(本地 17:25:12):dist-tags.latest = 0.2.1、dist.shasum = 8954979fac8e428be6f8789e5c29d2d5ff6fe095(与 stage 回执逐字一致)、npm stage list 已清空(No staged versions of package name "@xinvxueyuan/cordis-plugin-loop".)、dist.attestations.provenance.predicateType = https://slsa.dev/provenance/v1。入队与来源:stage id 4fba7165-3402-48f9-a943-9367090b55e3、shasum 8954979f…、provenance logIndex=3292055373(npm stage list 曾只读复核逐字一致:staged by: GitHub Actions (trusted automation)、tag: latest、access: public);批准动作由维护者本人在 2FA 下执行,发布者未代做 |
| Release | https://github.com/xinvxueyuan/cordis-plugin-loop/releases/tag/v0.2.1,三资产:xinvxueyuan-cordis-plugin-loop-0.2.1.tgz(138 928 B)、SHA256SUMS(107 B)、SHA256SUMS.asc(887 B,维护者对 SHA256SUMS 的分离签名,gpg --verify 为 Good signature) |
| tgz 校验 | 下载后重算 SHA256 = 274d2d21dc0107eafb9004bf4e974d9bc7e6a772b691eabe03046999beaa9505,与 SHA256SUMS 逐字一致;其 SHA1 = 8954979fac8e428be6f8789e5c29d2d5ff6fe095 == stage shasum ⇒ 与入队 tarball 是同一份字节 |
| 构建来源证明 | gh attestation verify <tgz|SHA256SUMS> --repo xinvxueyuan/cordis-plugin-loop --source-ref refs/tags/v0.2.1 两者均退出 0;负向对照 refs/heads/main 被拒(expected SourceRepositoryRef to be refs/heads/main, got refs/tags/v0.2.1)⇒ 只命中本 tag |
| GitHub Packages | [email protected],包 id 15868485、版本 id 1401012051(2026-10-17T08:34:04Z) |
| 验证 | 冻结字节:build / typecheck / npm test(200/200 · 10 suites · 3012.774 ms · 自然退出)/ test:integration(integration smoke: OK)/ 主题对账(DSH_THEME_CLIENT)41/41 全 exit 0;npm pack --dry-run 47 files,含 client.js(30.0 kB) 与 14 条 src/、凭据类条目 0;版本固化后复跑 build/typecheck/test 仍全绿(200/200 · 3046.8932 ms) |
| 推送前密钥扫描 | 43 文件 × 12 类厂商形状 0 命中 |
发布记录:v0.2.2(2026-10-18)—— 放宽 DSH peer 范围,接受 0.2.1-rc.2(已上线 npm latest)
| 项 | 值 |
|---|---|
| 内容 | 仅改 peerDependencies:4 个 @deepseek-ai/dsh-* 条目由精确 0.2.0-rc.2 放宽为 ^0.2.0-rc.2,让 DSH 0.2.1-rc.2 的运行期 peer 兼容门(evaluatePluginCompatibility → semver.satisfies(range, { includePrerelease: true }))通过。运行期代码零变化:src/、client.js、test/ 均未改;^0.2.0-rc.2 同时覆盖 0.2.0-rc.2 与 0.2.1-rc.2 |
| 代码 delta | 恰三个文件:package.json(version + 4 条 peer)、package-lock.json(根两处 version)、README.md(版本行),3 files changed, +8/−8 |
| tag | annotated + GPG v0.2.2;tag 对象 afb01e5b29a7332bfe6d5850ae4ed8ba92d28641,指向提交 a96f588d5dff1dee9afc9724bef0c5db91190821;git tag -v = Good signature(xinvxueyuan (github),密钥 6C6FD9B2…B35A)。v0.2.1 c12352ff…、v0.2.0 84dbce4b…、v0.1.1 e68b3fa0…、v0.1.0 9ae22118… 未移动 |
| 提交 | a96f588d「release: v0.2.2 — widen DSH peer ranges to accept 0.2.1-rc.2」,3 条显式路径(无 lib/、无 node_modules/、未用 git add -A/.),3 files changed, +8/−8;push main 后该 sha 命中 0 条 run(未触发发布) |
| CI | Publish run 39104757419 success:Test (node 24)、Publish to GitHub Packages、Stage for npm 三个 job 全绿;Release run 39104757427 success |
| npm 上线 | 已上线 2026-10-18T06:45:07.255Z(本地 14:45:07):dist-tags.latest = 0.2.2、dist.shasum = 4d802c27da6cdc0665971fb7b5849b05bab52c00(与 stage 回执逐字一致)、npm stage list 已清空(No staged versions of package name "@xinvxueyuan/cordis-plugin-loop".)、dist.attestations.provenance.predicateType = https://slsa.dev/provenance/v1、versions 数组含 0.2.2。入队与来源:stage id 8271dcca-43d8-4d45-b5fc-abc07847c71e、shasum 4d802c27…、provenance logIndex=3307633281(npm stage list 曾只读复核逐字一致:status: staged、tag: latest、access: public、staged by: GitHub Actions (trusted automation));批准动作(npm stage approve 8271dcca-43d8-4d45-b5fc-abc07847c71e)由维护者本人在 2FA 下执行,发布者未代做 |
| Release | https://github.com/xinvxueyuan/cordis-plugin-loop/releases/tag/v0.2.2,三资产:xinvxueyuan-cordis-plugin-loop-0.2.2.tgz(140 890 B)、SHA256SUMS(107 B)、SHA256SUMS.asc(887 B,维护者对 SHA256SUMS 的分离签名,gpg --verify 为 Good signature) |
| tgz 校验 | 下载后重算 SHA256 = 1657b2708d4ee0f66d2290a059aa97c9f8f3a8792bd72a5bd79ce8616e6a17a7,与 SHA256SUMS 逐字一致;其 SHA1 = 4d802c27da6cdc0665971fb7b5849b05bab52c00 == stage shasum ⇒ 与入队 tarball 是同一份字节 |
| 构建来源证明 | gh attestation verify <tgz|SHA256SUMS> --repo xinvxueyuan/cordis-plugin-loop --source-ref refs/tags/v0.2.2 两者均退出 0;负向对照 refs/heads/main 被拒(expected SourceRepositoryRef to be refs/heads/main, got refs/tags/v0.2.2)⇒ 只命中本 tag |
| GitHub Packages | [email protected],包 id 15868485、版本 id 1403958109(2026-10-18T06:39:00Z) |
| 验证 | 版本固化后 npm ci --dry-run exit 0(up to date in 920ms);build / typecheck / npm test(200/200 · 10 suites · 3462.7315 ms · 自然退出)全 exit 0;stage 日志 47 files、package size 140.9 kB、unpacked 515.3 kB |
发布记录:v0.2.3(2026-10-19)—— 真正让 DSH peer 范围接受 0.2.1-rc.2(已上线 npm latest)
| 项 | 值 |
|---|---|
| 内容 | 仅改 package.json 与 package-lock.json。v0.2.2 的"放宽"实际无效:它把精确 0.2.0-rc.2 改成 ^0.2.0-rc.2,而预发布版本只有与范围中某个比较器同 [major,minor,patch] 元组时才匹配——satisfies('0.2.1-rc.2', '^0.2.0-rc.2') 实测为 false,即它宣称接受 0.2.1-rc.2,实际从未接受。本版把 4 条 peer 改为 ^0.2.0-rc.2 || ^0.2.1-rc.2,实测同时满足 0.2.0-rc.2(latest)与 0.2.1-rc.2(next);dsh-* devDeps 升到 0.2.1-rc.2,唯 @deepseek-ai/dsh-invariants 保持 ^0.2.0-rc.2(registry 上该包没有 0.2.1-rc.2,其 next 仍是 0.2.0-rc.2) |
| 代码 delta | 恰两个文件:package.json(version + 4 条 peer + 17 条 devDep)、package-lock.json(重生)。src/、test/、README.md 未改;+633/−636 |
| tag | annotated + GPG v0.2.3;tag 对象 96defff9bd19efc0d59e7db81113ec051f6504e5,指向提交 4eddd56190785cb47e1b75ec76a3cf11982603ed;git tag -v = Good signature(xinvxueyuan (github),密钥 6C6FD9B2…B35A)。历史 tag(v0.2.2 afb01e5b… 等)未移动 |
| CI | Publish run 39146362415 success;Release run 39146362813 success |
| npm 上线 | 已上线 2026-10-19:dist-tags.latest = 0.2.3、dist.shasum = ee9367d2eb7213e5043562220908d9ea44b30a1c。入队来源:stage id 3ffca56f-3b70-4894-9aee-30d11c05427f、shasum ee9367d2…、staged by: GitHub Actions (trusted automation);批准(npm stage approve 3ffca56f-…)由维护者本人在 2FA 下执行,发布者未代做;批准后 npm stage list 已清空 |
| Release | https://github.com/xinvxueyuan/cordis-plugin-loop/releases/tag/v0.2.3,资产 xinvxueyuan-cordis-plugin-loop-0.2.3.tgz(141 709 B)与 SHA256SUMS |
| tgz 校验 | 下载后重算 SHA256 = 2091dcd03173a737315d48475572f52167dd6212f08be5d14519e5176a7140a2,与 SHA256SUMS 逐字一致;其 SHA1 = ee9367d2eb7213e5043562220908d9ea44b30a1c == npm 上 published shasum ⇒ 与 npm 那份是同一份字节 |
| 构建来源证明 | gh attestation verify <tgz> --repo xinvxueyuan/cordis-plugin-loop --source-ref refs/tags/v0.2.3 退出 0;负向对照 refs/heads/main 退出 1 ⇒ 证明只命中本 tag |
| 验证 | 独立验证者(与实现者无共享上下文)在 %TEMP% 副本自写探针复跑:npm ci / typecheck / test(200/200)/ build 全 exit 0;14 条 dsh-* peer 范围实测覆盖 0.2.1-rc.2 与 0.2.0-rc.2;活体闸门在真实宿主确认 loop_status 可答、loop_stop 在无 loop 时明确拒绝 |
发布记录:v0.3.0(2026-10-19)—— /loop --dry-run 预览 + loop_start({dryRun})(已上线 npm latest)
| 项 | 值 |
|---|---|
| 内容 | 新增 /loop --dry-run [<interval>] [<task>] 与 loop_start({dryRun:true}):只预览、不落地(零写零发),并在计划模式 / 子代理 / kill switch / enabled:false 下照常放行、把这些条件列为 Blockers:。预览与真实启动共用同一份归一计算(src/plan.ts 的 planLoop + src/registry.ts 的 resolveStartRequest),杜绝"预览说的"与"真起的"漂移。文档向实现对齐:loop_start 参数去掉从未实现过的 rounds、补 dryRun,并订正子代理读路径边界 |
| 代码 delta | 14 条显式路径:README.md、package.json、package-lock.json、src/command.ts、src/engine.ts、src/grammar.ts、src/plan.ts(新增)、src/registry.ts、src/tools.ts、test/grammar.test.ts、test/integration-smoke.ts、test/preview.test.ts(新增)、test/register.test.ts、test/tools.test.ts;14 files changed, +1658/−140;无 lib/、无 node_modules/,未用 git add -A/. |
| tag | annotated + GPG v0.3.0;tag 对象 31f32c463079a343b7b4bf7f8d4d84798ad416d5,指向提交 8250d2130316dd91aa63b1017af797627a35cca2;git cat-file -t = tag,git tag -v = Good signature(xinvxueyuan (github),密钥 6C6FD9B2…B35A)。历史 tag(v0.2.3 96defff9…、v0.2.2 afb01e5b…、v0.2.1 c12352ff…、v0.2.0 84dbce4b…、v0.1.1 e68b3fa0…、v0.1.0 9ae22118…)逐个复核前后未移动;无 force-push、无 workflow_dispatch |
| 提交 | 8250d213「release: v0.3.0 — /loop --dry-run preview and loop_start({dryRun})」;GPG 签名 %G? = G;push main 后按该 sha 过滤 run 列表命中 0 条 run(未触发发布) |
| 冻结锚 | 质量门 r2 绑定:45 文件逐文件 sha256(口径 git ls-files --cached --others --exclude-standard → 排除 node_modules/lib/.git → 序数排序;逐文件才是判据,聚合仅索引);发布者复算与绑定的 14 条逐字 MATCH,其余 31 个文件经 git hash-object 与 HEAD 逐字节比对一致 |
| CI | Publish run 39176407361 success,三个 job 全部执行且 success(无一 skipped):Test (node 24)(tests 217 · suites 10 · pass 217 · fail 0 · duration_ms 3257.575113)、Stage for npm (trusted publishing + human 2FA approval)、Publish to GitHub Packages (npm.pkg.github.com);Release run 39176407464 success |
| npm 上线 | 已上线 2026-10-19T00:05:01.267Z(UTC;= 本地 08:05:01):dist-tags.latest = 0.3.0、dist.shasum = 743571d014502a35c8f3f167d14915b5729abecc(与 stage 回执逐字一致)、npm stage list 队列已清空(No staged packages found.)、dist.attestations.provenance.predicateType = https://slsa.dev/provenance/v1、versions 数组含 0.3.0(0.2.3 的上线时刻仍为 2026-10-18T17:01:06.992Z,未被改写)。入队与来源:stage id 1e520c88-0ba7-4827-bfb5-b3d6d874ec8a、shasum 743571d0…、provenance logIndex=3318192747(npm stage list 曾只读复核逐字一致:status: staged、tag: latest、access: public、staged by: GitHub Actions (trusted automation)、date staged: 2026-10-18T23:25:21.736Z);批准(npm stage approve 1e520c88-0ba7-4827-bfb5-b3d6d874ec8a)由维护者本人在 2FA 下执行,发布者未代做(本轮未 approve、未 npm login、未做 2FA) |
| Release | https://github.com/xinvxueyuan/cordis-plugin-loop/releases/tag/v0.3.0,三资产:xinvxueyuan-cordis-plugin-loop-0.3.0.tgz(157 946 B)、SHA256SUMS(107 B)、SHA256SUMS.asc(887 B,维护者对 SHA256SUMS 的分离签名,gpg --verify 为 Good signature)。.asc 不由 CI 产出:release.yml 只生成前两者,.asc 由维护者本机补签后上传 |
| tgz 校验 | 从 Release 下载后重算 SHA256 = fec31622721f00080cd81dfa9005abb6845f476ea24a37b132ac26dce3125412,与 SHA256SUMS 逐字一致;其 SHA1 = 743571d014502a35c8f3f167d14915b5729abecc == stage shasum ⇒ Release 的字节与入队的是同一份 |
| 构建来源证明 | gh attestation verify <tgz|SHA256SUMS> --repo xinvxueyuan/cordis-plugin-loop --source-ref refs/tags/v0.3.0 两者均退出 0;负向对照 refs/heads/main 两者均退出 1(expected SourceRepositoryRef to be refs/heads/main, got refs/tags/v0.3.0);证明 JSON 全文对 refs/heads/ 零命中(refs/tags/v0.3.0 命中 8 处),SAN = …/release.yml@refs/tags/v0.3.0、externalParameters.workflow.ref = refs/tags/v0.3.0、resolvedDependencies = git+…@refs/tags/v0.3.0@8250d213… ⇒ 只命中本 tag |
| GitHub Packages | [email protected],包 id 15868485、版本 id 1406552576(2026-10-18T23:25:16Z) |
| 验证 | 冻结字节上:npm run build / npm run typecheck exit 0;npm test(217/217 · 10 suites · 3222.7036 ms · 自然退出);npm run test:integration exit 0(preview: stored rows 0 -> 0 / real start: stored rows 0 -> 1,即预览零写、真启动才落盘);npm run test:smoke:client exit 0;主题对账(DSH_THEME_CLIENT)41/41;npm ci --dry-run exit 0;npm pack --dry-run 50 files,含 client.js(30.0 kB)与 15 条 src/、凭据类条目 0。版本固化后复跑 build/typecheck/test 仍全绿(217/217 · 3252.7441 ms) |
| 版本固化 | package.json + package-lock.json 两处(根 version 与 packages[""].version)同步为 0.3.0;固化后逐文件锚 diff(白名单仅这两个文件)⇒ changed 2 / missing 0 / added 0,其余 43 个文件 sha256 逐字节未变;负向对照(空白名单)⇒ 退出 1 且恰好点出这两个文件 |
| 推送前密钥扫描 | 45 文件 × 15 类厂商形状(ghp_/github_pat_/gho_/ghs_/npm_/AKIA/glpat-/sk-/xox*/AIza/私钥块/Bearer/基本认证 URL/通用 secret 赋值/eyJ… JWT 形)0 命中(脚本置 %TEMP% 并在用后删除) |
| 活体闸门 | 队长在根会话、冻结字节上实测:固定档预览打印完整标签面且无 Id:、如实报出将被替换的真记录;预览后 loop_status 的 nextAt 未变(零副作用);真启动 / loop_stop / 再 loop_status 全链路正常;自定档预览无 Cron:/Jitter: 行(与契约一致);活体工具表 loop_start description 与源码逐字一致;client.js 全程零改动(428d0f58df5af7ac393dddb69cd38e85580e978283cbbca6c77184983536f2ef)。未覆盖(如实):/loop --dry-run 的命令面无法由模型执行(模型不能直接发斜杠命令),它由冻结字节上的用例覆盖(真 CommandRuntime),且与工具面共用同一 stripDryRunFlag |
| 环境边界(如实) | 发布者本地 npm test 跑的是本地已装的 dsh 0.2.0-rc.2(lock 已钉 0.2.1-rc.2/cordis 4.0.5,但本地 node_modules 未重装),而 CI 的 Test (node 24) 走干净 npm ci、跑 0.2.1-rc.2 —— 两侧运行期 peer 不同(与 v0.2.3 发布时相同)。npm ci --dry-run exit 0 ⇒ package.json 与 lock 同步;发布者未擅自 npm install(会动 node_modules) |
上线复核(现查,非转述):npm view @xinvxueyuan/cordis-plugin-loop version dist-tags dist.shasum --json ⇒ version 0.3.0、dist-tags.latest 0.3.0、dist.shasum 743571d014502a35c8f3f167d14915b5729abecc(与交回的 stage shasum 逐字相同 ⇒ 上线的那份与入队/Release 的是同一份字节);npm stage list ⇒ No staged packages found.(队列已清空)。
已知边界与待办(0.3.1 backlog,仅记录、未在本版处理)
/loop stop不撤回"已排队的那一轮":宿主dsh-schedule的语义是删除不撤回已投递的提醒(其schedule_delete描述原文如此)。因此停止一个 loop 后,此前已排队/已投递的那一轮消息仍可能浮现。活体闸门实测撞到过一次(loop_stop返回成功后,首轮消息仍出现)。这是宿主调度行为、非本插件缺陷,且不影响记录与投影的正确性(loop_status立刻如实变为active:false);本版仅在文档中如实记录该边界。test/loopfile.test.ts会遗留%TEMP%\loopfile-*目录:验证者实测残留 168 个,疑为mkdtemp后未清理。非本版引入(既有测试),不影响发布产物与 CI 结果(CI 为一次性 runner),记为 0.3.1 待办。
English
@xinvxueyuan/cordis-plugin-loop implements DSH's /loop: run a task now, then keep it running on a fixed wall-clock cadence (cron + deterministic jitter) or a self-paced one (loop_next / loop_stop). Each loop is one durable @deepseek-ai/dsh-schedule task whose title and prompt carry all of its mutable state, so a continuation, a stop, and the omission safety net are all one in-place ctx.schedule.update — a session never owns more than one loop record.
Seven call forms (plus /loop next to deliver a round immediately, and a leading --dry-run flag that describes a loop without arming it), wall-clock cron for every fixed cadence, nearest-step snapping with ties upward, min(10% of the normalized period, 900s) of jitter drawn from a stable fingerprint and expressed in the cron phase, a configurable round limit (maxRounds, default 24), a 7-day lifetime, a two-strike omission guard on agent/turn-stopping, .dsh/loop.md default tasks that are re-read every round through a pointer, plan-mode and kill-switch refusals, and an inert path when ctx.schedule is absent. The model can also start and read a loop itself through the loop_start / loop_status tools — the same startLoop implementation the /loop command uses, with dryRun: true for the same read-only preview — and a control bar above the composer (conversation.input.dock, id loop, order 5) shows the cadence, the next-round countdown, the round number and the starter, with stop / next-round buttons that simply execute /loop stop / /loop next through ctx.remote.commands.execute. Card-style visual parity with /goal is a 0.2.1 follow-up. See the Chinese sections above for the per-band truth table, the header format and its foreign-record boundary, the configuration table, and the deviation list.