Skip to content

v0.9.1

Choose a tag to compare

@github-actions github-actions released this 18 Oct 13:32
· 20 commits to main since this release
20765db

🆕 新增

  • M.O. 从「看得见」走到「跑得出账」:运行账落在项目里,执行窗口由人显式打开(beta) — v0.9.0 的 M.O. 只有一张图和候选,没有任何一条「跑过」的证据——读侧早就能把执行情况算成四态,可账本一直没人写,runs 恒为空数组、所有节点恒为 candidate,跳过与绕开的退休提议永远发不出来。

    细节

    这一版补上写侧:src/pipeline-runs.js 把一次 run 落到 <项目>/.mneme/pipelines/<id>.runs.jsonl,与手写的 .json 分开,一次 run 不会冲掉人的 diff。开窗必须由人的显式动作划:工作流的 trigger 是人话,拿它去匹配用户消息只能猜,而猜错的代价不是少记一笔,是把一条从没跑过的流程记成跑过、还把节点染成可信——所以窗口边界是 /mo-run start <id> 与 /mo-run stop,窗口之外发生的一切一个字都不记,漏记可接受、编造不可。四个取舍都有代价意识:窗口按会话开、一个会话同时只允许一个(再开会被拒而不是顶掉,顶掉会让前一次执行悄无声息地消失);事件先缓冲到 stop 才配对(工具的结果可能比调用晚很久,边到边配会把「还没等到结果」误判成「没有这个调用」);只在显式 stop 结账、不按回合关(一条流程常跨多个回合,按回合关会切成几条半截账);热路径永不抛(挂的是宿主会话事件通道,可选插件的可选能力把 agent 一步带崩,比丢一次抢救严重得多)。「账本坏了」与「从没跑过」严格分开:逐行收,好行照收、坏行进 problems(带行号、标出是否只是一截尾巴),状态降 partial/broken,并撤掉坏账本的 500——它会让客户端当成离线,还吞掉读得出来的那张图。账本目录自带 .gitignore 也是为这件事:.runs.jsonl 一旦被提交,两条并行车道先后追加同一份账本就会产生冲突标记,而那些标记不是合法 JSON,一份流水污染会升级成工作流状态永久消失。独立旗标 actionRunEnabled 默认关且进 LIGHT_MODE_OFF——看图与写账是两件事,混成一个键会让只想看图的人不知不觉获得写入能力。

  • 会话事件翻成观察:只有跑在命令执行器里的调用才算工作流的一步 — 接线前的实测就把原方案否掉了——400 个会话里工具名是 pwsh(1399)/ read(1709)/ grep(1040)/ glob(183),而工作流里人写的 tool 是 npm test、git push <tag> 这类命令行,两者不重叠…

    细节

    直接拿 name 去比一条都对不上;唯一能对上的是「命令跑在哪个工具里」,pwsh 的 arguments.command 才是那条命令行。三条纪律都是「不许编造」的具体形态:非命令执行器一律不产观察(read/write/glob/grep 是 agent 的日常动作,硬把文件路径拼成「命令行」去匹配只会制造噪声,而噪声的代价是把某一步凭空染色);只有拿到结果的调用才产观察(配对靠 callId,「调了但没结果」既不是成功也不是失败,拿它当一次成功使用是编造,如实进 dangling);成败是三态(拿不到就留 null,把「不知道」折叠成「失败」会给这一步打上不该有的 refuted 标记)。

  • 归属规则只出候选,绝不下结论 — tool 字段是人手写的自由文本——单命令、多命令并列、带占位符,还有 4 步是空的,拿这种文本做精确匹配注定命中率不高,而误匹配的代价远大于漏匹配(把「推 tag」记成「推分支」,deriveState 就会把错误的那一步染成可信)。

    细节

    所以这一层只产「候选 + 理由 + 三种匹配强度(exact / weak / 认不出来)」,认不出来是合法答案。弱匹配用词前缀而不是「第一个词相同」:真实工作流里 git 出现 5 次,按首词匹配会让一次 git push 报出六条候选,噪声盖过信号;压到 2–3 条之后人才愿意看。报告分三桶(有候选 / 认不出来 / 拼不出命令行),并把结构性无法归属的步骤单列出来——那是「为什么这一步永远没账」的答案。ponytail 复核随即砍掉四块替不存在的写者预留的代码(整块的 attributionReport、at/actor 两个没人读的字段、SEPARATOR_RE 的三个从未出现的分支、PLACEHOLDER_RE 的两种从未出现的形态),模块 189 → 165 行。

  • P1/P2/P3 三级落地:从「回执列出干了什么」到「人确认才写账」再到「观察旁挂与脱节检出」 — 三条实测把原方案一步步逼掉——单条命令匹配手写步骤表,唯一归属仅 14.2%(非发版会话 4.4%,区分力只有 3 倍);整段命令当分组键每会话中位 539 组、最多 7373 组,人扫不过来;换动词当键,密度最高的是 PowerShell 管道语法…

    细节

    说明**「命令 ≈ 动作」这个预设本身是错的**;只留业务命令(git/gh/npm/node/docker)后业务段占 6.5%,那才是人能扫的量级。于是 P1 只报「本次观察到哪些业务动作、各几次」由人对账(stop 在没归属到步骤时不再只说「没有落盘」,而是同时给出观察清单——清单才是这一版真正的交付物);P2 把写账从 stop 挪到 /mo-run confirm——「没确认」≠「没发生」(人没提到的步骤什么都不写,若把「忘了勾」记成「被跳过」,RETIRE_AFTER_SKIPS 就会据此发出错误的删步提议),unsure 与 notDone 必须分开,「全部没做」也是一条合法且完整的记录;P3 把两样都有的东西对一遍——人说这步做了 + 声明的 tool 本次一次都没出现(实测背书:gh pr merge --rebase 在 21723 次调用里出现 0 次),系统只把矛盾摆出来让人判,不改步骤表。观察走旁挂 <memoryDir>/observations/<项目键>/<id>.jsonl 而不是给账本加字段——账本是 v0.9.0 发出去的数据契约,为 P3 加字段是动已发布契约。左右各一道隐私闸:观察行只存 {key, count, flags?},形状闸验原始输入再构造,任何带 command/args/segments 的输入大声拒(静默剥离会把上游的形状 bug 藏进一次成功写入);P1 那边同样只保留动词/子命令/旗标、位置参数一律丢弃——回执会显示在界面上、可能被截图,而完整命令行里几乎必然含绝对路径。端到端实测当场抓出四个真 bug(脚本路径被当子命令、git -C <path> log 把值当子命令、带点的文件名通过校验、旗标被重复渲染),全部属于同一条红线。

  • 步骤新增 assert 与 optional:机器能不能验完这一步,从此在数据里说得出口 — assert 不是普通字段,它是「可断言节点 vs 判断节点」这整个划分的唯一载体——没有它,「哪些步骤能被机器自动盯着」在代码里就没有地方表达(评测报告的实测是 0/19 步,一条流程全靠人判,而那个事实当时没处可写)。

    细节

    加它要一次改四处(校验 / 归一化 / 节点投影 / 首屏计数),漏任何一处症状都是「加了但面板看不见」且不报错,所以先装一道字段白名单锁再改:往步骤里塞 assert/onFailure/retries,三个全部静默消失,正是这类「声称改了但产物里查不到」的同型装置。两个语义决定:没写断言时投影成 null 而不是空数组(后者会让「作者没写」与「写了但一条都没通过」长得一样,而验收时这是两件事);首屏新增 assertable 计数与既有 contrast 并列(后者数「写了没有」、内容仍靠人判,前者数「机器能不能判」,0/19 是实话不是缺陷,但清单上必须看得见)。optional 缺省 false(=必经),显式落键而非缺省缺键——「这步可不可跳」是判据要读的语义,读侧拿到的一律是布尔;存量步骤表零改动。

  • R1/R2:跳过有了性质,前置违规与 tool 过时都有了提议 — 同一句「这一步总被跳」,在可选步骤上是不该提议删的(只发 optional-skip-ok 信息项),在必经步骤上则同时意味着「删掉这一步」与「这条路不对」两种读法,两种提议并存、谁也不吞谁——第一版把 drop-step 挂在 else if 里永远到不了…

    细节

    被自己的测试抓出来。R1 的前置违规跨整本账本逐 run 查(保住 run 边界,tallyRuns 已经把 run 抹掉了),按 run|step|need 去重、同一步对不同前置各报一条(修图的人要知道断在哪条边上),一次就报。retirementProposals 的封闭问题表从两类穷举到五类——新类不配问法会落进「岔路」那条毫不相关的问法里。

  • 沉淀链:从真实会话日志里把反复出现的手法挖成 draft 工作流,但判据不给结论 — src/pipeline-nudge.js 走「多帧 zstd 逐帧解 → 重复序列候选 → 能算的缺席对照 + 答不出的封闭问题 → status:"draft" 草稿(过 validatePipeline)」。

    细节

    buildCounterfactual().verdict 恒为 null:机器只写观察到的,其余留「(待补)」,答案只能由人回答、经 applyAnswers 写进 steps[].evidence[]。专家团复核抓出七处,最贵的一条是「空回答不算已答」——applyAnswers(c, {}) 与 applyAnswers(c, null) 曾返回 ok:true 并推进到 answered,那是判据链路上唯一一处「没人回答也算答了」;另有一条把「机器不给结论」从产者的自觉变成边界的性质:校验层拒绝任何非 null 的 counterfactual.verdict(缺键也拒)。其余是跨工作区不并账、unanswered 补回出口、草稿与提议改原子落盘(一个半截文件曾能让整个项目层的工作流从面板上消失)、按层过滤后的相邻不许当真相邻、failures 不再用 null 同时表示「未接线」与「确实没有失败记录」。

  • 人格定义页 v1 → v1.2:从「一张卡」到「按 agent 寻址的多张卡」(beta,默认关) — v1 是存储/API/开关/注入/面板五件套(user_settings 加 persona 键、PERSONA_FIELDS 白名单挑拣、坏 JSON 容错回空对象、GET+PUT /persona 双面路由、inject.js 里人格段前置而 rules 恒最硬、任一闸关时输出逐字节等于历史版本)。v1.2 按实测三反馈升级:设置键变成 `{default…

    细节

    byAgent}多卡形,**v1 的单对象读作default、零迁移**;注入按会话身份(session.header.agentPreset,与 scope.js同源读法)寻址对应 agent 的卡,身份缺失/无卡/卡关一律落default;面板改多卡列表,补三个字段的可见标签(v1.1 反馈:aria-label看不见)与保存失败可见错误行(v1 的静默catch吞掉了 404,实测踩中)。**一次修好又回滚的教训**:卡标签删除的×复用了规则卡的mneme-set-del样式,而那个类只在.mneme-set-row:hover下解除opacity:0,卡标签不在该悬停容器里——功能在、UI 永不可达;修复(内联 opacity:1`)后又因同一原因整体回滚(#459),留作已知问题。占位示例也从使用者自己的两个人设名换成了中性名。

🐛 修复

  • 面板的检查更新把「成功」报成了「失败」,星星入口指向了「谁已经点过星」 — (#460):面板拿 updateInfo.status 去比一组后端 classify() 根本不会返回的名字,于是成功的检查一律落进错误分支。

    细节

    顺手补一道跨文件契约锁双向钉住(面板比较的每个状态必须是 classify 能返回的值,取数路径必须对得上注册的路由),并把「重新注入错名字」作为判别验证——新锁会点名它。星星入口原先链到 /stargazers,那是「已经点过星的人」列表、页面上没有 Star 按钮,改为仓库主页。

  • 向量的「有向量」不再是一句空话:写入回执、行级模型出处与模型感知的回填选择器 — (#450):embedFor/schedule 返回结构化回执(ok/failed/skipped + 原因)且 schedule 不再吞掉 promise,失败进 200 条环形账本(不接 llm_audit_logs:高频回填会淹没 LLM 调用的追溯面)。

    细节

    vector_meta.model_hash 是「最后一次成功写入的模型」,换模型即被整表覆盖,无法回答「这一条向量是谁产的」——memories 新增 embedding_model 列做行级出处,存量一律 NULL(出身未知),不按时间推断模型名(猜出来的名字会被日后审计当作证据,而它不是);清空向量时连同出处一起清。回填与 reindexMissing 改走新选择器(空向量 OR 出身未知 OR 模型不匹配),换模型后旧向量才真正被重算,而不是永远占着「有向量」名额虚增覆盖率;maxTotal=0 的语义定为一灌到底,启动期回填的三个写死数(500/10/200)改成配置项,返回体让 capped 与 drained 不再不可区分。

  • 账本的 ids 跨块引用被 catch 静默吞掉,fix-step-tool 永远少发而界面无异常 — (CodeRabbit #458 四条里核实的三条):F1 账本 ids 原声明在 if 块内,观察旁挂那块跨块引用必 ReferenceError,被外层 catch 吞成「没有观察」——提升为 handler 级 runIds…

    细节

    回归测试走真 handler + 真旁挂文件锁住「观察进了判据」这条正向信号(负向断言抓不到它,因为吞掉之后一切看起来都正常)。F2 config.memoryDir 默认值是 ~/.dsh/memory,而 observationDir 不展开 ~,readdir 永远落空,补 expandHome 与 candidates.js 同构。F3 rework-path 的两种成因(必经步骤被跳 / 被绕开成常态)共用一句问法,跳过场景答非所问,改为提议带 cause 字段、nudge 按成因选封闭问法、旧形状退回原问法。

  • candidates.js 的每一个换行都是「双 CR + LF」,从出生一路进了 main 又发到了 npm — 各 454 处。\r 在 JS 里是合法空白,解析、加载、1869 条测试全绿——当时没有任何检查会红。

    细节

    代价落在别处:任何改动这个文件的人 diff 都是整文件 908/908,噪声盖住真改动;同一份文件不同工具数出行数不同(Get-Content 908 行、编辑器 454 行);发布产物里带着畸形行尾。只动行尾、一个字符不碰(字节差 454 恰等于处数,剥掉全部 CR 后与 main 原版逐字节相同),并补一道扫字节序列本身的锁:既有的 lib-smoke 字节一致性锁抓不到它,那个锁只比 src↔lib 彼此是否一致,两边一起坏就一路放行;新锁直接查这个序列并报出文件与处数(注入 534 处双 CR 后精确变红、还原回绿)。

  • 桌面版装的是 0.26.1,落在 dsh-better-sidebar 的 ^0.18.0 之外 — peer 是 optional 且消费端全防御式(服务注入 + typeof 检查,issue #88 模式),范围只反映生态兼容面而非 API 锁,故放宽上界到 <1 而不是逐版追加。

  • 合并人格页后 effective 键计数锁少了一键 — 冲突时 git 把两边的加法串自动拼上,但只体现了一个 +1——实际上两边各加一键(actionRunEnabled 与 personaEnabled),应为 99。

    细节

    顺手把那条 26 项加法串收成一个字面量:那串每合并一次就要重算一遍,拼得越长越没人验算,换成 99 配一行注释反而可查。同步重新生成 lib/(合并时 lib/config.js 与 lib/settings.js 的冲突标记是脏的,sync 后清零)。

🎨 面板

  • 记忆页三栏改两栏、实体页去冗余、热力图改成按窗口分位染色 — (#448,三轮复检):记忆页左栏加宽到 264px 让四个重要性 chip 排成一行,重要性块跟在检索区之后而不是钉在底部;实体页搜索与计数吸顶、按类型分组的侧栏、画布 320 → 440、补「没有匹配」提示;设置页去掉自动弹出的版本横幅…

    细节

    改手动检查更新按钮(带行内结果)+ 星星入口 + 版本页脚,并把 M.O. 开关提到首屏(首屏预算 8 → 9,注释同步改);实体侧栏补 box-sizing: border-box——width:100% 加水平内边距会把 224px 的栏撑破,overflow-x: hidden 再把「×N」提及数裁掉。热力图撤掉写死的阈值(750k/1.5M/2.5M)改用每窗口分位分桶,低使用量的库也能铺满四档、空窗口降为 0 档,网格收到 GitHub 贡献图口径(11px 格 / 2px 间距 / 月份标签 / 一三五标注)。顺带补了一道反向 i18n 锁:既有的「每键恰好两行」计数锁看不见「零行」的键(它从计数里消失了),而 Icon 遇到未注册名字返回 null,于是新增「每个字面量 t() 都得有词条、每个传给 Icon 的名字都得已注册」的锁(73 → 74),并做过「临时删掉某键两行词典 → 旧锁仍绿、新锁点名」的判别验证。

📚 文档

  • 门面加「最近更新」、补四种安装来源、修掉一圈文档漂移 — (#466):根 README 新增 News 表(日期 | 版本 | 头条)与四种安装来源对照(npm 包名 / Git / tarball / 本地绝对路径),并写明 #path: 不可省的理由——省掉装到的是仓库根那份壳(0.0.0、无 dsh 字段),宿主不会把它当插件…

    细节

    github:slow-stack/mneme 这种写法实测装出来就是个 _pnpmPlaceholder 空壳,正确写法是 #path:/dsh-mneme。同时补 M.O. 章节、补路线图 v0.9 行、删掉两个 README 里早已不存在的幽灵键 sessionLifecycleEnabled(src/ 全无命中,v0.7.11 已清理)。子 README 的「模型工具(9 个)」改成 10 个(漏了 memory_register_document,而同一份文档的架构图里早就写着 10——自相矛盾比写错更容易被忽略)、「9 种记忆类型」改成 10 种(补 document 指针,issue #230)、设计文档 5 篇 → 11 项。

  • SECURITY.md 支持版本表走到 0.9.x,并删掉一个不存在的安全供应商 — (#467):英中两张支持版本表同步到 0.9.x,停止维护通知改成「尽力维护」而不是写死某个版本会终止;功能描述里写死的版本号(Standalone External API (v0.7.12, opt-in))全部去掉——功能描述不该绑版本。

    细节

    删掉 OrbisAI Security 六处:仓库里没有任何其他痕迹,它是 v0.7.1 一笔提交带进来的幽灵供应商,换成 CI 实况(gitleaks 全历史 + dependency-review 看 PR diff + OSV-Scanner 看 lockfile + Dependabot 告警 + secret scanning)。中文段此前整体落后英文段一个版本,这次一并补齐——包括「未配置 token 时远程端点实际处于禁用状态」这句,它正好是 fail-open 真相的反面。配套新增 test/security-doc.test.js 六条断言,其中两条把「开放读清单 / 受门控清单」与 createApi 真实装配出的 route.policy 双向比对——写进文档的路由清单从此不再可能悄悄漂移。

🔐 安全

  • 外部访问 API 的鉴权从「每个 handler 自己记得写」变成「路由注册的必填字段」(#462 / #464) — 审计前的实际形状是 39 条路由里 20 条有 requireAuth,其中 6 条只写了写方法、读方法裸奔…

    细节

    GET /export(导出整库,含 archived / forgotten)与 GET /external-api(明文回吐 Bearer,且会在未鉴权的请求里就地铸造 token)两条最贵重的路由都没有门。根因不是「忘了写」,而是漏写不会响:鉴权写在 handler 内部,少写一行的表现是「功能正常」。改法是把鉴权搬到结构上——新增 export const ROUTE_POLICIES = Object.freeze(["open","token","token-write"]) 与 export function createRouteRegistrar(webServer, apiToken, disposers),每条路由注册时必须声明 policy,漏声明或写错值 → createApi 装配期直接抛,而不是运行时静默放行;token-write 的语义是「只有非 GET/HEAD 才要 token」。39 条逐条归档为 open 19 / token-write 6 / token 14,23 处手写 requireAuth 与函数本体一并删除(grep 归零),routes: 36 这个会腐化的字面量改成 count()。唯一行为差异:少数「方法不对 + 未鉴权」的请求由 404 变 401(先判门再进 handler),方向是收紧。回归锁 6 条进新增的 test/route-policy.test.js(漏声明必抛 / 重复 kind:path 必抛 / 三种 policy 语义 / 装配后全部合法 / count() 与挂载数一致)。这组锁替代了原计划的权限矩阵测试:矩阵要抓的「新增路由忘了加鉴权」在结构上已经写不出来,测试只需锁住「结构本身还在」。

  • 请求体上限从「没有」改成 4 MiB,超限回 413 而不是静默截断(#462 评审跟进) — readBody 原先无上限,且超限时返回空串——parseBody 把空串解析成 {},而 PUT /profile、/rules、/persona 把「字段缺失」当成「用户要清空」,于是一个 5 MiB 的请求可以静默清空画像 / 规则 / 人格。

    细节

    现在超限 resolve(null) 并由 21 处调用点统一早退回 413;错误分支同时改成 resolve(overflow ? null : ""),避免 413 之后又被空串抹掉状态、让调用点拿空串继续写。两条既有测试原先是空断言(旧超限用例只断言 notEqual(200),而 /create 成功返回 201),改为明确断言 413 + 库里没新增 + 画像/规则原样保留。

  • 供应链三处收紧 — security.yml 的 OSV 扫描由 fail-on-vuln: false 改为 true(有漏洞即红,不再只报告);release.yml 的 npm 发布加 id-token: write 与 --provenance(发布产物带可验证来源)…

    细节

    仓库侧打开 secret scanning、push protection 与 Dependabot security updates。main 的保护规则 required checks 追加 gitleaks 与 dependency-review 两条(enforce_admins 仍为 false,这是刻意留的:紧急修复通道要留着)。

  • secret scanning 告警 #1 结案:不是泄露,是语料漏改,修法给语料加锁(#465) — 告警说 dsh-mneme/test/helpers/write-admission-samples.js:76 有明文 OpenAI Key,实际是刻意构造的形状样本(sk-proj- + 26 个顺序字母 + 10 个顺序数字 + ABCD)。

    细节

    该文件里 GitHub PAT / Slack / JWT / Stripe 四条凭据早已用 assemble() 拆成两段拼接(注释写明理由:push protection 会以 GH013 拒推、gitleaks 全历史扫描会红),唯独第五条漏了——「逐条记得改」本身不可靠。改为同样走 assemble(),并新增一条守卫用例:扫 test/helpers/ 下所有 .js,任何完整形状的凭据字面量都会让测试变红。守卫的规则前缀与生产 src/sensitive-scan.js 对齐(ghp|gho|ghu|ghs|ghr / xox[abprs]- / sk-(?:proj-)?),并加同源反查:每条规则给一个探针,断言探针同时被守卫自己的正则与生产 scanSensitive 命中——只查一边的话,任一侧收窄都不会红,正是这次漏掉的失效模式。双向变异验证过(守卫去 ghs → 红;生产去 ghs → 红)。

🧰 发版与文档

  • 发版准备改成「机械的归机器、判断的归人」(Release Prep 工作流重做) — 原先的 scripts/release-prep.mjs 只覆盖版本号、测试数与 CHANGELOG 占位节,而它对两个 README 的版本表有一半是静默失效的——根 README 的路线图与 News 表版本列写的是两段式 **v0.9**…

    细节

    旧正则只认三段式 **v0.9.0**,于是它打印「无版本表格行,跳过」,一个字节都没改。把「没做」说成了「不需要做」,比报错更坏:v0.9.0 那次发布准备(a9f8e35)只有 5 个文件变动,根 README 的路线图与 News 一字未动,就是这条路径留下的痕迹。现在版本表扫描拆成两段式与三段式两条独立正则(分开认是刻意的:给根 README 插 **v0.9.1** 会凭空造出一个不存在的小版本条目),syncReadmeTables() 同时做三件事——三段式版本历史表插占位行、两段式路线图插 minor 占位行、把遗留的「🚧 准备中」回填成已发布。它刻意不把「✅ 已发布(v0.9.0)」改写成「(v0.9.1)」:脚本跑在打 tag 之前,改了等于在 PR 里提前宣布一个还没发出去的版本,PR 一旦放弃就成了谎话。返回的是 actions 数组而不是布尔,因为「没插」有三种原因(没有版本表 / 已是最新 / 插了),入口要能如实说明;actions 非空而文件没变则直接 exit 1,脚本自己的 bug 不许静默发布。

  • SECURITY.md 的支持版本表只核对、不改写,落后时警告不中断 — 跨 minor 发版(0.9 → 0.10)时整张表要向下挪一格并同步改停止维护通知,那是维护策略的判断——机器写容易写出「0.9.x 已停止支持」这种真事故。

    细节

    脚本只核对中英两张表的首行是否等于 ${major}.${minor}.x 并写明期望值。刻意不硬失败:表先改则 package.json 还旧、测试红;版本号先改则表落后、测试红——两头都堵死,人就会绕过这套流程手工发版,那才是最坏结果。代价是那个 PR 的 CI 一开始就是红的,直到人把表挪完,而这正是 test/security-doc.test.js 该红的时候。

  • 根 README 的 News 表明确划在自动化之外 — 它是「日期 | 版本 | 头条」的编辑内容,脚本从不碰,改由工作流在 PR 正文与 commit message 里列进人写清单(连同 CHANGELOG 条目、占位行主题描述共三件)。

    细节

    CONTRIBUTING.md 的发布流程由 6 步改为 7 步:第 1 步跑工作流、第 2 步补人写内容、第 3 步跨 minor 挪 SECURITY 表、第 5 步合并后打 tag、第 6 步说明 tag 触发 release.yml 的完整链路(三方一致校验 → 测试矩阵 → --provenance + publish-gate.mjs → 从 CHANGELOG 抽正文建 Release),手工 npm publish 降级为第 7 步的「例外,不是流程」。工作流的 PR 正文改用 --body-file(多行中文塞进 --body 需要一串 $'\n' 转义,读的人看不懂、改的人不敢动)。回归锁 8 条进新增的 dsh-mneme/test/release-docs.test.js:两份真实 README 的版本表仍被认得出、两段式能插新 minor、三段式插完整 patch 号并带日期、已发布的行不被改写、插入不混入裸 LF、重复跑幂等、SECURITY 同 minor 通过跨 minor 报中英两条、节缺失或表格形状变了也必须报错。

  • Release 正文从「CHANGELOG 逐字搬运」改成「标题 + 一句导语 + 折叠细节」(新增 dsh-mneme/scripts/release-notes.mjs) — 此前 release.yml 用内联 node -e 把 CHANGELOG 该节原样写进 Release 说明,于是 v0.9.0 的 Release 页有 29921 字符、单条子弹最长 3318 字符——正文里全是决策理由与变异验证,读者要点开一屏才知道这条在说什么。

    细节

    抽取搬到独立模块并改成分层渲染:每条子弹保留粗体标题与一句导语(默认可见),其余正文折进 <details>,CHANGELOG 本体一个字不动(那是含决策理由与审计线索的完整记录,压缩只发生在对外渲染这一步)。导语切分规则全由真实语料逼出:句末标点只认 。!? 而刻意不认半角句号(版本号里的点是同一字符,认了会切在版本号中间);软标点断点必须括号平衡(v0.9.0 实测切出过「GET /external-api(明文回吐 Bearer,」这种把从句切断的收尾);导语不得短于 24 字符(有条首句只有 8 个字的「两处水源在说谎。」,离开上下文没有信息量);正文本身比上限还短时整段就是导语、不再加省略号(否则凭空说「后面还有」)。回归锁 17 条进新增的 test/release-notes.test.js(含真进程端到端:渲染后 CHANGELOG 字节不变、找不到小节必须非零退出且报出是哪一版),test/release-workflow.test.js 补一条「正文抽取必须调模块、不许退回内联脚本」。两条评审意见都是真 bug,各补一条锁:一是「短正文保护」原先守在 lede() 末尾、排在软断点搜索之后,短正文里只要有逗号就会被加省略号,逗号恰是末字时省略号后面什么都没有、折叠块是空的——这条保护必须最先执行;二是渲染单位错成了「行」,而 CHANGELOG 里有 179 行缩进续行(0.8.6 / 0.8.7 / 0.8.12 等老版本集中出现,另有 59 行缩进子列表),逐行 trim 会把续行甩到条目的 <details> 外面并丢掉缩进,读者看到的是「正文只有一句,剩下的莫名其妙散在下面」——改为先 splitBlocks() 切出「一条子弹 + 它的全部续行」再按条目渲染,且导语只从条目首行取(把续行拼回正文再切,换行会混进可见行,续行又被推回 details 外面)。变异验证三轮共十处全部变红(删短正文保护 / 去掉括号平衡 / 导语上限归零 / 软断点 consumed 少记一位 / details 与正文之间去掉空行 / 删入口守卫 / 删版本格式校验 / 短正文保护挪回末尾 / 退回逐行渲染 / 导语改从整段取)。

  • 发版准备把新版本节插在了 [Unreleased] 之上,且把攒着的条目留在原地(第一次实跑暴露,随后按评审意见补两处) — 改造后的 Release Prep 工作流第一次在真实 main 上跑 v0.9.1,gh pr create 因组织策略失败(GitHub Actions 不允许开 PR,属合理默认,不改),但脚本已经跑完并推了分支——于是它的产出能被直接审。

    细节

    产出是错的,两个位置错误:新版本节被插在 # Changelog 之后、也就是 ## [Unreleased] 之上,得到「[0.9.1] 排在 [Unreleased] 前面」的颠倒顺序;[Unreleased] 里攒的 9 条条目原地不动,新节里只有一条「🐛 修复 /(待填)」。两处都有同一个历史痕迹作证:v0.9.0 那次发版(a9f8e35)之后靠一笔人工提交 77df0fb「v0.9.0 版本节补上内容——把 [Unreleased] 攒的 27 条搬进来」才掰回来——机器算得对的事不该留给人补。现在先切出 [Unreleased] 到下一个 ## [ 之间的正文:非空则整段搬进新版本节、[Unreleased] 留空;为空才插占位节。搬位置不搬内容,一个字不改,所以「新节里该写什么」仍然只由往 [Unreleased] 写条目的人做。两条评审意见都是真问题,各补一条锁:一是用 moved(- 条目数)当「有没有搬」的判据是错的——正文非空但一条 - 都没有时 moved 是 0,入口会打印「占位节([Unreleased] 本来是空的)」,而它显然不空;改为独立字段 movedContent 决定日志分支、moved 只用来把话说得更具体。二是 body.trim() 会吃掉首行的前导空格,[Unreleased] 首行若是四空格缩进代码块就会降级成普通段落,与「原样搬」矛盾;改为只剥节边界空行(并以原文行尾拼回,免得 CRLF 文件退化成混合行尾)。回归测试 8 条 → 10 条。

  • 发版准备工作流真正跑通:重跑不再撞分支,开 PR 失败会告诉人该去开哪个开关(两次实跑暴露的两个独立真因) — 2026-10-18 把 v0.9.1 在真实 main 上跑了两遍,八步里前七步全成功,每次都卡在最后一步,但两次的原因完全不同。

    细节

    ①第一次(run 39123249758)报 GitHub Actions is not permitted to create or approve pull requests——工作流 job 里写着 pull-requests: write,可它只是声明式权限,真正的总开关在仓库设置(Settings → Actions → General → Allow GitHub Actions to create and approve pull requests),实测 gh api repos/slow-stack/mneme/actions/permissions/workflow 返回 can_approve_pull_request_reviews: false;更麻烦的是这个开关在组织层默认关闭时仓库层根本改不动(gh api -X PUT 直接 409 Conflict:The organization does not allow GitHub Actions to create or approve pull requests),所以除了给报错补上「去哪个页面、打开叫什么名字的开关」,代码这边能做的只有把失败说清楚。②第二次(run 39126751114)在更早的位置就挂了:! [rejected] release-prep/0.9.1 -> release-prep/0.9.1 (fetch first)——上一次运行已经把同名分支推上去了,这条工作流原本不可重入。不可重入的代价不是「重跑失败」这么轻:人会以为流程坏了,转去手工发版,整套自动化就此被绕开,而那正是这套流程想避免的结果。修法是把推分支那一步改成三档守卫:分支上有进行中的 PR → 什么都不动、静默退出(分支上可能已经有人补了 CHANGELOG 条目,那正是留给人的那部分工作,覆盖掉比不能重跑更糟);没有 PR 且顶端是上次机器产出(提交者是 modusensus、标题以 chore: v<版本> 发布准备 开头)→ 按重跑处理,用 --force-with-lease=refs/heads/<分支>:<fetch 到的 SHA> 覆盖(不是 --force:远端在我们 fetch 之后被推过就拒绝);其余情况 → 停下并要人先确认那条分支。回归锁加在 test/workflow-guards.test.js 两条,五处变异验证(去掉 PR 查询 / --force 退回无条件 / 删掉「顶端不是机器产出就停」的判定 / 报错文案去开关名 / 报错文案去具体报错)全部变红;其中后两条第一次是 GREEN——因为对全文 grep 会被注释里解释这件事的文字骗过,改用 codeOnly() 剥注释后才真正锁住。

📦 仓库结构

  • 仓库根补一份「只是壳」的清单,让单仓多包探测器认得出可安装的子包(dshfind 显示「不可安装」的修复) — 本仓库一直是单仓多包——真正可发布的包在 dsh-mneme/,仓库根只有文档。

    细节

    这在 npm 与 dsh plugin add 眼里没有问题(用 #path: 或绝对路径指到子目录即可),但依赖「仓库根有清单」的工具会判错:dshfind.com 的探测器在 probe-install.mjs:182 返回 no-manifest,页面直接写「Not an installable plugin package」,而这句判词与实际能不能装无关。同类项目(包名恰好等于仓库名)不会被误判,我们的包名 @modusensus/dsh-mneme 与仓库名 mneme 不同,探测器无从推断,只能看根清单。补的是一份刻意的空壳:private: true + version: "0.0.0" + 不带 dsh 字段(带了探测器会把仓库根当插件本体,装出来是一个没有 lib/index.js 的空包)+ 不带 type 字段(带了会改变仓库根 test/ 与 scripts/ 下 CommonJS 的模块语义,scripts/release-prep.mjs 与 test/mcp-package.test.js 的运行方式随之变化)。子包声明写进 pnpm-workspace.yaml 而不是 package.json 的 workspaces:npm 一看到根 workspaces 就把 prefix 提到仓库根、在仓库根找 lockfile,于是 dsh-mneme/ 里的 npm ci 报 EUSAGE 退出 1——ci.yml、release-prep.yml、release.yml 三条工作流全在子目录跑 npm ci,会一起炸掉;pnpm-workspace.yaml 只有 pnpm 与探测器读,npm 不读,零副作用。同时改掉一圈因这件事而变假的表述:两份 README 的安装章节原本写「子目录包、仓库根没有 package.json」,现在改说「单仓多包、根清单是仓库壳」;CONTRIBUTING.md 的仓库布局与发布红线、AGENTS.md 的仓库布局、ci.yml 跨包平价腿的注释、release.yml 的「勿把 link 指到仓库根」说明同步更新——文档说「根目录没有 package.json」而代码里有,比没有文档更坏。

🧪 内部与测试

  • 注释里的裸行号改成「符号名 + 日期」 — 审计发现一批行号引用已经腐坏。判据刻意不取「本批改动使该行内容变了」——那会把早就腐坏的引用一起算进来;改为两个维度交叉:锚点法(把引用点名的符号写成正则,看它在基线里能否找到)与平移法(用 git diff --unified=0 算净位移)…

    细节

    结论 3 / 18 / 0——本批推歪 3 条已改、既有腐坏 18 条只登记不改、反常 0 条。单靠任一法都会判反:早期探针只跑锚点法、还把锚点抠成了引用正文里的字样,于是把本批推歪的一条误判成「既有腐坏」。修法照既有约定:不留裸行号,写成「<文件> 的 <符号名>,YYYY-MM-DD 在 :<行号>」——只把数字改对是治标,下次再插一行又错,而注释里的行号不会有人来测。

  • 零消费者的代码与「替未来预留」的字段一次性清掉 — runner 里赋值后立刻 void 的死代码、只出现在 import 行的导入、confirm 回执里三块零引用字段、BUSINESS_VERBS 从 24 个砍到实测命中过的 6 个(21723 次调用只出现 git/gh/npm/node,其余是「猜未来会用到」的预留)、`lo…

    细节

    oksLikeSubcommand的五道if 收成一个正则(字符集天然挡住旗标、含点文件名、$(…) 片段与超长值)。**顺带修掉一处隐私回流**:hints[stepId]` 存的是完整命令行,而它会经提议流进命令回执——P1 的分桶层刚花力气把位置参数剥掉防路径泄露,这里等于从另一个口子把路径带回来,改成只存次数。实际净减 16 行(不是清单预估的 28,有几处加了说明注释抵消)——「删了 28 行」这种账要按实际算,不然它就是下一轮审计里的假话。