dsh-tiddlywiki 0.33.2 → 0.33.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/docs/CHANGELOG.md +172 -199
  2. package/docs/development.md +1 -1
  3. package/lib/client.js +6 -6
  4. package/lib/index.js +474 -189
  5. package/lib/index.js.map +1 -1
  6. package/package.json +2 -2
  7. package/src/client/i18n-frame.ts +4 -2
  8. package/src/client/i18n.ts +9 -2
  9. package/src/client/knowledge-fab.ts +7 -4
  10. package/src/client/note-widget-save.ts +4 -1
  11. package/src/client/note-widget-scope.ts +16 -1
  12. package/src/client/note-widget-tags.ts +15 -12
  13. package/src/client/note-widget.ts +1 -1
  14. package/src/client/settings-page-catalog.ts +7 -2
  15. package/src/client/settings-page-runtime.ts +7 -6
  16. package/src/client/settings-page-wikis.ts +3 -4
  17. package/src/client/sidebar-entry.ts +4 -0
  18. package/src/client/status-cache.ts +4 -1
  19. package/src/client/tid-parse.ts +88 -3
  20. package/src/client/tid-preview.ts +18 -7
  21. package/src/client/tool-views-shell.ts +4 -4
  22. package/src/client/tw-frame-api.ts +11 -4
  23. package/src/client/tw-frame-hash.ts +7 -1
  24. package/src/client/tw-frame.ts +11 -4
  25. package/src/host/admin-routes-info.ts +62 -16
  26. package/src/host/admin-routes-seeds.ts +22 -12
  27. package/src/host/admin-routes.ts +29 -13
  28. package/src/host/clip-bridge-limits.ts +12 -3
  29. package/src/host/clip-bridge-payload.ts +15 -4
  30. package/src/host/clip-bridge.ts +20 -3
  31. package/src/host/command-note.ts +6 -3
  32. package/src/host/config.ts +7 -2
  33. package/src/host/git.ts +6 -2
  34. package/src/host/mutation-lock.ts +22 -4
  35. package/src/host/routes-session-agent.ts +5 -1
  36. package/src/host/routes-session-summary.ts +23 -3
  37. package/src/host/routes-tw-proxy.ts +5 -0
  38. package/src/host/routes.ts +15 -3
  39. package/src/host/session-summary-scan.ts +12 -3
  40. package/src/host/tools-attach.ts +24 -4
  41. package/src/host/tools-git.ts +1 -1
  42. package/src/host/tools-notes-lifecycle.ts +53 -22
  43. package/src/host/tools-notes-structure.ts +11 -6
  44. package/src/host/tools-notes-write.ts +18 -7
  45. package/src/host/tools-support.ts +61 -12
  46. package/src/host/tools.ts +6 -3
  47. package/src/host/tw-api.ts +5 -1
  48. package/src/host/wechat-publish.ts +6 -2
  49. package/src/host/wiki-farm.ts +9 -3
  50. package/src/host/wiki-instance.ts +14 -0
  51. package/src/host/wiki.ts +8 -0
  52. package/src/index-routes.ts +52 -7
  53. package/src/index-wikis.ts +2 -2
  54. package/src/index.ts +23 -2
package/docs/CHANGELOG.md CHANGED
@@ -1,8 +1,24 @@
1
1
  # 版本记录
2
2
 
3
- > 逐版本变更。**版本历史的唯一所在地**——README 只放"是什么、怎么装、好在哪",不再重复这里(2026-09-29 起,此前 README 抄了最近 18 条,每次发布都要改两处)。
3
+ > **只记「这一版改了什么、为什么」,不写推演过程**。完整来龙去脉见 git log / [GitHub Releases](https://github.com/bbqisbbq/dsh-tiddlywiki/releases);当前状态与踩过的坑见 AGENTS.md §2 与 wiki「dsh-tiddlywiki 事实总表」。
4
4
 
5
- > 最近几个主要版本的一句话记录(完整变更见 [Releases](https://github.com/bbqisbbq/dsh-tiddlywiki/releases) / git log)。
5
+ > **维护规则**:「最近版本」保持 8 条详细条目,新条目加在最上面;超过 8 条就把最老的一条压成历史表的一行。历史表一行只需说清「改了什么」,别把守门条数、行数对比这些过程数据抄进来。
6
+
7
+ ## 最近版本(详细)
8
+
9
+ - **v0.33.4**(2026-10-26):**四路只读审计(host / 工具与数据路径 / 客户端 / 死代码)后的修复版**。
10
+ - **数据安全**:trashbin 软删除把副本标签整体替换成 `$:/tags/trashbin`,恢复时又从 `fields.tags` 找原文——而 TW 的 `tags` 是已知字段、永远不进 `fields`,于是装了 trashbin 的库删完再恢复**标签全丢**且静默(现在副本写 `[标记, ...原标签]`,恢复只剥标记);写工具(put / batch_put / delete / rename / append / replace / attach)**没有** `isBlockedProxyTitle()` 守卫,模型可直接改写 `$:/plugins/dsh-tiddlywiki/config`(`wechat.command` 会被 spawn、`git.remote` 下次同步生效)——读侧早就拦了,写侧一直敞着。
11
+ - **互斥与假成功**:`MutationLock.end()` 没有所有权校验,而 `/admin/seeds/run` 对**每条**响应都调它(最常见的「重新初始化但什么都没改」压根没 begin)⇒ 会把此刻正持锁的 `/sync` 释放掉,`/admin/restart` 随即拿锁、两条路径同时排干 + 停子进程;现在 `end(label)` 校验持有者。`/admin/info`(改插件/主题/语言也会 drain + 重启)一直没接这把锁;`/restart` 与 `/admin/restart` 在目标库没在跑时回 `{ok:true,drained:true}`(什么都没重启)。
12
+ - **多库与会话**:会话汇总的引用正则只认裸 `/tw/#标题`(多库注入的是 `/tw/<id>/#标题` ⇒ 「知识库」Tab 的读取列表系统性为空)、`NOTE_TOOL_NAMES` 漏 `tiddlywiki_replace`、汇总缓存不按库分键(3s 内串库)、工具层 `autoCommit()` 永远 touch 默认库的 committer、设置页跑 seed 丢了 `proxyBase`(多库下「重新初始化」把 `$:/config/tiddlyweb/host` 写回裸路径,那个库的编辑器此后所有 API 打到默认库)、`/admin/prompt` 预览漏掉 wechat 规则与多库链接格式。
13
+ - **其他确定缺陷**:`config.load(undefined)` 清缓存却把 `loaded` 留成 true(下一次 `set()` 会把配置抹成只剩本次补丁)、`stop()` 打断 `start()` 后 health 永远停在 `starting`、`/note` 的 `=== true` 把「默认开启」倒过来、剪藏兜底标题的最后一个候选从未校验、剪藏正文上限(20 万字符)大于 body 上限(256KB)、不带 heading 的 append 仍塞 LF 块(CRLF 笔记被写混)、attach 吞 `tags: []`、put 回执的 `字段:` 是「调用方传了什么」、wechat 启动 spawn 抛错仍回 `ok:true`、`/api` 代理不排空 DELETE body。
14
+ - **死代码 / 无效逻辑**:`delete` 的 `permanent: true` 逃生通道写在 `canTrash` 之前(文案指的路永远走不通)、trash list 的 `slice(-limit).reverse()` 把「字母序副本 + 时间序索引」混当「最新在前」、`empty` 在从没有索引的库上凭空建索引、`listTrashbin` 单条读取失败会掀掉整张列表、`库目录 === 仓库根` 的库在「diff 未知 ⇒ 保守重启」里被漏掉、`isPlausibleTitle` 放行 `[`、7 处被 `\t` 吃字/粘行的注释;`tiddlywiki_status`(不存在的工具名)又一次出现在错误文案里。
15
+ - **文档**:`docs/CHANGELOG.md` 从 292 KB 压到 ~32 KB —— 最近 8 条保持详细,其余 150 条压成一句话表(演进细节交给 git log / GitHub Releases)。
16
+
17
+ - **v0.33.3**(2026-10-26):**`.tid` 预览的第一眼改成看内容,而不是看元数据**。
18
+ v0.33.2 修好注册之后,预览能出图了,但顶部把**所有**字段原样铺开:`created`/`modified` 是 17 位 UTC 戳(`20260920070533227`),`revision`/`workspace`/`ws/<项目>`/`agent-written` 这类机械字段也一字不落地占版面 —— 打开一个笔记,前两屏全是元数据。
19
+ 现在新增纯函数 `summarizeTid()`(`src/client/tid-parse.ts`):单列 `title` / `tags` / 时间,时间戳按 `YYYY-MM-DD HH:mm` 格式化(不是 17 位数字就**原样返回**,不装作看懂了);`ws/*` 与 `agent-written` 不再占标签位,但会在「另外 N 个字段」那行留一个 `tags(hidden)=…` 的影子 —— **折叠不等于丢弃**。
20
+ 另把实现名从 `.tid 文件预览` 改成 **「真 TiddlyWiki 渲染」**(en: `Rendered TiddlyWiki`):宿主在 `candidates.length > 1` 时会画一个实现选择菜单(`rendererId` 按标签页记忆,这是它让"修好了"看起来像"没修好"的原因),名字写清楚用户才能自己切回来。
21
+ 守门 `verify-tid-preview` 17 条:新增「17 位戳格式化 / 非 17 位原样」「摘要只单列 title·tags·时间且机械字段进 other」「没有 title/时间时不硬造」三条;i18n 475 → 476 键(`frame.tidMoreFields`)。
6
22
 
7
23
  - **v0.33.2**(2026-10-26):**`.tid` 预览仍不生效 —— 这次是"服务还没出现就放弃"**。
8
24
  v0.33.1 修好了 `key` 配对,但用户实测仍是纯文本,且 devtools 里 `[class*="dsh-tw-tid"]` 数量为 0(正文组件压根没渲染)。
@@ -27,200 +43,157 @@ v0.33.1 修好了 `key` 配对,但用户实测仍是纯文本,且 devtools
27
43
 
28
44
  - **v0.31.0**(2026-10-26):**对齐 DSH 0.2.1 的一轮改造 + 两条真实回归修复**。① **选库入口搬到官方座位**:`conversation.input.selector.context` 在 0.2.1 里**不存在**(v0.28.8 起的 `scope-seat.ts` 整个是死代码),选库改挂官方内联座位 `conversation.input.left`(`id: 'wiki-scope'`),`scope-seat.ts` 删除、dock 里那套 `scope` 内联渲染与自测量一并清掉;右侧栏 guide 条目补 `id` + `keepMounted: true`(0.2.1 的分页约定)。② **新增 `scripts/verify-dsh-contract.mjs`**:解析宿主 `app.asar` 里的 `dsh-cordis-client-runner`,把 103 条官方座位目录与我们源码里注册的 6 个座位逐一比对(存在性 / kind / `tool.call.toolview` 的 54 个已占 key 是否撞车),并断言 `engines.dsh` 已声明 —— 此前"座位名写错"只会静默不渲染。③ **`tiddlywiki_get` 支持分页**(`offset`/`limit`,默认 12000 字符):超长笔记一次读全会撞宿主的 12500 token 回执上限;回执明确写"正文 a–b / 共 N 字符"并给出下一步 `offset`。`search`/`lint` 的回执也加了同量级的体量预算(命中条数与计数结论**永不截断**,只截示例并说明省略了多少)。④ **16 个工具补 `output.schema`**:DSH 0.2.1 在每次调用后用 `validateJsonSchemaValue` 校验返回值,不过即抛 `ToolOutputError` —— 而 `sdk.ts` 的 `output.schema` 自 v0.20.0 起写死成 `{}`(当时注释断言"没人读它",**实测为假**)。现在按"只声明必定存在且类型稳定"的规则为 16 个工具各写一份,并加两条守门:每个工具必须声明非空 object schema;**用 E2E 里每一次真实返回值反查声明**(抓"schema 比现实更严"这种线上致命错误)。⑤ **skill 改用 `ctx.skills.register`**:拆库 skill 过去只能往 `$DSH_HOME/skills` 写文件,现在优先注册到宿主 skill 注册表(内存、随插件卸载消失、不碰用户目录),只有宿主没有该服务时才回落写文件;新增 `scripts/verify-embedded-skill.mjs`(解析/坏输入/disposer/注册表抛错/**启动顺序必须"先注册后回落"**)。⑥ **新增 `/note` 斜杠命令**:一句话记笔记,直接写进当前会话的知识库、不产生模型消息;同名笔记**已存在就拒绝**并指路("同一件事写多份互相矛盾"是最难收拾的失败形态),且写成 `inbox` + 工作站标记、**不打 `agent-written`**(那是人写的);守门 `scripts/verify-note-command.mjs`。⑦ **提示词两处修订**:新增「### 先查再答」(模型可见工具从 57 涨到 243 之后,新会话不再主动检索 —— 注入本身没坏,`system/message` 里逐字可查,坏在提示词只讲"怎么写"、从不讲"什么时候该来查")与「### 时效与去重」(作者原话:agent 爱写短命内容、同一件事写多份有差异的、正文里堆变更流水)—— 钉子:一件事只留一处权威表述(先搜、命中就**原地改那一节**)、每条笔记必须看得出"还有效到什么时候"(`valid-until`/`review-after`/"长期有效",说不清时效的别写)、**不写流水账**、**读到就先判时效**。slim 正文 1079 → **1390 字符**(预算 1000 → 1470,与逐条断言绑定),并把提示词正文的拼装从"硬编码数组 + 导出的块清单"两份实现收成 `governanceBlocks()` 单一来源 —— 加规则时 `verify-prompt` 当场抓到这个双份实现。
29
45
 
30
- - **v0.30.65**(2026-10-09):**新增 `tiddlywiki_replace` 工具(笔记正文局部替换)**。Agent 想只改笔记里的几处文字(比如把 Markdown 圆括号内链批量改成双括号 `[[x]]`)此前没有对应工具:要么整篇 `tiddlywiki_put` 重写(易出错、费 token),要么绕过插件手写 HTTP PUT(会踩「`modified` 是 UTC、别写本地时间」这类坑)。新工具 `replace(title, old, new, all?, expectedModified?, expectedRevision?, force?)`:读全文 → 精确替换子串 → 整篇回写;默认只换第一处、`all:true` 换全部;`old` 未命中回执 `replaced:0` 且不报错(别把「没命中」当「改好了」);与 put/append 同一套乐观并发保护与写策略(保留 type/tags/字段、二进制附件拒绝)。工具数 15 → 16:`full` 提示词目录、`verify-prompt`/`verify-tool-views` 的工具数断言、AGENTS.md §2、package.json 描述、docs/development.md 同步;客户端新增「局部替换」工具卡与徽标(`tool-views` / `i18n-card`)。
31
-
32
- - **v0.30.63**(2026-10-09):**默认提示词整理(「聊天引用」与「笔记内引用」分家)+「整段替换」配置说明勘误**。① **笔记约定的链接规则拆成两条**:旧版只有一条 `[标题](/dsh-tiddlywiki/tw/#标题)`——那是**聊天回复**专用写法,模型会照抄进笔记正文,于是 URL 一旦带未转义空格就被 markdown-it 整条拒绝渲染成纯文本,即便渲染成功也是外链、点开另起标签页(正是「笔记里的链接偶尔是 Markdown 格式、不是 `[[…]]`」的根因)。现在拆成「**笔记内引用**」写 `[[标题]]`(文字不同写 `[[标题|文字]]`,两种内容类型下都能正确渲染成站内链接、无需 URL 编码)与「**聊天里引用笔记**」写带作用域 base 的链接形式(多库时自动带库 id)。slim 正文 866 → 937 字符(仍 < 预算 1000,`verify-prompt.mjs` 预算/链接/多库断言全绿)。② **`config.prompt.override` 设置页说明勘误**(zh+en):原「非空时取代上面的内置文本」有歧义,改为「非空时替换内置提示词,附加说明仍会追加在末尾」。纯文案,无行为变化。
33
-
34
- - **v0.30.62**(2026-10-09):**第二轮审查的三类修复一次落地(回收站格式合并 / 会话作用域目录 / 剪藏目标库口径 + 提示词 −14% + 清理)**。全部改动都有行为级守门,且都做了反向验证。① **回收站:两种格式真正合并**。v0.30.59 的注释写着「legacy index is still READ so a wiki that has both keeps working」,代码却是 `trashbin !== undefined ? {entries: []} : readTrashIndex()` —— 于是**装了 trashbin 的库反而看不到内置条目**:work 库当时回收站里明明有 1 条内置索引条目,`trash list` 报「共 0 条」(本次实测复现)。现在两种格式**都读、合并列出**,恢复/清空按「这条来自哪种格式」记账(`fromIndex`)。② **`|` 分隔符会截断标题**:v0.30.60 的注释断言「TW 把 `|` 转义成 `&#124;`」——**实测为假**(`<$text text="a|b"/>` 原样渲染回 `a|b`;TW 的 htmlEncode 只转 `& < > "`),所以标题含 `|` 的副本会被拆成两段、列出**截断的标题**且恢复不了。改为用**自有标记 `<li>`** 分隔(标题里的 `<` 必被转义,不可能伪造标记),并把抽取逻辑拆成纯函数 `parseTrashbinFragment()`;顺手补上**实体还原**(`&lt;`/`&gt;`/`&quot;`/`&amp;`,`&amp;` 放最后才是无损的)⇒ 标题含 `<`/`&`/`"` 的副本现在也能正确列出与恢复。③ **恢复不再把副本的溯源字段留在笔记上**:`TRASHBIN_AT_FIELD` 在 v0.30.60 从 `trash-at` 改名成 `trash-iso`,而恢复路径只删新名字 ⇒ 恢复**任何 v0.30.60 之前的副本**(或内置格式快照)都会让 `trash-at` 留在恢复出来的笔记上(正是改名要躲的那种「TW 会重排成本地日期」的字段)。现在旧名字有具名常量 `TRASHBIN_LEGACY_AT_FIELD`,**列出时读、恢复时删**,内置格式的写入也统一用新常量。④ **`git_resolve` / `git_sync` 的目录改从会话作用域读**(`ToolScope.dir`):两者原来都走 `ToolsDeps.wikiPath()`,而它解析成**默认库** —— 于是「排干哨兵写进会话库、却在默认库目录里等文件」(多库且作用域≠默认时**必然** 12s 超时 + 假 `drainFailed`,而真正要验的库一行没验),`git_resolve` 不传 `wiki` 时会在**错的仓库**里 checkout+commit(回执示例恰好就是不带 `wiki` 的写法)。`ToolsDeps.wikiPath` 已删除(它没有 session 参数、只会误导)。⑤ **剪藏目标库口径统一**:`bridge.wiki` 指向**已登记但没在跑**的库时不再静默回落默认库(那正是「剪到别的库」的失败形态,而书签小工具根本不看回执里的库名),改为 **503 + 点名那个库**(与 `?wiki=` 路由自 v0.29.0 起的规则一致,`targetProblem()` 把句子带进响应体);**未知 id** 仍回落(老书签不能 404);书签的成功提示现在显示回执里的库 id;`bridge.*` 是**插件级**设置(读取它的是默认库那份 config),设置页因此只在「未选中具体库」的作用域里露出这一组,选中某个库时给一句说明 —— 否则在别的库的配置里改它是静默无效的(v0.28.8 起就存在的陷阱);seed 文档的配置表补上 `bridge.wiki` 这一行。⑥ **注入提示词 1011 → 866 字符(−14%,预算 1200 → 1000)**:写入约定压成「被拒后重读、别用 `force`」一句(`expectedModified`/`force` 的语义逐字在 `put` 的 schema 里)、同步那条去掉与 schema 重复的自动 commit 说明、intro 去掉「本机有一个知识库」这个**多库下就是错的**说法;同时**多库时正文会自动换成带库 id 的链接格式** `/tw/<id>/#标题`(裸 `/tw/` 在助手正文里跟随**焦点库**、写进笔记则把中央 TW 的 iframe 导航到**默认库**——两种都不是「本会话那个库」)。⑦ **清理**:删掉死代码 `messageKeys()`(注释自称有消费者,实际全仓 1 处引用=声明自身)、修掉三处过时注释(「尚无 i18n 分支」/「index.ts effectiveBridge」/两条被粘成一行的注释)、`tiddlywiki_status` 这个**不存在的工具名**从错误文案里去掉、删掉两个零引用的一次性迁移脚本(`push-notes-to-wiki.mjs` / `split-agents-to-wiki.mjs`)、`docs/code-review-*.md` 不再随 npm 包发布(`files` 加否定式 + 守门断言,实测 171 → 170 项)、`wechat.endpoint` 补进 `ResolvedConfig`/`STRING_CONFIG_PATHS` 两处形状、AGENTS.md §2 的行数清单去掉会漂移的数字。**守门**:`verify-trashbin-probe.mjs` 11 → **15 条**(新增「两种格式合并列出」「从索引恢复要摘掉索引记录」「恢复不得残留 `trash-at`」「含 `|`/转义标题的解析」)、新增 `scripts/verify-clip-target.mjs`(运行中/已登记未运行/未知 id/清单未就绪 + 端到端 503 文案,**这是 AGENTS 早就声称存在、实际为 0 的守门**)、`verify-git-multi-sync.mjs` 新增两条(哨兵必须落在会话库目录、`git_resolve` 缺省目标必须是会话库)、`verify-prompt.mjs` 新增多库链接用例。**反向验证**:去掉 `delete restored[TRASHBIN_LEGACY_AT_FIELD]` ⇒ 15 条里精确红 1 条;恢复后逐字一致。**环境事实(今天第二次踩到)**:运行时的 `profiles/*/node_modules/dsh-tiddlywiki` 必须显式升级,源码仓库改了不会自动生效。
35
- - **v0.30.61**(2026-10-09):**修 `tiddlywiki_trash list` 少报一条 —— TW 给块级片段的首元素套 `<p>`** —— v0.30.60 实跑时 `list` 对**126 条**回收站报「共 125 条」,少的那一条**恰好是排序后的第一条**(实测片段为 `<p>$:/trashbin/__验收测试_trashbin软删除__|…`,而 `startsWith('$:/trashbin/')` 对带 `<p>` 的首条不成立)。**「列表少一条」是最坏的失败形态之一** —— 少的那条可能正是用户最想找的那条,而工具报告成功、不报错。现在改为**先定位前缀再剥标签**(`indexOf` 取到前缀处、再 `replace(/<[^>]*>/g,'')`),与首条是否被段落包裹无关。② 同时兼容旧字段名:`trash-iso` 是 v0.30.60 起的名字,**此前写的副本只带 `trash-at`**,不兼容读它时回执里「删除于」那一栏**整列为空**(实测);现在两个字段都读,`trash-iso` 优先。**守门** `scripts/verify-trashbin-probe.mjs` 增至 **11 条**,新增两条钉住上述两点;**反向验证**:去掉剥标签逻辑 ⇒ 红、改回 `\n` 拆分 ⇒ 红,各自精确点名,恢复后逐字一致。
36
- - **v0.30.60**(2026-10-09):**修 v0.30.59 的 `tiddlywiki_trash` 静默谎报「回收站是空的」** —— v0.30.59 发布后第一次实跑,`list` 就报「共 0 条」,而 wiki 里明明有 126 条 `$:/trashbin/` 副本。**两个独立缺陷叠在一起,缺一个都不会这么静默**:① `listTrashbin()` 的探针写成 `<$list … variable="ignore">` —— **`variable="ignore"` 会占用 `currentTiddler` 这个保留变量名**,于是循环体内的 `<$text text=<<currentTiddler>>/>` 全部绑到空上下文,实测返回 **126 个分隔符、零个标题**;② 分隔符原写成 `\n`,而片段是 **HTML**、换行会被折成空格,按 `\n` 拆会把整个列表当成**一行**,那行又过不了 `$:/trashbin/` 前缀检查。两者都产出 `[]`,而「空的回收站」是完全正常的答案 —— 于是工具**报告成功、同时撒谎**。③ 补一道自检:**有分隔符却零标题 = 探针坏了,必须显式报错**(不再回落成空列表)。④ 逐条 `wiki.get()` 改**有界并发(8)**:125 条串行就是 125 次 REST 往返。⑤ 溯源时间字段由 `trash-at` 改名 **`trash-iso`** —— TW 会把任何 `*-at`/`*-date` 字段当日期**重新序列化成本地格式**(实测 PUT 进去的 ISO 读回来是 `10/09/2026 05:11:54`),机器可读的值会丢。⑥ 配套发现并修正一处**流程事实**:`plugins/dsh-tiddlywiki`(源码仓库,0.30.59)与 `profiles/*/node_modules/dsh-tiddlywiki`(运行时实际加载,0.30.57)**是两份不同的副本** —— 改完源码 + 重启 DSH 不会生效,必须走发布流程(tag → npm publish → 升级运行时副本),这是 AGENTS.md §4 末尾第 8 步「同步线上 wiki」的真实含义。**守门**(新增 `scripts/verify-trashbin-probe.mjs`,进 `verify:unit`,8 条):钉住探针不含 `variable="ignore"`、必须输出 `<<currentTiddler>>`、必须按 `|` 拆而非换行、有分隔符零标题必须 throw、有界并发、字段名。**反向验证**:把 `variable="ignore"` 注回去 ⇒ 红;把 `|` 换回 `\n` ⇒ 红;恢复后逐字一致。
37
- - **v0.30.59**(2026-10-09):**软删除改为「跟着 wiki 已有的回收站机制走」;同时把一条会误导人的注释改成实测事实** —— 本机 work 库已装 [kookma/trashbin](https://github.com/kookma/TW-Trashbin)(v1.3.6,`$:/trashbin/<原标题>` + `$:/tags/trashbin` 标签),但 `tiddlywiki_delete` 此前**看不见它**:软删除一律写 `$:/dsh-tiddlywiki/trash/<时间戳>/<原名>` + JSON 索引,于是 agent 删掉的东西只有 agent 能列出/恢复,人在 TW 侧边栏里一条都看不到,两套回收站互不相通。现在:① `tools-support.ts` 新增 `hasTrashbin()` 探测(**只读**,不写探针 tiddler——那会在从不装 trashbin 的库里留垃圾,而它正是要检测的东西;判据是 `$:/tags/trashbin` / `$:/plugins/kookma/trashbin/sidebar-tab` **是否存在条目**,**不是查 `tiddlywiki.info`**——浏览器安装的 trashbin 压根不在 info 里(实测:服务器无 `plugin.info`、info 无登记,却照常工作),查 info 会在「恰恰装了它的库」上误报 false);② 命中时 `delete` 写 trashbin 格式副本(保留原 `created`/`modified`,另记 `trash-of`/`trash-iso`),**不再写 trash-index**,同名二次删除显式拒绝而非静默覆盖;③ 未命中时走原路径(未装 trashbin 的库行为不变)。**⚠️ 顺带修正一条错误的注释**(`tools-support.ts` 原先写「`$:/` 系统标题无法被枚举」,v0.30.58 之前 agent 据此连续三次误判,包括否掉一个实际可行的方案):实测证明「不可枚举」**只对 HTTP 列举端点成立**(`get-tiddlers-json.js` 给任何 filter 追加 `+[!is[system]]`,实测 `tiddlers.json` 恒 138 条、系统条目恒 0),而**filter 引擎无此限制**(服务端渲染 `[tag[$:/tags/trashbin]]` 得 `COUNT=125`)、**启动期 `store.area.template.html` 内嵌整个 store** 使客户端一开始就持有全部 tiddler——**三条通道必须分开说**,这也是 trashbin 格式不需要索引的根本原因。
38
- - **v0.30.58**(2026-10-09):**`tiddlywiki_git_sync` 不再要求显式配 `git.remote` —— 配置为空时回落到该库所在仓库自己的 `origin`**(作者 2026-10-09 裁定,方案 A)。起因是一个**同一个问题两个答案**的真实现场:设置页/FAB 的「同步」按钮一直是在目录里跑 `pull/commit/push`、按仓库 `origin` 推,而 Agent 工具把 `git.remote` 当成"开启同步"的开关 —— 同一个仓库,界面点得动、工具却回「没有任何知识库配置了 git.remote(同步远端)」并**整库跳过**(实测:本机 notes 仓库已推到远端、`git remote -v` 有 origin,工具仍报三个库皆未配置)。现在:① 判定改为「**`git.remote` 或仓库 `origin`**」,`git.remote` 降级为**覆盖**(也仍是 `ensureRemote` 写入的目标);② 回落**只在「没配 **且** 真在 git 仓库里」时发生** —— 不在仓库里就不白跑 `git`(新增 `GitFace.originOf()`,**只读、不建远端**);③ `GitSyncTarget` 新增 `remoteSource: 'config' | 'origin' | 'none'`,**回执逐仓库写出远端与来源**(「自动识别自仓库 origin」/「来自 git.remote 配置」),且 URL 里的**凭据先剥掉**(`git.remote` 可能带 PAT,`/admin/state` 对同一个值打码 —— 回执是新开的一条出口);④ `skipped` 分得清「未配 `git.remote`,仓库也没有 origin」与「不在 git 仓库里」,零目标时的文案同时给出两条出路;⑤ 设置页「git 远端」提示改为「空=自动用该库所在仓库的 origin」,`docs/features.md` / `docs/usage.md` / `AGENTS.md` 同步改写。**守门**:`scripts/verify-git-multi-sync.mjs` 新增「回落只在该回落时发生」(**假 farm + 假 GitFace 直接驱动 `createGitLayer().gitTargets()`**,断言 `originOf` 的**调用点集合**:有配置的不探、不在仓库的不探)、回执来源文案、凭据剥离、两种跳过原因;`scripts/verify-repo-keying.mjs` 用真临时仓库钉 `originOf()` 的三态(有 origin / 没有 / 不在仓库里)与「只读不建远端」。`createGitLayer` 因此进入公共面(`scripts/index-barrel-surface.json` 已更新)。**反向验证**:把回落那一行改回 `''` ⇒ 新守门当场点名 `personal` 的 `remoteSource`;恢复后逐字一致。**顺手修好一处既存红灯**:`AGENTS.md` §5 的开发文档索引还指着 2026-10-04 那次笔记整理里**已被合并删除**的 5 篇旧笔记(常见坑 / 架构事实 / 仓库布局 / 易变清单 / 发布流程),而 `scripts/verify-wiki-pointers.mjs` 正是为此存在 —— 指针已改指**整理后仍在的三篇**(《dsh-tiddlywiki 事实总表》《TiddlyWiki 使用与技巧事实总表》《拆库丢失的系统条目清单》)+ 仓库 `docs/CHANGELOG.md` / `docs/development.md`,§2 §4 §6 §7 里同样的旧名字一并改掉。**不恢复那些笔记**(删除是有意为之),`verify:sync` 因此恢复全绿。
39
- - **v0.30.57**(2026-09-30):**给 `AGENTS.md` 的注入预算装上守门** —— 这条纪律的**存在理由就是它自己**。本文件顶部写着:宿主给 workspace instructions 的预算是 **65536 字节**,「过去它长到 ~120KB,**截断正好砍掉最后几节(发布流程 / 维护规则 / 常见坑)**」—— 也就是说**这份文件当初被拆成「规则 + 指针」的原因,就是它撞过预算上限**。而那条教训**只写在正文里,没有任何东西在量它**(`verify-doc-links` 管链接、`verify-wiki-pointers` 管指针,都不管篇幅),失效方式依然是沉默的:文件继续长到超限,宿主从末尾截断,被砍掉的正好是**最该被看到的规则**,**没有人会收到报错**。新增 `scripts/verify-agents-budget.mjs`(进 `verify:static`):**两级硬阈值** —— 硬上限 **65536**(宿主预算=事实边界)与软上限 **45000**(自我警戒线),**两级都红**(本仓库的教训是「只打印 warning 的守门等于没有守门」;要抬 SOFT 必须明确改常量=有意识的决定)。⚠️ 顺带守一件更隐蔽的事:**文件第 5 行对自身体积的自述不得漂移** —— 它当时写着「现在拆完约 **12KB**」,而实测已 **40.7KB**(**偏差 70%**,文件在对自己撒谎而无人被冒犯)。**这正是本守门第一次运行就抓到的**:`顶部自述「约 12KB」,实测 40.7KB`。已改为实测值并把守门写进那句旁边。**反向验证**:填充到 46920 字节(> 软上限)⇒ 红;填充到 66920 字节(> 硬上限)⇒ 红并点名「注入会被从末尾截断」;恢复后哈希逐字一致。当前实况:`41890 字节 / 65536 = 63.9%`。
40
- - **v0.30.56**(2026-09-30):**`AGENTS.md` 的 wiki 指针现在有守门了** —— §5「开发文档索引」是这套文档体系的**承重墙**(细节搬进 wiki、注入文件只留指针),但指针断掉的后果是**沉默的**:新会话按它去读会读到空气,而 `tsc` / 任何现有守门都不会报错(`verify-doc-links.mjs` 只管仓库内的相对文件链接)。新增 `scripts/verify-wiki-pointers.mjs`(并进 `npm run verify:sync`):**从 `AGENTS.md` 的表格行里**取出 `[[…]]` 指针(**只取表格行** —— 正文里的 `[[标题]]` 是 TW 语法示例、不是指针,这个区分是判据的核心),再从 wiki 数据根解析出**全部条目标题**(meta 的 `title:` 优先、退回文件名),要求每个指针都命中。**多库模式也认**(优先读 `$DSH_HOME/dsh-tiddlywiki/wikis.json` 的 `defaultId`/全部库,退回单库的 `location.json`)—— 实测本机上插件跑在多库模式、`location.json` 根本不存在,只认单库的话这个守门会**永远 SKIP**。⚠️ **找不到 wiki 数据根时 SKIP 而不是 FAIL**(别人的机器 / CI 上 wiki 可能不存在,前提不成立),但 SKIP **必须明说**「本轮未验证」;也正因如此它**不进 `verify:static`**(CI 上必然 SKIP,等于给人「已守」的错觉),而与 `verify:lib-sync` 同挂在 `verify:sync`(本机环境相关、提交/发布前跑)。**反向验证**:把 §5 的一个指针改成不存在的笔记名(=笔记被改名后的真实形状)⇒ **红并点名**;`--self-test` 模式另行证明「不存在的指针名会被判为缺失」。当前实况:**5 个指针全部命中 · 3 个库 · 2041 条条目**。
41
- - **v0.30.55**(2026-09-30):**把新守门接进 `prepublishOnly` + 修 `verify-all` 的收尾** —— ① `package.json` 的 `prepublishOnly` 此前只跑 `typecheck + build + verify:static + verify:unit`,**不含 `verify:e2e` / `verify:large` / `selftest` / `smoke:client`**,且**不跑 `verify:sync`** ⇒ 发布时构建产物可能**没被校验过与 `src/` 同步**(正是 v0.30.52 那个缺口的发布形态)。现在改为 **`npm run verify:all`**(= `verify:sync` + 8 步 `verify`),发布前的门与 CI 完全一致。② `verify-lock-sync.mjs`(v0.30.54 新增)此前只在 `verify:static` 里;本版确认它在 `prepublishOnly` 路径上也一定会跑(`verify:all` → `verify` → `verify:static` 的第一步就是它)。③ 顺手把交接卡与三篇 wiki 笔记更新到 v0.30.55。**发布前的门 = CI 的门 = 本地的门**,三处同源。
42
- - **v0.30.54**(2026-09-30):**铁律 #12 终于有了执行者** —— `package-lock.json` 与 `package.json` 的同步,此前**只靠「记得手工跑一下 `npm ci --dry-run`」**(既慢又碰网络,于是没人会在提交前跑)。新增 `scripts/verify-lock-sync.mjs`(进 `verify:static` 的**第一步**,因为它是纯读两个 JSON、最快能给出结论的一道网):**零网络、零磁盘副作用**地查六件事 —— ① 版本**三处一致**(`package.json.version` / `package-lock.json.version` / `packages[""].version`,发版漏改一处 `npm ci` 就报 `lock file does not satisfy`);② `lockfileVersion >= 2`;③ 名字一致;④ 四个依赖字段的**集合**双向一致(漏了 / 已删);⑤ 每个声明的依赖在 lock 里有**实体条目**且 **locked 版本满足声明的 range**(用 semver;非 semver 范围如 `git`/`file:` 跳过并明说);⑥ **范围本身一致**(同一个依赖写出两个范围=手改过 lock)。⚠️ 它**不替代** `npm ci`(真正的「全量可解析性」需要网络,交给 CI),它拦的是「**改了 `package.json` 忘了重生成 lock**」这一类事故的完整形状。**反向验证三种注入**:改 range 不重生成 lock ⇒ 红(范围不一致);改 lock 里实体版本到不满足范围 ⇒ 红;只改 lock 顶层 version ⇒ 红。当前实况:`LOCK SYNC OK(版本三处一致 · lockfileVersion 3 · 14 个依赖全部对得上)`。
43
- - **v0.30.53**(2026-09-30):**让「本地全绿 = 可以提交」这条等式真正成立** —— `npm run verify` 现在与 CI **覆盖面相同**,并新增 `scripts/verify-ci-parity.mjs` 防止两者**再次**漂移。这一轮发现的缺口比上一轮更大:CI 的 `selftest` job 跑 **`selftest` + `smoke:client`**,而本地 `verify` **两个都不跑**;CI 四步里还各跑 `typecheck` / `build`,本地 `verify` 也到不了(`AGENTS.md` §4 只写了「改核心路径跑 `npm run selftest`」—— 一句**没有执行者**的建议)。⇒ `verify` 改为 `typecheck && build && verify:static && verify:unit && verify:e2e && verify:large && selftest && smoke:client`(**8 步,与 CI 的 9 个 script 一一对应**),并新增 `verify:all` = `verify:sync && verify` 作为「提交前跑完整一遍」的唯一入口。守门 `verify-ci-parity.mjs`(进 `verify:static`):取出 `ci.yml` 里所有 `npm run <x>`,要求**每一个都能从本地 `verify` / `verify:all` 到达**(按**脚本名可达性**判断,而不是逐字比较命令 —— 本地串 34 个 verify-*.mjs、CI 直接调聚合脚本,形式不同而覆盖面相同,逐字比会天天误报);例外表 `CI_ONLY_ALLOWED` **当前为空,且刻意如此**。**反向验证**:往 CI 里加一步本地到不了的 `npm run verify:missing-demo` ⇒ 红并点名;写完本守门的**第一次运行就当场抓出** `typecheck` 与 `build` 两个缺口。
44
- - **v0.30.52**(2026-09-30):**补上唯一一个「本地全绿、CI 才红」的类别** —— `lib/` 与 `src/` 的同步,此前**只由 CI** 的 `git diff --exit-code -- lib/` 把关,本地四档 `npm run verify` **一次都不查它**。新增 `scripts/verify-lib-sync.mjs` + `npm run verify:sync`(**故意不进 `verify`/`verify:static`** —— 它是唯一会**写 `lib/`** 的守门,只应在「准备提交」时跑,只读的那两处必须保持只读)。判据之所以可以这么硬:构建**逐字节可复现**(实测两次 `npm run build` 的 `lib/index.js` SHA256 相同)⇒ 直接「重建 → `git status --porcelain -- lib/` 必须为空」,不需要时间戳或哈希清单这类花招。顺手把 CI 里那条**同样只存在于 CI** 的检查(铁律 #8:`lib/*.js` 不得含真实的 `@deepseek-ai` 运行时 import,用 `from`/`require(` 前缀正则避免命中文案)搬到本地。**反向验证**:改一行 `src/index.ts` 而不 build ⇒ 红并点名 `lib/index.js.map`(=当年那次「差点带过期 lib 提交」的完整形状);给 `lib/index.js` 注入 `@deepseek-ai/sdk` ⇒ 命中数 0→1。
45
- - **v0.30.51**(2026-09-30):**把「barrel 公共面」从人肉纪律变成机器棘轮** —— 补上 `verify-index-barrel` 之外的最后一道缺口。v0.30.49 新加的守门只覆盖「本仓库脚本用得到的」175 个导出;而 `src/index.ts` 一共导出 **294** 个,剩下 ~119 个**在仓库内零消费者**(它们的存在意义是 npm 下游/未来调用方)—— v0.30.42 丢掉 `MARKDOWN_PLUGIN` 就是这一类:**零消费者 ⇒ 零报错 ⇒ 只有人肉 `git diff` 能发现**。本版加**只增不减的棘轮**:把完整公共面快照进 `scripts/index-barrel-surface.json`(294 个名字),**少一个** ⇒ 红并点名(要拦的事故),**多一个** ⇒ 也红并提示更新快照(`公共面变大应当是一次有意识的决定`)。**反向验证**:删掉 `export { MARKDOWN_PLUGIN }` —— `tsc` 报 **0 个错误**,而棘轮精确点名它 ⇒ 这正是 v0.30.42 的真实事故形状;把快照人为缩小一项 ⇒ 「新增了 MARKDOWN_PLUGIN」也红。⚠️ 生成入口 `--update-surface` 必须在「快照存在性检查」**之前**处理,否则首次生成会被自己拦住(本版实测并修掉)。
46
- - **v0.30.50**(2026-09-30):`src/index.ts` **724 → 674** —— 新模块 `index-startup.ts`(202):**自举阶段整片**(装拆库 skill → 读控制文件 → 读会话作用域 → 建 farm → `startAll` → `applyPrompt` → 绑剪藏桥,收进 `createStartupStage(deps)`)。`index.ts` 拆分第三轮:剩下的是 `apply()` 的其余装配(farm/锁/工具接线 + 拆卸 effect),读入口现在基本能一眼看完生命周期。**三条刻意不动**:① `disposed` 仍归入口(它是**拆卸 effect 与这个任务共同的主人**——任务每次 await 后检查它、发现卸载就自己停子进程,v0.19.1),以 `isDisposed` 传入;② `farm` 由这个阶段**创建**、但**全插件所有**(路由/工具/视图都读)⇒ 经 `setFarm` 交回入口,入口仍是可变状态的唯一主人;③ **顺序即语义**(尤其是「拆库 skill 必须在 wiki 之前装」,那是真实事故的产物)——已在模块头逐条写明。⚠️ 其中一个**必须留在 `index.ts` 的导入绑定**(`readLocationState` / `readRegistry` 这两个名字只被 barrel 再导出使用)暴露成一个模式,本版顺手改掉了根因:`export { X } from '…'` **不消费** import 绑定 ⇒ `noUnusedLocals` 报「声明未读」、而删掉 import 就让 `lib/index.js` **静默少一个导出**(v0.30.48 / v0.30.50 各踩一次,只有 e2e 能发现)。修法:这类名字改用**无 `from` 的 `export { X }`** —— 真的读一次绑定,导出照样存在,两个要求同时满足。⚠️ 守门同步:`verify-ready-policy` 那条「`base: config` 必须交给实例」的断言改读 **`src/index` 族**(阶段搬走后 `base: deps.config` 落在新模块里)—— 判据不变,只是不再钉文件名;`verify-index-barrel`(v0.30.49 新增)本轮**当场**抓出了被漏掉的两个导出。
47
- - **v0.30.49**(2026-09-30):**修 v0.30.48 引入的真缺陷 + 补上「barrel 公共面」的守门**。① v0.30.48 用 `noUnusedLocals` 清理 import 时删掉了 `writeLocationState` 的 **import 行**,而同一文件里 `export { … } from './host/wiki-location.ts'` 是**独立语句**、不消费那个绑定 —— 于是 `lib/index.js` 不再导出它,`scripts/verify-wiki-switch.mjs` 在 **e2e** 阶段才崩(`does not provide an export named 'writeLocationState'`),而 `typecheck` / `verify:unit` / `verify:static` **全绿**。这正是 v0.30.42(误删 `MARKDOWN_PLUGIN`)那一类「**barrel 丢失对编译器和多数守门不可见**」的第二次事故。② 新增 `scripts/verify-index-barrel.mjs`(进 `verify:static`):**凡是本仓库脚本从 `../lib/index.js` 具名导入的符号,`src/index.ts` 必须真的导出它**(解析的是 `export { … }` 与 `export <decl>`,**不是**「出现过这个名字」—— 后者挡不住「barrel 行被降级成 import」这种形态)。带两条反空转保险(导出数 ≥100、需求数 ≥50),并**反向验证**过两种注入(删掉导出 / 降级成 import,都稳定变红)。③ 写这条守门时自己踩了同一个坑的**第三种方向**:解析 `export { … }` 列表时按 `,` 切分,而列表里恰好有一段解释性注释 —— 注释里的逗号把名字切碎、注释里的右花括号提前终止列表,**症状是「源码明明导出了,守门却报缺」**。修法同 v0.30.45/48:**先剥注释再解析**(顺序照旧先剥行注释)。⇒ 「对源码文本做结构断言先剥注释」这条纪律,本仓库已经交了**四次**学费(v0.28.12 假红、v0.30.45 假绿、v0.30.48 假红、v0.30.49 解析器自己出错)。
48
- - **v0.30.48**(2026-09-30):`src/index.ts` **955 → 724** —— 新模块 `index-routes.ts`(381):**路由 / admin 装配阶段整片**(`RouteDeps` + `AdminDeps` 两个对象与两张路由表的注册,收进 `createRouteStage(deps)`)。**这是 `index.ts` 拆分第二轮**:v0.30.42 先切走了「纯」的配置面,这一轮切走的是**装配阶段里唯一自成一段**的部分 —— 读入口现在能一眼看到生命周期(farm → prompt/tools → 启动任务 → 路由 → 拆卸),而不再被 120 行 accessor 接线打断。三条刻意不动的东西:① **`disposed` 仍归入口**(它的主人是拆卸 effect 与启动任务),唯一需要它的那处守卫改为**传入谓词** `isDisposed()`;② **变更锁仍由入口建、并把同一个实例传进 stage**(v0.30.12:整插件一把锁)—— 顺手删掉一个**从未被读的依赖** `GitLayerDeps.isDisposed`,README 式的「按行删 import」交给 `noUnusedLocals`(一次点名 23 处,零猜);③ **注册时机一字未改**(`ctx.inject(['webServer'])` 仍在 wikiViews 之后、拆卸 effect 之前 —— 顺序没有重排)。⚠️ 守门同步一处:`verify-mutation-lock` 的「只建一把锁」计数改成**按模块族**统计,并给该判据**剥注释** —— 新模块的说明文字里写了一句 `createMutationLock()`,未剥注释时会被当成**第二个调用点**(v0.30.45 那种「断言被自己的文档满足」的镜像:这次是假红)。
49
- - **v0.30.47**(2026-09-30):`src/client/note-widget.ts` **588 → 573** —— 新模块 `note-widget-foot.ts`(73):**底栏那一组**(左组:上传按钮 / 「🕘 最近」/ `Ctrl+Enter` 提示;右组:「✏️ 在 TW 中编辑」/「保存」)。这一片是**零卡片状态**的纯布局(与 v0.30.43 的 placement 片同类):依赖面只有「一个已经建好的上传按钮」+「`recent.toggle` 的函数引用」,把 `{ foot, footLeft, footRight, saveBtn, editBtn }` 交回卡片 —— 两个按钮的 `click` 处理器**仍绑在卡片里**(紧挨着实现它们的 `note-widget-save.ts`),因为「谁实现动作」与「谁画按钮」是两件事。`footLeft`/`footRight` 两个 class 一并交回,因为 `.dsh-tw-note-foot-right` 的 flex 规则(右贴边 + 装不下时整组换行,v0.22.6 的按钮压成两行事故)是**契约**,卡片还要往里 append。
50
- - **v0.30.46**(2026-09-30):`src/client/note-widget.ts` **615 → 588** —— 新模块 `note-widget-upload-ui.ts`(82):上传的**接线**那一组(「上传」按钮 + 隐藏的 file input + 拖放到编辑器上,连同它自有的 `dragDepth`)。分层刻意分成两个文件:`note-widget-upload.ts` 是**动作**(`uploadInto`:校验大小 → POST → 在光标处插入 Markdown 行 → toast),`note-widget-upload-ui.ts` 是**接线**(File 从哪来、何时给编辑器挂 `.dsh-tw-note-drop` 高亮)。分界线:整组只问卡片要两样东西 —— 编辑器(拖放目标 + 插入点)与 `wikiQuery`(附件必须落在**这张卡片的目标库**里),卡片侧不再持有任何上传状态(`footLeft.append(upload.button, …)` 一行替代原 36 行)。
51
- - **v0.30.45**(2026-09-29):**`note-widget.ts` 拆分第四轮 —— 抽出「保存 / 在 TW 中编辑」整组(677 → 615 行),并在同一轮**抓出一条被注释满足的假绿守门**。① 新模块 `src/client/note-widget-save.ts`(187 行)拿走 `saveDone` · `saving`(在途互斥)· `doSave` · `doEdit` —— 这四样是 `build()` 里唯一真的"说话"的一段(发请求、读回执、弹 toast、改按钮状态与文案)。**分界线(可复用)**:先把这段代码**读写到的闭包状态**列出来,再逐样决定怎么传 —— 它自带的 `saving` **跟着函数走**(同 v0.30.44);卡片**留在原地**的四样各有理由:`close()` 是卡片自己的可见性收尾、`postEditAndOpen()` **两条路径共用**(`doEdit` 与 `openNative`)所以必须住在两者都看得见的地方、`loadedToken` 的**主人是「🕘 最近」picker**(它写、这里只读)⇒ 按 getter/setter 传、`scope` 这里只读**一个**方法(`stoppedTarget()`)⇒ 传函数而不是整个模块。② `const doSave = async (): Promise<void> => {` 这一行**逐字保留**:`verify-wiki-focus.mjs` 用 `indexOf(那句签名)` 定位函数体、再断言其后 900 字符内出现 `stoppedTarget()`(=「卡片保存同样要拒绝未运行的目标库」,v0.29.0);它是**族读**(`readFamily('src/client/note-widget')`)⇒ 搬到新文件仍命中。③ ⚠️ **本版最重要的一条:那条判据在搬迁后变成了假绿,是反向验证抓出来的。** 第一次跑两种注入(**删掉整个 `stoppedTarget()` 拒绝分支** / **把 `doSave` 改名**)——`verify-wiki-focus` **两次都照样绿**。根因:它是拿**未剥注释**的族文本做 `indexOf`,而新模块的**模块头注释**逐字引用了那句签名、还在同一段里提到 `stoppedTarget()` ⇒ `indexOf` 先命中**注释**,900 字符窗口里出现的 `stoppedTarget()` 也来自注释,**断言被自己的文档满足**(这条注释本身是为了解释"为什么这一行不许改名",属于"越写越安全"的反面)。修法:`verify-wiki-focus.mjs` 新增 `stripComments()`(**先剥行注释、再剥块注释** —— 顺序承重,v0.30.14 在 `verify-client-i18n` 里踩过反序吞代码的坑),那条测试的五条断言全部改跑在剥注释的源码上。**加固后重跑同样的两种注入 ⇒ 两次都稳定 FAIL**(删分支 ⇒「卡片保存同样要拒绝未运行的目标库」;改名 ⇒「找不到 doSave」),还原后文件哈希与注入前**逐字节一致** ⇒ 绿。⇒ 结论:`grep`/`indexOf` 型判据在**拆分**这件事上尤其脆 —— 模块头注释几乎必然会引用被搬走的那句签名,所以凡"定位函数体 + 窗口内找符号"的断言,**必须剥注释**。④ **其余守门一条都不用改**(逐个核实,不是猜):`verify-frame-guards` 按模块族读并在测试里剥注释行,`dispose()` / `open()` / `openNative()` / `installCardDrag()` / `positionCard()` 都留在卡片里;`verify-wiki-focus:770` 那条**按精确路径**读卡片、钉 `const mark = …note.defaultMark…` 的断言同样不受影响(被断言的那行没离开卡片)。⑤ 一个**诚实的差异**写进模块头(不改变可观察行为):旧代码直接闭包引用 `editor`/`titleInput`/`tagEditor`/`saveBtn`/`editBtn`,新代码经 `deps.getUi()`,于是两个函数各多一条 `ui === undefined` 早退(实践中不可达 —— 两者都由 `build()` 建出来的按钮与编辑器键位触发,写出来只为类型收窄)。**验证**:`typecheck` / `build`(client bundle 770,645 字节,仍 minify)/ 客户端五守门(`verify-frame-guards` / `verify-wiki-focus` / `verify-tw-origin` / `verify-client-i18n` / `verify-frame-surface`)/ `verify-editor-popup` / `verify:unit`(退出码 0)/ 全量 `npm run verify` 四档退出码 0(E2E / WIKI SWITCH / WIKI FARM BOOT / LARGE WIKI CHECKS 全绿)/ `verify-line-endings`(260 个文本文件全 LF)/ `verify-version-consistency` 全绿。
52
- - **v0.30.44**(2026-09-29):**`note-widget.ts` 拆分第三轮 —— 抽出「草稿生命周期」整族,连同它持有的两个状态一起搬走(750 → 677 行)**。① 新模块 `src/client/note-widget-autosave.ts` 拿走 `resetTitle` / `flushDraft` / `scheduleDraft` / `hideDraftBanner` / `resetForNewNote`,**以及它们闭包里的 `draftTimer` 与 `persistedSignature`** —— 这一轮与前两轮都不同:**状态跟着函数整片搬走**,卡片只经模块返回的句柄读写(`resetTitle` / `flush` / `schedule` / `hideBanner` / `resetForNewNote` / `clearTimer` / `setPersistedSignature` / `markPersisted`)。② **为什么这次状态必须走**(而不是像 v0.30.41 的 scope 片那样只传访问器):`verify-frame-guards.mjs` 把两条不变量**按名字**钉在 `resetForNewNote` 的函数体里(`/clearTimeout\(draftTimer\)/` + `/persistedSignature\s*=/`),它们编码的正是 v0.22.8 的「幽灵草稿」事故 —— 「保存 / 丢弃后重置编辑器」必须①取消防抖定时器、②把这段空内容记成"已定型",否则 500ms 后那次防抖会把 `{text:'', title:'<时间戳>'}` 写成草稿、下次打开对着空编辑器报「已恢复未保存草稿」。把变量与函数整片搬走 ⇒ 两处字面量**逐字保留**、**一个守门都没有改**;反过来「改名成 `draft.timer` 再放宽断言」会把判据从「钉不变量」降级成「钉新名字」,那才是真正的损失。③ 卡片侧保留 `flushDraft` / `scheduleDraft` / `hideDraftBanner` / `resetTitle` 四个**局部别名**(`const { flush: flushDraft, … } = autosave`):这些名字在 `build()` / `dispose()` / `open()` 里有十几处调用,改名只放大 diff、不增加信息(v0.30.38 那条「优先抽取、少改名」)。④ 顺带删掉一处**只写不读的死状态** `autoTitle`:自 v0.29.0 起 `flushDraft` 的判据已从「正文为空 **且** 标题是自动生成的那个」升级成「正文为空」(守门还专门断言它不得再引用 `autoTitle`),于是这个变量只剩赋值 —— 把一个只写不读的变量搬进新文件等于把债务搬进新家。⑤ 两个**诚实的差异**写进了模块头,都不改变可观察行为:`getUi()` 可能返回 `undefined` ⇒ 模块里多了早退(旧代码直接闭包引用 `editor`/`titleInput`/`tagEditor`,那时卡片必然已 `build()`,真为 undefined 会抛 TypeError);`opened` / `disposed` **刻意不进**新模块,按谓词传进去(它们属于卡片的可见性生命周期,与 `note-widget-recent.ts` 同款处理)。⑥ **反向验证**:删掉模块里 `resetForNewNote` 的 `clearTimeout(draftTimer)` ⇒ `verify-frame-guards` 稳定 FAIL 在「保存/丢弃后重置必须取消防抖并记录已定型签名」;还原后文件哈希与注入前**逐字节一致** ⇒ 绿。**验证**:`typecheck` / `build`(client bundle 770,128 字节,仍 minify)/ 客户端五守门(`verify-frame-guards` / `verify-wiki-focus` / `verify-tw-origin` / `verify-client-i18n` / `verify-frame-surface`)/ `verify:unit`(42 套,退出码 0)/ 全量 `npm run verify` 四档退出码 0 / `verify-line-endings` / `verify-version-consistency` 全绿。
53
- - **v0.30.43**(2026-09-29):**`note-widget.ts` 拆分第二轮 —— 抽出「卡片摆在哪 + 怎么拖」,817 → 750 行;顺手修掉一批写错版本号的注释**。① 新模块 `src/client/note-widget-placement.ts`(109 行)拿走两件事:`positionCard(root, card, anchor?)`(有锚点就贴在输入框上方、否则右下角,视口夹取)与 `installCardDrag({ root, card, head, closeBtn })`(标题栏拖动,pointer events + `setPointerCapture`)。**分界线怎么定的**:问「这段代码需要卡片给它什么」—— 两半都**只碰 DOM 元素和自己私有的手势状态**(`dragState`),对 wiki 名册 / 草稿 / 保存 token **零依赖**,所以一个访问器、一个 setter 都不用传(与前两轮的 `note-widget-scope.ts` 恰好相反:那边七个状态必须用访问器过桥)。这轮也因此**没有一个守门需要放宽** —— 全仓库 `grep` 不到任何 `dragState`/`applyDrag`/`pointerdown` 的断言。② 两个「别顺手清理掉」的语义已写进模块头:**`positionCard()` 每次 open 都跑**,所以拖过的位置**刻意不跨 open 保留**(下一次打开重新贴锚点);拖动的监听器挂在 `dispose()` 会移除的 DOM 上,所以**故意没有卸载步骤**。③ **补了一条守门**(`verify-frame-guards.mjs`,「每次 open 都重新定位 + 拖动接线只有一份实现」):前半钉 `open()` 里那次调用必须带上锚点(谁改成"记住位置"就是改了行为),后半**按精确路径**单独读卡片本体、断言 `setPointerCapture`/`pointerdown`/`dragState` 都**不在**卡片里(族读会把模块拼进来、分不出谁实现的 —— 这里正是"族读"与"精确读"必须各用一次的场景)。**反向验证**:去掉锚点参数 ⇒ 红;把 `dragState` 塞回卡片 ⇒ 红;还原后文件哈希与原文件一致。⚠️ 写这条守门时踩到 `bodyOf()` 的一个坑:**签名里别带 `{`** —— `bodyOf` 会自己跳到函数体的第一个花括号,签名带上 `{` 它就从那里往后找**内层**块,取到的不是整个函数(第一次断言因此假失败,已把这条写进脚本注释)。④ **顺带修掉 7 处写错版本号的注释**:上一轮(v0.30.41)的 `note-widget-scope.ts` 拆分被注释成了「v0.30.42」——`src/client/note-widget-scope.ts:4`、`src/client/note-widget.ts:36/111`、`scripts/verify-tw-origin.mjs:169`、`scripts/verify-wiki-focus.mjs:314/345/791` 全部改回 **v0.30.41**(`git log -S` 确认它们确实随 `d2417c5` 落地)。**教训与 v0.30.42 的 barrel 那条同源**:注释里的版本号/文件名/公共面**没有任何自动化校验**,只能靠人复核(顺带说明:这类"注释说的版本 ≠ 实际落地版本"不会让任何关卡变红)。**验证**:`typecheck` / `build`(client bundle 769,654 字节,仍 minify)/ 客户端五守门(`verify-frame-guards` / `verify-wiki-focus` / `verify-tw-origin` / `verify-client-i18n` / `verify-frame-surface`)/ `verify:unit`(42 套,退出码 0)/ 全量 `npm run verify` 四档退出码 0 / `verify-line-endings` / `verify-version-consistency` 全绿。
54
- - **v0.30.42**(2026-09-29):**`index.ts` 拆分第一轮 —— 先切「配置常量」这半边,1036 → 955 行**(交接卡第 6 条 B 步:**先区分「阶段产物」与「配置常量」,再按生命周期阶段抽**)。新版 `src/index-config.ts`(127 行)拿走五样东西:`TiddlywikiConfig`(`config:` 块形状)· `ResolvedConfig`(合并后形状)· `CLIP_BRIDGE_DEFAULT_PORT` · `DEFAULTS`(那张默认值表)· `resolveConfig(rawConfig)`(合并函数)。① **为什么这半边能离开入口**:它是**纯**的 —— 不吃 `ctx`、无副作用、**对装配顺序零依赖**;而 `apply()` 里剩下的东西(farm 的建立 → 变更锁 → 路由 → 工具)恰恰相反,**顺序本身就是语义**(第 3 条裁定:整插件只建一把锁、drain 只走一条路径),所以先切纯的这半边,正是 B 步「先分类再动手」的意义。② **搬迁 ≠ 顺手扩公共面**:只把**本来就是导出**的 `TiddlywikiConfig`(type)+ `CLIP_BRIDGE_DEFAULT_PORT` 再导出;`DEFAULTS` / `ResolvedConfig` / `resolveConfig` 以前**都不是** `export`(`DEFAULTS` 连 `const` 都没带 export),所以**不**加进 barrel —— 公共面按原样搬,不借机变宽。③ `index-wikis.ts` 里那条 `import type { TiddlywikiConfig } from './index.ts'` 改指 `./index-config.ts`(形状的新家):运行期图仍是**单向**的 `index.ts → index-config.ts → index-wikis.ts`(回来的那条只有 `import type`,编译后不产生运行期边)。④ **编译器又一次当免费的协调器**:`noUnusedLocals` 精确点名搬迁后**只剩两条** import 变孤儿(`DARK_PALETTE_DEFAULT`、`READY_TIMEOUT_DEFAULT_MS`),逐条删掉即可,没有一处靠猜。⑤ ⚠️ **本仓库的一条新教训(已写进 wiki 常见坑):barrel 里"再导出"的丢失,`tsc` 与守门脚本都**看不见**。** 搬迁过程中我误删了同一区块内的 `export { MARKDOWN_PLUGIN } from './host/wiki-instance.ts'` —— `tsc` 全绿、`verify:unit` 42 套全绿,因为**全仓库没有任何东西 import 它**(`grep MARKDOWN_PLUGIN` 只有定义处 + 一条守门脚本里都没提),它是给 `lib/index.js` 的**外部**消费者用的公共面。**抓它的是 `git diff` 逐行复核**,不是自动化。⇒ 凡动 `index.ts` 的 barrel,`git diff` 必须逐行看,别只看编译器脸色(这与 v0.30.28「钉 import 字面量的断言改成钉语义」是同一族问题的两面:**公共面的存在性没有守门**)。⑥ **守门无需改动**:`verify-config-ranges.mjs` 的 `read('src/index.ts')` 本来就是 `readFamily`(`index-config.ts` 属 `src/index-*` 族 ⇒ `DEFAULTS` 仍被找到、`CLIP_BRIDGE_DEFAULT_PORT` 仍被 `export const [A-Z0-9_]+ = [\d_]+` 收集到);另外九个按族读 `src/index` 的守门(`verify-restart-drain` / `verify-language-pin` / `verify-frame-guards` / `verify-mutation-lock` / `verify-skill-install` / `verify-wechat-docs-seed` / `verify-wechat-publish` / `verify-wiki-focus` / `verify-ready-policy`)逐个复跑全绿;`verify-package-contents.mjs` 逐字列了 `'src/index.ts'`,但只判文件存在,不受影响。**验证**:`typecheck` / `build`(client bundle 769,598 字节,仍 minify)/ `verify:unit`(42 套,退出码 0)/ 上述九个族读守门单跑 / 全量 `npm run verify`(static + unit + e2e + large)退出码 0 / `verify-line-endings` / `verify-version-consistency` 全绿。
55
- - **v0.30.41**(2026-09-29):**`note-widget.ts` 拆分第一轮 —— 抽出两片,993 → 817 行**(第 5 条裁定的收尾,接着 v0.30.40 的 `tw-frame` 样板做)。① **`note-widget-recent.ts`(「🕘 最近」picker)**:这是 774 行 `createNoteWidget()` 闭包里**唯一一组状态完全私有**的东西 —— `recentOpen` 只被 `toggleRecent`/`closeRecent`/`loadNote` 三个函数碰到,于是**状态直接跟着搬走**,卡片里**一处都不用改名**(`tw-frame` 那条「优先抽取、少改名」的结论又一次成立)。它从卡片拿的两样东西是**会写**的(`loadedToken` / `persistedSignature`),用 setter 回调传进去 —— **卡片仍然是这两个值的主人**,模块只负责喂。⚠️ 每个请求都走 `wikiQuery` 依赖:漏了这条在多库下「最近」会去默认库拿列表(正是 `verify-wiki-focus` 钉的那个缺陷)。② **`note-widget-scope.ts`(这张卡片作用于哪个库)**:把 `targetWiki`/`targetPicked`/`rosterMode`/`roster`/`defaultWikiId`/`focusOff`/`targetPromise` 七个状态与 `ensureTarget()` / `stoppedTarget()` / `wikiQuery()` 一起搬出,**但 DOM 那一半刻意留下** —— 画 `<select>` 是卡片的职责,卡片通过 `scope.setSyncDom()` 把重画函数注册回去。这条分界线同时保住了 `verify-wiki-focus.mjs` 里那条**按精确路径读 `note-widget.ts`** 的断言(「默认库只加身份标记」的那行 `const mark = …` 仍在卡片里),所以**不需要**把那个 `readFileSync` 改成族读。③ **守门同步三处(都是"钉语义不钉排版")**:`twProxyFor(rosterMode, targetWiki, …)` 的两条断言(`verify-tw-origin` / `verify-wiki-focus`)放宽成 `twProxyFor\([^,]+, [^,]+, payload\.twUrl, payload\.twUrlAbsolute\)` —— 钉的是「前两个参数来自**这张卡片的目标库**」,不是那两个变量名;native 路径的顺序断言从 `indexOf('await ensureTarget()')` 改成 `search(/await (?:scope\.)?ensureTarget\(\)/)`(钉的是**先解析、再写**这个顺序);`const mark = item.id === default(Wiki)?Id ? …` 改成 `… === [^?]*\? t\('note\.defaultMark'\)`(钉的是「默认库那一项拿到目录里的身份标记」)。④ `verify-frame-guards.mjs` 那份「生命周期函数各一份」的清单**不用改** —— 它本来就是 `readFamily`,`const ensureTarget = (): Promise<void> => {` 的写法原样保留在新模块里。**验证**:`typecheck` / `build`(client bundle 769.2 kB,仍 minify)/ `verify:unit`(42 套,退出码 0)/ 五个客户端守门(`verify-frame-guards` / `verify-frame-surface` / `verify-tw-origin` / `verify-wiki-focus` / `verify-client-i18n`)/ `verify-line-endings` 全绿。
56
- - **v0.30.40**(2026-09-29):**`tw-frame.ts` 内核抽出 `tw-frame-hash.ts`**(第 5 条裁定继续,**纯搬迁**):把「待落的 hash 怎么落到 iframe 上」整段(`applyPendingHash` + `tryOnce` 的 40×150ms 等待链 + 跨源兜底 `fallbackLoad`,62 行)抽成 `createHashNavigator(deps)`。base **652 → 615 行**,新模块 106 行。① **关键设计:只读状态用访问器传,不改名**。这段代码对 `frame` / `disposed` **只读**(`frame.src = …` 是对**对象属性**赋值,不是重绑变量),所以用 `getFrame()` / `isDisposed()` 传进去即可 —— 而这一步是**必须**的:`frame` 在同一文件里**同时**是接口属性(`frame: string`)与对象字面量的键(`frame: 'dsh-tw-rightbar-frame'`),任何文件级改名都会误伤它们(v0.30.38 实测被 tsc 拦下、已回滚)。**抽取只要求集中「写入点」,改名才要求改对所有「出现点」** ⇒ 名字超载时**优先抽取、少改名**;真正会被写的 `pendingHash` / `hashWaitTimers` 留在 `surfaceState` 里按引用传入。② **守门同步**(两处,都是"钉语义不钉排版"):`verify-wiki-focus.mjs` 里 `wikiSwitchPending` 的两条断言放宽为 `[Ww]ikiSwitchPending` / `/if \(.*[Ww]ikiSwitchPending.*\) return/`(现在只剩访问器形式 `deps.isWikiSwitchPending()`);同文件的 `const frame = readFileSync(…'src/client/tw-frame.ts')` 改成**再拼一个族成员** —— 它是**按精确路径**读的,抽走后那半段断言会看不见代码(这正是本仓库"守门按模块族读"那条规矩的由来)。③ 顺带确认:`verify-frame-guards.mjs` **本来就是 `readFamily`**,所以它那份「生命周期函数各一份」的清单**不需要改**(`applyPendingHash`/`fallbackLoad` 仍然各一份,只是换了文件)。**验证**:`typecheck` / `build` / `verify:unit`(42 套,退出码 0)/ 五个客户端守门(`verify-frame-guards` / `verify-frame-surface` / `verify-tw-origin` / `verify-wiki-focus` / `verify-client-i18n`)/ `verify-line-endings` / `verify-version-consistency` 全绿。
57
- - **v0.30.39**(2026-09-29):**`tw-frame.ts` 状态合并第二片**(第 5 条裁定继续,**行为零变化**):`pendingHash`(10 处引用)与 `wikiSwitchPending`(5 处)也进 `surfaceState`。① 这两个是上一轮**特意留下**的——它们被 6 处源码级守门提到,动手前逐条核实了**到底哪些真的在钉名字**:`verify-frame-guards:182` 是一份**函数名显示清单**、`verify-frame-surface:355` 是**注释**、`verify-tw-origin:177` 是**测试名**(都不受影响),真正结构性的一条只有 `verify-wiki-focus:736` 的 `/if \(wikiSwitchPending\) return/`。② 于是把那条断言的判据**从「钉变量名」改成「钉语义」**:`/if \((?:\w+\.)?wikiSwitchPending\) return/` —— `(?:\w+\.)?` 让它只要求「换库未完成时必须 return」这件事成立,**不再锁定这份状态住在哪**(这正是本仓库拆分守门的通用改法:钉意图,不钉排版;此前 `verify-tw-origin` 那条钉 import 字面量的断言也是这么改的)。③ 至此 `tw-frame` 内核的**第一阶段(状态合并)**已覆盖 4 个成员(`hashWaitTimers`/`frameLoaded`/`pendingHash`/`wikiSwitchPending`),剩下的 `frame`/`disposed` 同类处理即可;**状态全部集中后**才做第二阶段的模块抽取(`tw-frame-hash.ts`,必须用 `tw-frame-` 前缀,否则 `readFamily` 读不到、守门静默失效)。**验证**:`typecheck` / `build` / `verify:unit`(42 套,退出码 0)/ 五个客户端守门(`verify-frame-guards` / `verify-frame-surface` / `verify-tw-origin` / `verify-wiki-focus` / `verify-client-i18n`)/ `verify-line-endings` 全绿。
58
- - **v0.30.38**(2026-09-29):**`tw-frame.ts` 内核:第一步「状态合并」起手**(第 5 条裁定继续,**行为零变化**)。① **为什么不是"直接抽取"**(v0.30.37 读了源码后的结论):`createTwFrameSurface()` 里最像独立模块的那一段(`applyPendingHash` + `tryOnce` + `fallbackLoad`,约 60 行)引用了 **6 个闭包内的可变状态**;硬抽成模块就得把它们做成 7 个访问器(含 setter)—— 那不是"职责清晰",而是**把内部细节摊成一个又宽又漏的接口**,比原样更难读。**抽取不是目的,清晰才是**,所以先做第一步:**把状态收成对象**(此后抽取只需传一个 `surfaceState`)。② **先量爆炸半径再动手**(`grep scripts/*.mjs`):`pendingHash` 被 **4** 处源码级守门按文本钉着、`wikiSwitchPending` 被 **2** 处,而 **`hashWaitTimers` 与 `frameLoaded` 是 0 处** —— 于是本版**只合并这两个零风险的状态**(`const surfaceState = { hashWaitTimers, frameLoaded }`,7 处引用改名),把"改名撞上按文本匹配的守门"这类**事后排错**换成**事前排序**。③ 下一步(已写进交接笔记):再动 `pendingHash`/`wikiSwitchPending`,届时把那 4+2 处断言的判据从**「钉变量名」改成「钉语义」**;状态全部集中后,才做真正的模块抽取。**验证**:`typecheck` / `build`(client bundle 768,231 → 768,362 字节,仍 minify、远低于 1MB)/ `verify:unit`(42 套,退出码 0)/ 五个客户端守门(`verify-frame-guards` / `verify-frame-surface` / `verify-tw-origin` / `verify-wiki-focus` / `verify-client-i18n`)/ `verify-line-endings` 全绿。
59
- - **v0.30.37**(2026-09-29):**给 v0.30.36 补上缺的那条行为守门**(本会话第 5 次「规则必须有人执行」)。v0.30.36 修好了「未知变化 ⇒ 保守重启该仓库下全部库」,但那一支当时**只有类型系统间接约束、没有直接断言**。本版在 `verify-wiki-farm.mjs` 既有的 `affectedBy` 用例里补一条:`ids(undefined)` ⇒ **该仓库下全部库**,与紧邻的 `ids([])` ⇒ `[]`(没有改动就不重启)**成对**钉住 —— 两条并排才说明「未知」与「没有改动」是**可区分**的两种状态,而不只是各自成立。**反向验证(带 build)**:把 `undefined` 分支改成 `return []` ⇒ `WIKI FARM CHECKS FAILED`;恢复 ⇒ 绿。② 顺带记一条纪律:这一支的守门是**行为级**的(import 编译后的 `lib/`),所以反向验证必须「注入 → `npm run build` → 跑守门」——v0.30.35 时我漏了 build,得到过一次「看起来通过了」的假验证(详见 wiki 常见坑)。**验证**:`typecheck` / `build` / `verify:unit`(42 套,退出码 0)/ `verify-wiki-farm` / `verify-line-endings` 全绿。
60
- - **v0.30.36**(2026-09-29):**补完 v0.30.35 的另一半:未知变化 ⇒ 保守重启该仓库下的全部库**(同一个 `pull` 缺陷的收尾)。① v0.30.35 只做到「不再沉默」(回执警告),本版把**行为**也修正:`affectedBy(repoRoot, changedFiles)` 的第三参改为 `readonly string[] | undefined`,**`undefined`(diff 算不出来 ⇒ 不知道哪些文件变了)⇒ 重启该仓库下的全部 runtime**。宁愿多重启一个(代价是一次 TW 重启),也不能静默陈旧 —— 用户面前的 TW 是内存副本,磁盘被 pull 改过而没重启就会一直显示旧内容。② ⚠️ **谓词方向是个真陷阱,已写进代码注释**:受影响 = 文件落在库目录**里面**,所以正常分支是 `isInsidePath(runtime.path, filePath)`(第一个参数是容器);"全部库"必须用**反方向**的 `isInsidePath(repoRoot, runtime.path)`。**不能**拿一个假文件名(例如 `.`)去套正常分支 —— 那样问的是「repoRoot 是否在某个库目录里」,方向反了,**同仓多库里更深的那些库不会重启**(正是本版要覆盖的场景)。③ 签名链 5 处(`tools-support.ts` 的 `RestartAffected` 类型 · `routes.ts` 的路由依赖类型与调用点 · `tools-git.ts` 的局部类型与调用点 · `index.ts` 的两处 lambda · `index-wikis.ts` 的共享 helper · `wiki-farm.affectedBy`)由**编译器逐个点名**改齐,无一处靠猜;两个调用点统一写成 `pulled.changedFilesUnknown === true ? undefined : pulled.changedFiles ?? []`。④ **仍未加行为守门**:`undefined ⇒ 全部库` 这一支目前由**类型系统**(调用点必须处理 `| undefined`)与 `verify-repo-keying`(生产者三态)间接约束,**没有**直接断言"未知时重启了全部库"的行为测试 —— 要造一个多库同仓的 farm 实例,留给下一步。**验证**:`typecheck` / `build` / `verify:unit`(42 套,退出码 0)/ `verify-repo-keying` / `verify-git-multi-sync` / `verify-git-resolve` / `verify-line-endings` 全绿。
61
- - **v0.30.35**(2026-09-29):**修「`pull` 的 diff 失败被当成『没有变化』⇒ 不重启任何库」**(第 4 条裁定追到底的一处**真缺陷**,不是假阳性)。① **缺陷链**:`git.ts` 的 `filesChangedBetween()` 里 `if (!r.ok) return []` —— **「diff 算不出来」与「真的没有变化」返回同一个值**;`pullUnlocked` 只在 `changedFiles.length > 0` 时带该键、却仍返回 `changed: true`(HEAD 确实动了);两个调用点(`routes.ts` / `tools-git.ts`)又当 `?? []`,`wiki-farm.affectedBy([])` 返回 `[]` ⇒ **一个库都不重启**。后果:`pull` 成功把 HEAD 从 A 移到 B(磁盘内容**真的变了**),但 TW 子进程仍在内存里渲染**旧内容**,**而回执写着同步成功** —— 内容变了、服务没跟上、回执不说。② **本版修「不再沉默」那一半**(单文件可完成、无签名扩散):`filesChangedBetween` 返回类型改为 `Promise<string[] | undefined>`(**`undefined` = 答不出来**,`[]` = 确实没变化),`pullUnlocked` 据此带出 `changedFilesUnknown: true`,并**在回执 message 里显式警告**「已拉取到新提交,但无法确定哪些文件变化 —— 没有自动重启任何知识库,请手动重载 TW 面板」。③ **保守重启(未知 ⇒ 该仓库下全部库)需要动 `restartAffected` 的签名链**(`tools-support`/`routes`/`tools-git`/`wiki-farm` 四处 + 新守门):`affectedBy` 的谓词是 `isInsidePath(runtime.path, join(repoRoot, file))`,所以"全部库"**不能**用一个假文件名糊过去 —— 完整修法写在 wiki 缺陷笔记里,下一步执行。④ **守门升级为三态**(`verify-repo-keying.mjs`):`''`/同一 ref ⇒ `[]`;**无法解析的 ref ⇒ `undefined`**,并加 `assert.notDeepEqual(…, [])` 把「必须与『没有变化』可区分」钉死。⑤ ⚠️ **反向验证过程中又踩到同一族坑**:该守门是**行为级**的(import 编译后的 `lib/`),只改 TS 源码**不会**让它变红 —— 第一次注入因此"看起来通过了反向验证",是我的检查脚本自己抛错才拦住(否则就会记下一条假的"已验证")。正确姿势是**注入后先 `build`**。**反向验证(带 build)**:改回 `return []` ⇒ `REPO KEYING CHECKS FAILED`;恢复 ⇒ 绿。**验证**:`typecheck` / `build` / `verify:unit`(42 套,退出码 0)/ `verify-line-endings` 全绿。
62
- - **v0.30.34**(2026-09-29):**把三个「手动浏览器守门」写进文档**(第 4 条裁定的收尾,**行为零变化**)。核查「守门是否存在孤儿」时发现:`scripts/verify-*.mjs` 共 **56 个**,其中 **53 个**接在四条链(`verify:static` / `verify:unit` / `verify:e2e` / `verify:large`)里,剩下 3 个 **`verify-theme-browser.mjs` · `verify-clip-bridge-browser.mjs` · `verify-menubar-theme.mjs`** 是**有意**的手动浏览器 E2E(各自头部都写明「OPTIONAL dev tool / NOT part of CI:CI 上只会打 SKIP」)—— **不是死脚本**。但它们**在任何文档里都搜不到**,于是成了「规则存在、没人知道它存在」的一类:真出问题时不会有人想到去跑它。本版把三条命令连同**前置条件**(本机 Chrome/Edge + `puppeteer-core`;`verify-menubar-theme.mjs` 需要 `TW_URL` 指向**你自己**运行中的 wiki,且**故意没有默认值**)写进 `docs/development.md` 的命令清单,并标明「不在任何链里」。② 同一次核查还确认了两件事:**没有悬空的守门引用**;`docs/development.md` **早已写明** `npm run verify` 这个总闸(= static + unit + e2e + large)—— 也就是说「逐条手动拼链」是我的疏忽而非文档缺失(已把「发布前跑总闸,别自己拼」记进 wiki 交接笔记)。**验证**:`verify:static`(含文档链接 + 行尾)/ `verify:version-consistency` 全绿。
63
- - **v0.30.33**(2026-09-29):**`ConfigStore` 的写路径:核实后决定「不改」,并把理由写具体**(第 4 条裁定的收尾,**行为零变化**)。v0.30.30/31/32 之后剩下的唯一一处手拼 body 是 `config.ts` 的配置 tiddler,本版**读完全文再判**:它**已经先读后写**,且**按文本**做幂等跳过(`storedText === nextText`,v0.25.0 为「每次点保存都产出一条 git diff、两台机器因此冲突」而加)。改成 `buildWriteTiddler` 的唯一收益是「保留自定义字段」—— 而这在配置 tiddler 上**没有这个场景**;代价却是**若写策略对正文做过任何规范化,幂等判据就会与实际落盘的文本不一致 ⇒ 每次保存都重新 PUT ⇒ 那条 git-diff 回归复活**。**收益小、风险确切 ⇒ 不动。** 于是把守门里那句含糊的「改前需确认」换成这段具体理由 —— 否则下一个人(或下一次审计)会再问一遍同样的问题,甚至"顺手修好"它。**验证**:`verify-write-call-sites` / `verify:unit`(42 套)/ `verify:version-consistency` / `verify:line-endings` 全绿。
64
- - **v0.30.32**(2026-09-29):**给「共享写策略」补上调用点守门**(第 4 条裁定的收尾,把 v0.30.31 写下的规则变成可执行断言)。新增 `scripts/verify-write-call-sites.mjs`(进 `verify:unit`):钉的形态只有一种 —— **`client.put(` 后面直接跟对象字面量 `{`**(=手拼 body,TW 的整体替换会静默丢自定义字段);`const { tiddler } = buildWriteTiddler(…)` 之后 `client.put(tiddler)` **不算**(那就是共享写策略本身 —— 审计曾把它误算成「绕过」)。② 三条断言:**(a)** 手拼 body 的调用点必须全部落在**例外清单**里;**(b)** 清单**自清洁**(列了却已合规 ⇒ 红);**(c)** 清单每项必须写明 `why`。③ 守门**第一次运行就抓到两件事**:`src/host/routes-helpers.ts` 有一处我没数到的手拼 body(TW **草稿**写入 —— 草稿必须显式构造、**不能**以原条目为基底,否则会把笔记的 tags/自定义字段复制进草稿;已作为「特例」连同理由入清单),以及**我自己的 `index-clip.ts` 清单条目是过期的**(那里根本没有手拼 body ⇒ 自清洁断言当场把我抓出来)。④ 现状:手拼 body 共 **18 个文件** —— 7 个 seed 家族(内置内容)+ flush 探针 / `$:/temp` 汇总 / 配置 tiddler / 草稿,**全部有书面理由**。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(42 套,含新守门)全绿。
65
- - **v0.30.31**(2026-09-29):**把「共享写策略」的例外清单写进代码与铁律**(第 4 条裁定的收尾,**行为零变化**)。① `write-policy.ts` 模块头新增一节「本策略的例外清单」:先澄清一个**曾被误报的口径** —— `const { tiddler } = buildWriteTiddler(…)` 之后跟 `client.put(tiddler)` **就是本策略本身**(规则要求的就是走 `buildWriteTiddler`),第二轮审计把它算成了绕过,于是「21 个文件绕过共享写策略」这个数字**大半是假的**;再列出真正允许手拼 body 的**两类**:**(1) 我们完全拥有内容**的条目(seed marker / `$:/temp` 会话汇总 / 剪藏写入 / seed 的内置内容)—— 读一遍没有信息量;**(2) flush 探针**(唯一用途是给一次写入计时并轮询落盘,改读-改-写会引入额外往返、**改变时序语义** —— 这条不是偷懒,是必须)。② `AGENTS.md` 铁律 #2 同步这一句,并写明判据:**这次 PUT 的 body 是不是基于某条已存在的用户条目改写的**(是 ⇒ 必须先 `get` 再 `buildWriteTiddler()`),**不是**文件名或行数。③ 为什么值得单独一版:这条规则此前是「绝对规则 + 隐性例外」,于是**每一次审计都会重新发现同一个「问题」**(本会话就重报了两次)。把例外写成可读的清单后,下次审计的结论只剩「清单里没有的新调用点」——那才是真缺陷。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套)全绿(纯注释与文档,`lib/` 行为不变)。
66
- - **v0.30.30**(2026-09-29):**`$:/language` 的条件写入改走共享写策略**(第 4 条裁定「写路径例外清单」分类后的**第一处真修复**)。`pinLanguageTiddler()` 此前**已经** `get` 过那条目(v0.24.2 为「内容没变就别 PUT」加的),却仍手拼 body:`client.put({ title: '$:/language', text: desired, type: 'text/plain', tags: [] })` —— 手拼就是铁律 #2 说的那件事:**整体替换会丢掉这条目的自定义字段**(`tags: []` 还会顺手清空标签)。改为 `buildWriteTiddler('$:/language', desired, { existing, fields: { type: 'text/plain' } })`:以已读到的条目为基底、显式保住 `text/plain`、自定义字段原样保留;`$:/language` 是系统条目,`buildWriteTiddler` 不会给它补 `agent-written`(`finalTagsForWrite` 对 `$:/` 前缀直接跳过)。**行为不变**:`verify-language-pin.mjs`(行为级,断言「内容相同只读不写」「内容不同才写」的调用序列)照旧全绿。② 顺带记录本轮的分类结论(见 wiki 待办笔记):先前「21 个文件绕过 `buildWriteTiddler`」的**口径是错的** —— `const { tiddler } = buildWriteTiddler(...)` 之后跟一句 `client.put(tiddler)` **就是**共享写策略本身,被我算成了绕过。按「body 是天生的还是手拼的」重新分类后:**不是违规 2 处、有意例外约 17 处(seed marker / flush 探针 / `$:/temp` 会话汇总 / 剪藏写入 / seed 家族)、真候选只剩 2 处** —— 本版修掉其中一处(另一处是 `config.ts` 的配置 tiddler,`ConfigStore` 有自己的合并逻辑,改前需确认语义不冲突)。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套)/ `verify-language-pin` 全绿。
67
- - **v0.30.29**(2026-09-29):**`wechat-publish.ts` 拆出「契约层」**(第 5 条裁定继续,**纯搬迁**)—— 也就是上一轮记为「会成运行期环、需先定方案」的那个文件的落地。① 新增 `wechat-publish-contract.ts`(275 行):适配器清单与各自的产物文件、DSN/Cli 值的合法性判定、配置形状与归一化、命令行调用怎么拼、输出怎么判成败、任务视图类型。`wechat-publish.ts` **700 → 450 行**,只剩有状态的 `WechatPublishRunner`(子进程、任务表、轮询)。② **先核实再动手**:`scripts/verify-wechat-publish.mjs` **按精确文件**读 `wechat-publish.ts` 并断言两条**赋值语句**的字面量(`export const WECHAT_ADAPTER_MARKER = 'resolveNoteTitle'`、`const WECHAT_TITLE_FILE_ARG = 'titleFile'` —— 它们是「入口版本哨兵」,真入口声明的参数名/函数名必须与之对齐)。核实后发现这两个符号**只被 base 里的 `WECHAT_FRESHNESS_MATCHERS` 用到**,且搬走的那段**一次都没引用**它们 ⇒ 把它们连同那条匹配器留在 base 即可,**既不动守门、也不造环**。这正是上一轮结论里 (a) 那一支:**一条 grep 就把「环」的担心消掉了**。③ 搬迁脚本这轮又暴露两个真实的生成器缺陷(都值得记):**(a) 别名 import** —— `import { spawn as nodeSpawn }` 的 specifier 整体是 `spawn as nodeSpawn`,而代码里出现的是**局部名** `nodeSpawn`,于是被「按名字过滤」滤掉了;判据要取 ` as ` **之后**的名字。**(b) 非 export 的声明** —— 原文件里 `const WECHAT_FLOW_FILE`、`const JOB_HISTORY_LIMIT`、`interface JobRecord` 本来不是导出的,搬进新文件后需要变成 `export`(否则新文件里「声明未使用」= 红),而 base 的 import 清单是**按 `export ...` 正则**扫出来的 ⇒ 漏掉这三个。修法是**先让新文件成稿,再从它的「实际导出」反推 base 的 import/re-export 清单**(而不是只信一次性的预扫描)。④ 另一处必须**排除**的范围:`WechatPublishRunner` 的 doc 注释(`/** … */`)属于类、不属于被搬的代码 —— 按行硬切会把它劈成两半,症状是 `'*/' expected` 与 `Unterminated regular expression literal` 这种「看起来像语法错、实则是范围错」的报错。⑤ 证据:逐行 `Compare-Object` ⇒ **零代码缺失**(唯一三条「只在旧文件里」的行就是被有意加上 `export` 的那三行);`WECHAT PUBLISH OK` + `WECHAT ADAPTER CHECKS OK`。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套)/ `verify:e2e` 全绿。
68
- - **v0.30.28**(2026-09-29):**`tw-frame.ts` 拆出「公共 API 层」**(第 5 条裁定继续,**纯搬迁**)。① 新增 `src/client/tw-frame-api.ts`(107 行):别的模块要用 tw-frame 的那一套 —— 两个事件名(`ACTIVATE_EVENT`/`PANEL_RELOAD_EVENT`)、`reloadTwSurfaces()`、标签文字(`setTabLabel`/`getTabLabel`)、`requestRestart()`、`TwTabIcon`、以及「这份 dataset 能不能直接当 iframe src 用」的 `loadableFrameUrl()` + `FrameLoadedState`。`tw-frame.ts` **736 → 652 行**,并把这些名字**原样再导出**(外部导入路径一个不变)。② **先证明了无环才动手**:脚本扫过这段代码引用的每个标识符,确认它**只引用自己人**、不引用文件后半部分的任何东西(`TwFrameSkin`/`createTwFrameSurface`/`liveFrames` 一个都没出现)—— 与 v0.30.27 那次相反,这里**不需要**让 base 反向 import,所以搬得干净。③ **两处 `tabLabel` 直接读改成 `getTabLabel()`**:内核里原来写的是 `setTabLabel(payload.ui.tabLabel ?? tabLabel)`,直接摸模块级变量;搬走后 base 不能再碰它,改用公开的 accessor(同一份状态、语义完全等价)。这与 v0.30.27 的教训是同一类:**搬迁会逼出「谁有权碰这份状态」这个问题**,答案是 accessor 而不是把状态留在原地。④ 守门同步:`scripts/verify-tw-origin.mjs` 原本钉死字面量 `import { RESTART_ENDPOINT, resolveTwUrl, twProxyFor } from './endpoints.ts'`,而 `RESTART_ENDPOINT` 跟着 `requestRestart` 搬走了 —— 判据改成「族里**从 endpoints.ts 取到了**这两个必需符号」+ 单独钉住 `RESTART_ENDPOINT` 仍在族内(意图不变:**该拿到的符号一个都不能少**,但不该锁定某一行的写法)。⑤ 证据:逐行 `Compare-Object` ⇒ 只有 3 行差异且**全是有意改动**(2 处 `?? tabLabel` → `?? getTabLabel()` + 那条 import);客户端 bundle 768227 → 768231 字节(仍在 1MB 限制内、仍 minify)。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套)/ `verify:e2e` 全绿;客户端四个守门(`verify-frame-guards` / `verify-frame-surface` / `verify-tw-origin` / `verify-wiki-focus` / `verify-client-i18n`)亦绿。
69
- - **v0.30.27**(2026-09-29):**`seeds.ts` 拆出「注册表装配层」**(第 5 条裁定继续,**纯搬迁**)。① 新增 `seeds-registry.ts`(422 行):`SEED_DEFS` 那张表 + 装配它的全部东西(`defineSeed()`、`inspectSeedContent`、`refreshSeedMarker`、`SeedMeta`/`SeedWriter`/`SeedContent` 类型,以及三个小判定 `presentOf`/`removedDetail`/`isOurProxyBase`)。`seeds.ts` **657 → 262 行**,只剩**编排**(`checkAllSeeds` / `runSeedById` / `runAllSeeds` / `removeSeedById` + 接口 + 排干原语的再导出)。② **分界线是「策略 vs 编排」**:此前想回答「哪些 seed 会被自动写、各自的 gate 是什么」得先翻过几百行实现;现在一张表一个文件。③ **依赖方向单向**:base 从注册表取 `SEED_DEFS`,注册表用 `import type` 拿 base 的三个类型(`SeedContext`/`SeedDef`/`SeedRunResult`/`SeedStatus`)——类型期闭环、运行期无环。注册表的值依赖全部来自**其它**模块(`seed-util` / `config` / `wiki` / 12 个 `seed-*`),所以 `isOurProxyBase` 必须**跟着搬**(它被 `defineSeed` 的实现用到),否则会造出「注册表 → base → 注册表」的真环。`seeds.ts` 原样再导出 `SEED_DEFS` 与 `isOurProxyBase`,**外部导入路径一个都不变**。④ **搬迁手法升级为「编译器驱动」**:脚本解析 base 自己的 import 语句、按「这段代码里到底用了哪些名字」过滤,逐个模块重生成注册表的 import 行;base 侧则由 `tsc` 的 `noUnusedLocals` 点名后**只删「确实是 import 声明」的行**(`TS6192/TS6133` 也可能指向未用变量,所以删除前逐行核对前缀)——15 行一次删净,零猜测。⑤ 顺手把 `scripts/verify-wechat-docs-seed.mjs` 从「按精确文件名读 `seeds.ts`」改成 `readFamily(repoRoot, 'src/host/seeds')`(拆分后它读不到注册表 ⇒ 稳定失败在那个点,正是本仓库「守门按模块族读」的由来)。⑥ 证据:逐行 `Compare-Object` ⇒ **零代码缺失**(唯一两条「只在旧文件里」的行就是被有意加上 `export` 前缀的那两行)。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套)/ `verify:e2e` 全绿,`verify-seed-error-policy` / `verify-restart-drain` / `verify-mutation-lock` 亦绿。
70
- - **v0.30.26**(2026-09-29):**补上 v0.30.22 漏掉的用户文档**(纯文档,无代码改动)。`docs/usage.md` 的工具表仍写着 `tiddlywiki_attach` 存「二进制附件」,而 v0.30.22 之后文本类(`text/*`、`application/json`)存的是**纯文本正文**。这一行的措辞直接决定用户(和模型)对「我 attach 一个 .md 之后能不能搜到它」的预期,所以按实际行为改写(二进制 → base64;文本 → 纯文本 ⇒ 进检索)。顺带核对 `docs/features.md` / `usage.md` 其余关于附件与 base64 的说法:剪藏桥的图片、以及「检索跳过二进制附件」那两条描述**依然准确**(文本附件现在正确地参与检索,正是 v0.30.22 的意图)。**验证**:`verify:static`(含 `verify-doc-links`)全绿。
71
- - **v0.30.25**(2026-09-29):**`admin-routes.ts` 拆出最后两条业务路由**(`config` + `prompt`),本文件终于成为**纯组装点**(第 5 条裁定的继续,**纯搬迁** + 工厂化)。① 新增 `admin-routes-config.ts`(131 行):`POST /admin/config`(保存配置补丁)与 `GET|POST /admin/prompt`(提示词预览,POST 带草稿且不落盘)收进 `createAdminConfigRoutes(deps, helpers)`。base **358 → 272 行**(本文件累计 **746 → 272**),现在只剩 `AdminDeps` 与名册接口、两个闭包 helper、四个工厂调用、注册表 —— 与 `routes.ts` 同一种「组装点」形态。② **依赖面这次是「按代码生成」而不是手抄**:脚本在切出的两段代码里扫 `deps.X`,据此拼出 `Pick<AdminDeps,…>` 与调用点成员(结果是 `config` / `getClient` / `getPrompt` / `onConfigChanged` 四项)。③ **两次自我纠错值得记下来**:**(a)** 第一次切 `prompt` 时我把范围切宽了,**把相邻的 `createAdminWikiRoutes(...)` 工厂调用一起卷进了新文件** —— 症状是新文件里出现 `Cannot find name 'createAdminWikiRoutes'`,而 base 静默少了那次调用。根因是我按「`handlePrompt` 之后的下一条注释」猜终点,而中间还夹着两次工厂调用;改成**按内容找终点**(找到 `handlePrompt` 的收尾 `}`,并断言它确实是 ` }`)后一次通过。**(b)** 重做前我漏了 `git checkout` 恢复 base,于是脚本在**上一轮的破损状态**上又切了一刀(base 511 行、新文件 349 行全是垃圾)。补上「行数必须是 358、终点行必须是 ` }`」这类**前置断言**之后,坏状态再也无法静默产出垃圾 —— **搬迁脚本必须自带前置断言**,这是本轮最实在的一条教训。④ 证据:逐行 `Compare-Object` ⇒ **零代码缺失**,唯一三条「只在旧文件里」的行就是**被有意改写的那三条 import**。⑤ `verify-restart-drain` / `verify-mutation-lock` / `verify-config-patch` / `selftest` / `verify:e2e` 全绿(config 那一组会拿变更锁并热应用 git 配置)。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套)/ `verify:e2e` 全绿。
72
- - **v0.30.24**(2026-09-29):**`admin-routes.ts` 再拆出「状态 / 插件·主题·语言」那一组**(第 5 条裁定的继续,**纯搬迁** + 工厂化)。① 新增 `admin-routes-info.ts`(224 行):`GET /admin/state`(设置页总览)与 `POST /admin/info`(写 `tiddlywiki.info`,必要时重启 TW)收进 `createAdminInfoRoutes(deps, helpers)` —— 依赖面 `Pick<AdminDeps,'config'|'getClient'|'getWikiPath'|'server'|'targetProblem'|'twRoot'>` 加 base 的闭包 helper `refuseStoppedTarget`。base **535 → 358 行**(本文件累计 746 → 358)。② 搬迁过程中 **tsc 替我发现两件值得记的事**:**(a)** 这一组里的 `handleInfo` **也**调 `drainThenStop` —— 也就是说「会 drain + 重启子进程的 admin 路由」**不止 seeds 那一组**,新文件同样落在铁律 #1 的守门范围内(`verify-restart-drain.mjs` / `verify-mutation-lock.mjs` 都按模块族读 admin,搬迁后照旧绿,但我把这条写进了新文件的模块头);**(b)** 顺着 `noUnusedLocals` 精确清掉了三条**只被这一组使用**的 import(`redactLogLines` / `redactRemoteUrl` 从 routes.ts、整条 `admin-catalog` 导入)与一个多余的 helper 参数 —— 这次**没有靠猜**:先搬、再让编译器点名、再删,删错会立刻红。③ 证据:HEAD 全文非空行 vs「base + 新模块」逐行 `Compare-Object` ⇒ **零代码缺失**(唯一两条「只在旧文件里」的行就是**被有意改写的那两条 import**)。④ 行为侧由 `selftest`(真实打 `/admin/state`)与 `verify:e2e` 覆盖。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套)/ `verify:e2e` 全绿。
73
- - **v0.30.23**(2026-09-29):**把一条审计假阳性就地钉死**(第 4 条裁定的核对环节,**行为零变化**)。第二轮审计把 `session-scope.ts` 的两条正则记成「sessionId 正则 ×2(冗余实现)」,核实后是**假阳性**:`isSafeSessionId` 校验的是 **DSH 生成的**会话 id(实测允许大写与 `:`,上限 120),`isSafeWikiId` 校验的是**我们自己造的**库 slug(只允许小写,上限 64)—— 两条看着像、语义不同。合并的两种后果都是真故障:按 wiki 的规则收紧会**拒掉合法会话 id**(作用域静默丢失),按会话的规则放宽会**放行非法库 id**(报错点离现场很远,得等 registry 兜底)。所以本轮不动代码行为,只在两处就地写明「故意不同、别合并」并注明这条审计是假阳性 —— **判据要落在下一次会读到它的地方**,而不是只写在审计笔记里(否则同一个假警报会周期性回来)。**验证**:`typecheck` / `build` / `verify-session-scope` / `verify-line-endings` 全绿。
74
- - **v0.30.22**(2026-09-29):**修 `tiddlywiki_attach` 把文本文件存成 base64**(第二轮审计的遗留项,与 v0.23.5 那条「别把二进制交给 `put` 写文本」正好互为镜像)。旧实现无条件 `buffer.toString('base64')`,于是 attach 一个 `.md` / `.txt` / `.json` 会得到 `type: text/markdown` + **一整段 base64**:TW 把那段 base64 当**正文**渲染(满屏乱码),而 `isBinaryType('text/markdown')` 又是 `false`,所以它还会被算成「文本条目」进检索与 lint —— 搜出来同样是乱码。现在按 TW 自己的约定分流:`text/*` 与 `application/json` 存**纯文本正文**(且只接受能**原样往返**的 UTF-8;不能往返的例如 latin-1 `.txt` 退回 base64),其余照旧 base64。`AttachResult` 新增 `encoding: 'text' | 'base64'`、`chars` 改为「实际存进 tiddler 的正文长度」,回执据此如实写「纯文本 N 字符 / base64 约 N 字符」;工具 schema 的 description 也同步说明(原文里「同名已有条目默认拒绝写入」那句一字未动 —— `verify-prompt.mjs` 会断言它)。**已反向验证**:把分流条件写死为 false(=旧行为)⇒ `selftest` 稳定失败在「文本附件按纯文本存(text/markdown/base64)」;恢复 ⇒ 通过。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套,含 `verify-prompt` 1011 字符 slim)/ `verify:e2e` 全绿。
75
- - **v0.30.21**(2026-09-29):**修掉剪藏桥「同一秒内连剪两次会撞名」**(第二轮审计的遗留项)。`resolveClipTitle` 的去重顺序是 `base` → `base(2)…(20)` → **时间戳兜底**,而兜底只精确到**秒**(`toISOString().slice(0, 19)`)。走到兜底意味着这一页已经剪过 **20+ 次** —— 也就是用户正在**批量重剪**,此时「同一秒里剪两次」(连点书签、或浏览器把书签跑了两遍)不是边角情况而是常态:两次算出**同一个标题**,第二次直接撞上刚写下的那篇。现在兜底带**毫秒**,且对每个候选名仍然**真的问一次 `exists`**(同一毫秒内继续加 `-<n>` 序号)—— 不再因为「时间戳不同了」就假设名字空着;`now` 做成可注入参数,让回归测试能钉住同一毫秒、仍然观察到两个不同的标题。**已反向验证**:把毫秒改回秒 + 去掉候选探测 ⇒ 新用例稳定变红;恢复 ⇒ 绿。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套,含 `verify-clip-bridge`)/ `verify:e2e` 全绿。
76
- - **v0.30.20**(2026-09-29):**`admin-routes.ts` 再拆出「一次性预置(seed)」那一组**(第 5 条裁定的继续,**纯搬迁** + 工厂化)。① 新增 `admin-routes-seeds.ts`(142 行):`GET /admin/seeds`、`POST /admin/seeds/run`、`POST /admin/seeds/remove` 收进 `createAdminSeedRoutes(deps, helpers)` —— 依赖面 `Pick<AdminDeps,'getClient'|'getWikiPath'|'mutationLock'|'seeds'|'server'>` 加 base 的闭包 helper `notRunning`,与 `createNoteRoutes(deps, helpers)` 同形状。base **632 → 535 行**(本轮累计 746 → 535)。② **为什么这一组值得单独出去**:`/seeds/run` 是全仓库**唯一**「写完就 drain + 重启 TW」的 admin 路由(seed 里的 server-route 插件只在 TW 启动时加载),于是它同时牵着两条铁律 —— 排干必须经 `drainThenStop`(铁律 #1)、这一段必须持有共享变更锁(v0.30.12)。单独成文件后,「谁能在 admin 侧停子进程」只有一处可读。③ 顺带清掉三条因此变成**未使用**的 import(`waitForFileWrite` / `needsRestartAfterSeeds` / `RENDER_PLUGIN_FILE` 只在这一组里用)—— `noUnusedLocals` 会替我们确认没删错:`drainThenStop` 仍被 base 的另外两处用着,所以保留。④ 证据:HEAD 全文非空行 vs「base + 新模块」逐行 `Compare-Object` ⇒ **零缺失**(唯一「只在旧文件里」的行是那条被有意拆成两条的 import)。⑤ 这一组的两条铁律守门 `verify-mutation-lock.mjs` / `verify-restart-drain.mjs` 都按**模块族**读 admin(`admin-*.ts`),搬迁后照旧绿;行为侧由 `selftest`(seeds 的 run/remove 断言)与 `verify:e2e` 的 `verify-seeds-admin.mjs` 覆盖。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套)/ `verify:e2e` 全绿。
77
- - **v0.30.19**(2026-09-29):**`admin-routes.ts`(746 行)拆出「知识库列表 / 位置」那一组**(第 5 条裁定的继续,**纯搬迁** + 工厂化)。① 新增 `admin-routes-wikis.ts`(145 行):`/admin/wikis`(GET 读控制文件、POST 增删改 / 设默认 / 切模式)与 `/admin/wiki/{location,switch,reset}` 四条 handler 收进 `createAdminWikiRoutes(deps)`,依赖面用 `Pick<AdminDeps,'server'|'wiki'|'wikis'>` 写明 —— **一个闭包 helper 都不需要**。base **746 → 632 行**,注册表(那 12 行 `webServer.register`)一行未改。② 为什么按这一组切:它们回答的是「插件现在服务哪个目录 / 控制文件里有什么」,与 `/admin/{state,info,config,seeds}` 那组(改**当前库**的配置)是两件事;而且它们**不吃 `?wiki=`** —— 请求问的就是「哪个库」本身,所以这一组也不需要 `refuseStoppedTarget`(搬迁时顺手把这条写进注释,免得下一个人以为漏了)。③ **「什么都没丢」的证据**:HEAD 版全文的非空行 vs「base + 新模块」的非空行逐行 `Compare-Object` ⇒ **零缺失**,多出的 28 行全部是模块头注释、3 行 import、工厂签名与类型、调用点、返回语句。④ 被搬走的路由由 `verify:e2e` 真跑覆盖(`WIKI FARM BOOT` 打 `/admin/wikis`、`WIKI SWITCH` 打 `/admin/wiki/{switch,reset}`),均绿。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套)/ `verify:e2e` 全绿。
78
- - **v0.30.18**(2026-09-29):**`src/host/session-summary.ts`(633 行)拆出扫描层**(第 5 条裁定的继续,**纯搬迁**)。① 新增 `session-summary-scan.ts`(239 行):`NOTE_TOOL_NAMES` + 两个扫描上限(`MAX_SESSIONS` / `MAX_SEARCH_RECORDS`)+ 六个只与「读事件日志」有关的函数(`collectDescendantIds` / `isPlausibleTitle` / `record` / `trimTrailingClosers` / `scanRefs` / `scanSnapshot`)。base **633 → 416 行**,只剩编排(`writeSessionSummary`)与探测 / 格式化 / wikitext 三段。② **顺手纠正速查表里的一个错**:那条「仍未拆」清单里的 `session-summary.ts`(633) 指的是**宿主**这一个,客户端有个**同名**文件只有 430 行、是另一件事(会话「知识库」Tab 的视图)—— 本轮把宿主这个拆完,并顺带按实测更新清单里的行数(`tw-frame.ts` 718 → **736**)。③ **搬迁纪律**:类型用 `import type` 回 base(类型期闭环、运行期无环,本仓库既有约定),而**值**(两个上限常量)**搬进扫描层**、让 base 反过来 import —— 依赖只有一个方向,不制造运行期环。④ **"什么都没丢"的证据**:把 HEAD 版全文的非空行与「base + 新模块」的非空行(剥掉 `export ` 前缀后)**逐行比对**,结果只差新加的模块头注释与两行 import、**零缺失**(`Compare-Object` 输出 19 行全是我新加的);行为证据是 `selftest` 里真的跑了一遍 `/session/summary`(`produced:5, read:3, searches:2, sessions:3`)照旧。⑤ 过程里踩了自己的坑:`[IO.File]::WriteAllLines` 在 Windows 上写 **CRLF**,被刚落地一天的行尾守门当场抓住(先红后归一)—— 那条新守门第一次真的拦住了东西。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套)/ `verify:e2e` 全绿。
79
- - **v0.30.17**(2026-09-29):**公众号三条路由补上「已登记但没在跑 → 503 点名」+ 铁律「行尾必须 LF」终于有了执行者**。① `/wechat/{ready,publish,publish/status}` 此前是**唯一**不吃 `?wiki=` 目标检查的路由,而 `wechatConfig(req)` / `wechatReady(req)` 都是**按请求上那个库**取值的 —— 于是设置页写着「正在配置 books」、公众号的就绪状态与配置却来自**默认库**,而且看起来一切正常。现在三条都在 `guardWechat` **之后**补上 `refuseStoppedTarget`(先认证/开关、再判目标;这个顺序由 `verify-wechat-publish.mjs` 钉死的「先判方法+同源、再判开关/token」相邻性决定),`createWechatRoutes` 与 `createNoteRoutes` 一样收到闭包 helper。② **过程中揪出一处静默的仓库不一致**:`src/host/routes-wechat.ts` 的**工作区**副本是 CRLF,而 git blob 是 LF —— `.gitattributes` 会在 commit 时把 CRLF 归一成 LF,于是 `git status` / `git diff` **完全看不到**这个状态。它当天的实际后果是:我用 `[IO.File]::WriteAllText` 做的一次**反向验证注入没匹配上、变成了空操作**,守门照样绿 —— 差一点被读成「断言不生效」而去改一条本来正确的断言。③ 所以顺手把这条铁律变成**有执行者**的规则:新增 `scripts/verify-line-endings.mjs`(进 `verify:static`),扫 `src/` `scripts/` `tools/` `docs/` `lib/` 与根目录共 **244** 个文本文件,任何 CRLF 直接红;并带一条反向保险「扫到的文件数 < 50 就报错」,免得目录名/白名单写错时它「零文件、零违规、全绿」(本仓库管这叫守门在空转)。**已反向验证**:给一个源文件注入 CRLF → 红;恢复 → 绿。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套)/ `verify:e2e` 全绿。
80
- - **v0.30.16**(2026-09-29):**数值配置项的「同一组数字」收成一条通用守门**(第 4 条裁定的继续,接 v0.30.15)。v0.30.15 只钉住了 `startup.readyTimeoutMs` 一处;这次发现**每个**数值配置项都患同一个病:数字散在**两到三处**、各自决定不同的事 —— 宿主 `config.ts` 的 `NUMBER_CONFIG_RANGES`(保存时**夹取**到哪)、客户端设置页表单的 `min`/`max`(**允许填**什么)、以及表单在「还没配置过」时回显的默认值。只改一处就会重现本仓库反复记录的那个症状「**设置页显示的值 ≠ 真正生效的值**」,而且**没有任何报错**(夹取是静默的、回显是照抄的)。新增 `scripts/verify-config-ranges.mjs`(进 `verify:unit`),刻意只做**数字比对**:① 两侧**键集合必须完全相同**(新加一个可夹取路径却没给表单字段,或反过来);② 每个键的 `[min, max]` 数值相同;③ 默认回显 == 宿主 `DEFAULTS` 的默认值。宿主侧允许写**导出常量**(`READY_TIMEOUT_MIN_MS`、`CLIP_BRIDGE_DEFAULT_PORT`),守门会把常量解析成数值 —— 它鼓励「只有一处定义」,而不是把常量逼回字面量。解析上刻意避开两个坑:取 `DEFAULTS` 的体用**花括号配对**(同名的**类型声明** `port: number` 会被普通正则先匹配到,于是拿「类型」当「默认值」比、还比得过),以及强断言解析出的字段数 ≥ 4(解析器坏了必须**响亮**,否则就是「以为在守、其实没守」)。**已反向验证三种注入**:表单区间漂移 → 红;表单默认回显漂移 → 红;宿主夹取表漂移 → 红;恢复 → 绿。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(41 套)全绿。
81
- - **v0.30.15**(2026-09-29):**就绪窗口的上下界收成「唯一来源」+ 一条反向验证过的守门**(第 4 条裁定「该清理的就清理」的继续)。`startup.readyTimeoutMs` 的 `5000–600000` 此前在**三处**各写一遍:`ready-policy.ts` 的导出常量(权威)、`config.ts` 的 `NUMBER_CONFIG_RANGES` 夹取表、客户端设置页表单的 `min/max`(外加一份默认回显 `60000`)。三者分别决定「宿主怎么夹」「表单允许填什么」,只改一处就会重演本仓库反复记录的那个症状——**设置页显示的值 ≠ 真正生效的值**。现在宿主夹取表**直接引用** `READY_TIMEOUT_MIN_MS` / `READY_TIMEOUT_MAX_MS`(宿主侧从此不可能漂,且 `config.ts → ready-policy.ts` 无环);客户端进不了 host 模块(两份 bundle 分开构建),所以改成**钉数值**:`scripts/verify-ready-policy.mjs` 新增一条断言,把表单的 `min` / `max` / 默认回显分别与宿主三个常量**数值比较**,并断言宿主夹取表必须引用常量而不是写死数字。**已反向验证**:把表单的 `max` 改成 `600_001` → 红;恢复 → 绿。**验证**:`typecheck` / `build` / `selftest` / `verify:unit`(40 套)全绿。
82
- - **v0.30.14**(2026-09-29):**客户端 i18n 收官 + `tool-views.ts` 拆分 + 设置页重启后顺手重载面板**(第 3/5 条裁定的收束,外加作者当天追加的一条)。① **i18n 全量落地**:本轮转完最后三个文件并补齐接线 —— 工具卡 `tool-views.ts`(49 条 `card.*` 键进此前空着的 `i18n-card.ts`)、设置页插件/主题/语言/初始化 `settings-page-catalog.ts`(60 条 `settings.*` 键)、常规配置 `settings-page-config.ts`(73 条 `config.*` 键)。目录 **283 → 467 键 / 7 个分片**,守门的 `PENDING_CONVERSION` **清空** —— `src/client/` 下不再有未转换的用户可见中文(机制保留,防止下一处新界面又直接写中文)。工具卡的 15 个徽标改成**惰性取值**(`TOOL_LABELS` 存 `() => t('card.toolX')`),模块级常量不再把 bundle 加载那一刻的语言永久冻住。② **`tool-views.ts` 940 行 → 7 个模块 + 组装口**:`tool-views-{types,fetch,shell,body,search,misc,links}.ts`(类型与参数解码 / 取数·`useAsync`·正文 LRU 缓存 / 卡片外壳与知识库作用域 / 原生渲染正文卡与列表原语 / 检索·最近·批量卡 / 附件·标签·git·删除卡 / TW 代理路径判定与链接拦截器),`tool-views.ts` 只剩调度器 + `TOOL_VIEW_KEYS` 注册并原样 re-export 公共面(`index.ts` 的 import 一字未改)。③ **设置页「重启 TW」成功后顺手重载面板**(作者 2026-09-29 追加要求):新增 `tw-frame.ts` 的 `reloadTwSurfaces()`,把此前只有 FAB 菜单在用的 `PANEL_RELOAD_EVENT` 收成一个具名出口;四条**会就地重启 TW** 的路径(状态行「重启 TW」+ 插件/主题/语言三个「应用…(重启 TW)」)在成功回执后调用它,并 `invalidateStatus()` 丢掉旧的 `/status` 探针缓存 —— 此前面板会一直显示重启前那份文档,而「重新载入」只藏在 FAB 菜单里。④ **守门**:三个**按精确文件名**读源码的守门改读模块族(`verify-tool-views` 的措辞断言、`verify-render-sanitizer` 的两条、`verify-wiki-focus` 的卡片断言),措辞类断言统一改成仓库既有的「目录里有该文案 + 代码里有 `t()` 调用点」形状(`verify-plugin-runtime.mjs` 同办),并新增一条把「`tool-views-fetch.ts` 必须经 `render-fetch.ts` 取渲染片段」钉死 —— 补上模块族带出的那条新缝隙。⑤ **修掉一个守门自身的解析 bug(当天真踩了两次)**:`verify-client-i18n.mjs` 的 `stripComments()` 先剥块注释、后剥行注释,于是**行注释里**出现「块注释起始符」(例如 `/wechat/` 后面跟一个 `*`)会一路吞到下一个结束符之前的**真实代码** —— 14 个正常键被误判成「从未使用」;现在**先剥行注释**,并把这条坑写进函数头(第二次是给这个函数写说明时,说明文字里又出现了结束符,直接把脚本写成 SyntaxError)。⑥ 顺带把 `ui.tabLabel` 的空值兜底改成 `t('frame.tabLabelDefault')`(zh 逐字不变,en 下显示 "Knowledge base")。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(含 i18n 守门 467/467)/ `verify:e2e` 全绿。
83
- - **v0.30.13**(2026-09-29):**客户端 i18n 收尾(第 3 条裁定,283 键 / 20 个文件已转,只剩 3 个)**。本轮按「串行手工、每步守门全绿」的纪律一次转完 8 个文件:**note-widget.ts**(卡片与对话框的全部文案:标题/占位符/「写入」目标/最近列表/上传/保存与冲突提示,共 40 条新键)· `note-widget-draft.ts`(相对时间)· `note-widget-upload.ts`(上传 toast)· `editor-popup.ts`(原生编辑器浮层:标题、关闭、拖拽提示、aria-label)· `knowledge-fab.ts`(FAB 菜单:状态行 / 悬停 tip / 多库分组 / 打开·收起面板 / 重载 / 同步 / 快速笔记)· `quick-note-dock.ts`(按钮标签与三种悬停 tip)· `sidebar-entry.ts`(每行的 aria-label)· `tw-frame.ts`(中央面板与右栏的 TW 内核状态行:不可用 / 启动中 / 重试 / 重启中 / 「正在载入知识库『X』…」/ `/status` 不可达 / 状态码)· `wiki-scope-dock.ts`(会话选择器:标签、aria、悬停说明、「(默认)」「(未运行,选中会启动)」身份标记、切换失败)· `session-summary.ts`(会话「知识库」Tab 的空/错/降级态与刷新按钮)。① **顺手修掉三处语言相关的真缺陷**:**(a) 最近列表的日期兜底**硬编码 `toLocaleDateString('zh-CN')` ⇒ 英文界面下最近列表出现中英混排,现在跟随 `getLang()`;**(b) `tw-frame.ts` 的 `tabLabel`** 是模块级 `let tabLabel = '知识库'` —— 若照搬 `t()` 到模块作用域,会把「bundle 加载那一刻缓存的语言」永久冻住(换语言再也不生效);现在默认值**延迟**到 `getTabLabel()` 里求值(`tabLabel ?? t('frame.tabLabelDefault')`,`setTabLabel` 接受 `undefined`),`ui-config.ts` 那处模块级 `t()` 也一并复核过;**(c) `sidebar-entry.ts` 的「新建会话」选择器**是 DSH shell 自己按钮的 aria-label 匹配(两种语言都要匹配),属于**协议不是文案** —— 这是本轮唯一新增的 ALLOWLIST 条目,同时把 v0.30.6 留下的 `session-summary.ts` 那条(转换后已无用)删掉,避免白名单慢慢变成「什么都放行」。② **守门同步**:`verify-wiki-focus.mjs` 里「换库提示必须写清是哪个库」原来匹配 `tw-frame.ts` 里的模板串,文案搬进目录后假红 —— 改成仓库既有的「目录里有带 `{label}` 的模板 + 代码里有 `t('frame.loadingWiki', { label })` 调用点」形状(这条断言在本轮**真的红过一次**,不是预防性修改)。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(32 套,含 i18n 守门 283/283)/ `verify:e2e` 全绿。
84
- - **v0.30.12**(2026-09-29):**两个真缺陷 + 三条新守门**。① **`tiddlywiki_append` 的 `heading` 会把 Markdown 图片当成标题**:wikitext 标题正则 `/^!{1,6}\s*(.*)$/` 同样匹配 Markdown 图片语法(叹号紧跟一个方括号开头的行),于是该行被认成「一级标题,标题文本就是那串图片语法」。后果两层:模型可能被回执骗着 append 到一个"图片标题"上;更实际的是**定位与切段**——`heading: '工作日志'` 扫「下一个标题」时会在图片行提前截断,内容落进图片与正文之间。现在加负向先行断言 `(?![[!])`:叹号紧跟方括号的图片行不再算标题,而 `! 章节`、`!章节`、`!! 小节` 照旧(TW 两种写法都合法)。② **变更锁只覆盖了一半**:`/restart` 与 `/sync` 互相排斥,但**各持一把私有标志**(`routes.ts` 里的 `mutationInFlight`),`admin-routes.ts` **一把都没有** ⇒ 设置页能在 `/sync` 正排干 syncer 队列时按「重启」(`/admin/restart`),或在与重启重叠时跑初始化(`/admin/seeds/run`)——两者都会停 TW 子进程并抢同一个队列,正是这把锁当初要防的事。现在抽出 `host/mutation-lock.ts`,由 `index.ts` 建**唯一**实例并同时注入 `RouteDeps.mutationLock` 与 `AdminDeps.mutationLock`;`/admin/restart` 与 seeds-run 的重启段都拿锁、拿不到回 **429 并报出持有者**、`finally` 释放。③ **守门**:新增 `scripts/verify-mutation-lock.mjs`(进 `verify:unit`)——行为级(独占 / 释放后可再取 / idempotent / 两个实例互不影响,后者正是缺陷当年的形态)+ 接线级(`createMutationLock()` 在 `index.ts` 只能调**一次**、两个 deps 都拿到、别处不得手写同一套标志)。另在 `verify-write-policy.mjs` 追加三条 `insertIntoSection` 用例(图片不算标题 / 真 wikitext 标题仍认 / CRLF 不退化),并把 `insertIntoSection` 从 `index.ts` 导出给 headless 守门(此前它的唯一回归测试在 e2e 里,要 spawn TW,太慢)。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(39 套)/ `verify:e2e` 全绿。
85
- - **v0.30.11**(2026-09-29):**客户端 i18n 继续推进(第 3 条裁定)+ 守门解析器加固**。① 本轮把 5 个小文件全部转完并补进目录(共 181 键):`scope-seat.ts`(选择器座位标签)· `endpoints.ts`(同步回执后缀 `,TW 已重启` / `,TW 未自动重启` —— 它会拼进设置页与同步按钮的 toast,是最容易漏的一处用户可见文案)· `rightbar-tab.ts`(右侧边栏 Tab 的标题与说明)· `client/index.ts`(dock「快速笔记」与设置页分区名)· `note-widget-tags.ts`(标签输入框占位符 + 添加/移除标签的 aria-label,后者用 `{tag}` 占位符)。② **守门解析器加固**:`verify-client-i18n.mjs` 的 `chunkKeys()` 原来靠「`},` 是否单独成行」切 zh/en 区——某个分片把收尾括号写在最后一条同一行时,zh 区会吞掉整个 en 块(22 条、11 条重复),报成「键集合不一致」这种**看不懂的假红**。现在按 `zh: {` / `en: {` 两个语言头切分,对任何括号排版都稳。③ 执行方式调整(重要教训):**i18n 逐文件转换改为串行手工做**——本会话早些时候的并行子代理方案里,12 个子代理有 10 个静默失败并留下「文件改了、目录没写」的半成品,比不做更糟;串行做一轮 5 个文件、每步 `typecheck` + 守门全绿,可控得多。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(38 套,含 i18n 守门 181/181)全绿。
86
- - **v0.30.10**(2026-09-29):**`routes.ts` 拆分第 3 步(笔记组)+ 修掉 `/upload` 的多库 URL 缺陷**。① `routes-note.ts`(373 行):`/note` `/tags` `/recent` `/get` `/search` `/upload` 六个 handler 抽成 `createNoteRoutes(deps, helpers)`——依赖面用 `Pick<RouteDeps,'getClient'|'getWikiPath'|'server'|'serverById'|'wikiIds'|'wikiSummaries'|'noteDefaults'|'autoCommit'>` 写明,闭包 helper(`refuseStoppedTarget`/`notRunning`/`invalidateGitStatus`)由参数传入;`routes.ts` 1373→**667**(`handleStatus`/`handleEdit`/`handleRestart`/`handleSync` 与 `twProxyAbsoluteBase` 按守门要求留在原地,注册的 kind/path/guardHandler 包法一字未改)。② **顺手修的真缺陷**:`/upload` 回给客户端的 `url` 原来是 `${TW_PROXY_PATH}files/<name>` ——**裸路径,等于默认库**。多库安装里往「书籍库」上传图片,拿到的链接会指到**默认库**的 `/files/`(图裂,或更糟:指向另一个库里同名文件)。现在走新增的 `uploadBase(req)`:多库给 `/tw/<id>/`(`proxyBaseFor(mode, id)`,id 取请求上那个在册的 `?wiki=`,否则默认库),**单库保持原来的裸路径**——老安装一个字节都不变。③ 执行过程中被 `tsc` 与守门各抓到一次:批量删 import 的正则把仍在用的 `timestampTitle`/`resolveTags`/`conflictTokens` 也删了(`TS2304` 点名救回),以及 `,\s+\}` → ` }` 的压缩把 `twProxyAbsoluteBase,` 的尾逗号吃掉、让 `verify-tw-origin.mjs` 的「必须注入会话路由」断言变红。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(38 套)/ `verify:e2e` 全绿。
87
- - **v0.30.9**(2026-09-29):**`routes.ts` 拆分(第 5 条裁定)—— 两步纯搬迁/工厂抽取,行为零变化**。① `routes-helpers.ts`(256 行):十来个**模块级纯 helper**(上传文件名净化 `sanitizeUploadName` + 危险扩展名/Windows 保留名、`redactRemoteUrl`/`redactLogLines` 脱敏、`MAX_TAGS_LIMIT`/`readLimit`/`readOptionalLimit` 夹取、`timestampTitle`、`resolveTags`/`conflictTokens` 解析)以及 `openInTwEditor` 一起搬走。**`routes.ts` 原样 re-export 了公开的三个**(`redactRemoteUrl`/`redactLogLines`/`openInTwEditor`)——`admin-secrets.ts` 从这里 import、`index.ts` 从这里再导出、selftest 与 `verify-audit-fixes.mjs` 直接用它们,**既有 import 路径一个都没改**。`NOTE_TYPE` 随 `openInTwEditor` 一起搬(它属于那套写策略),由新模块导出。② `routes-wechat.ts`(210 行):`guardWechat` + `/wechat/{ready,publish,publish/status}` 三条路由抽成 `createWechatRoutes(deps)`。它们原来直接住在 `registerRoutes()` 的闭包里,抽取时**量过并写明了真正用到的依赖面**(`Pick<RouteDeps,'getClient'|'wechatConfig'|'wechatReady'|'wechatRunner'|'wikiSummaries'>`)——`import type` 只在类型期存在,运行时无环;`ROUTE_PREFIX` 改从源头 `wiki.ts` 的 `PATH_PREFIX` 导入并起同名别名,函数体一字未改。③ **守门铺路的第三处**:`verify-wechat-publish.mjs` 原本按精确文件名读 `routes.ts` 断言三条路由的注册与「先判方法+同源、再判开关/token」,搬迁后假红——改成读 `readFamily('src/host/routes')`,断言回到「规则」而不是「文件路径」。**验证**:`typecheck` / `build` / `selftest` / `verify:static` / `verify:unit`(38 套)/ `verify:e2e` 全绿。
88
- - **v0.30.8**(2026-09-29):**给 `routes.ts` 的拆分铺路:两处守门改成按模块族读**(行为零变化)。① `verify-render-sanitizer.mjs` 原本读「`routes.ts` + `routes-tw-proxy.ts`」这个**写死的两文件列表**,一旦把 `/render` 再搬进更细的模块就会假红(v0.28.8 已经踩过一次,当时的补法是往列表里加一个文件名——治标);现在读 `readFamily('src/host/routes')`,断言的是规则本身(宿主侧存在 `sanitizeTwFragment(` 调用),搬家不再需要改守门。② `verify-restart-drain.mjs` 原本遍历 `['routes.ts','admin.ts']` 两个**具体文件**并要求「每个文件里 restart 数 > 0」——把 `/sync` 或 `/restart` 的 handler 搬进新模块,`routes.ts` 的 restart 数会变成 0 而**静默丢掉覆盖**(甚至搬到别的文件后连「0 处 restart」那声错都不会响);现在遍历 `['src/host/routes','src/host/admin']` 两个**模块族**,规则(区域里不得有裸 restart、`drainUntilStop` 数量不少于 restart)对整片区域成立。**教训**:改守门脚本时改了调用处却忘了补 `import { readFamily }`,会以「函数未定义 → 测试抛错 → FAIL」的形式暴露(本轮正是这样被抓到的),所以守门改完必须**立刻单跑一次**。
89
- - **v0.30.7**(2026-09-29):**大文件拆分(第 5 条裁定的继续)** —— 纯搬迁 + 工厂抽取,行为零变化。① `src/host/tools-notes.ts`(683 行)→ **14 行的 barrel**:七个工具工厂按职责搬进 `tools-notes-write.ts`(`get`/`put`/`batch_put`)· `tools-notes-structure.ts`(`rename`/`append`)· `tools-notes-lifecycle.ts`(`delete`/`trash`);barrel 保持原导出面,`tools.ts` 的导入与**注册顺序**(`full` 模式参数索引依赖它)一字未动,每个分片的 `import` 按「该片真的用到」自动裁剪(`typecheck` 校验)。**守门依赖按模块族生效**(`readFamily('src/host/tools-notes')` / `'src/host/routes'` 会把新文件一起读进来),所以拆分没有削弱任何断言——`verify:static` / `verify:unit`(38 套)/ `selftest` / `e2e` 全绿。
90
- - **v0.30.6**(2026-09-29):**客户端 i18n 层落地**(作者:「客户端零 i18n 层,这个需要补齐」)。此前 `uiLanguage` 只驱动 TW 子进程,插件自己的界面(FAB / 侧边栏入口 / 输入框上方那一行 / 快速笔记卡片与编辑弹窗 / 工具卡片 / 会话「知识库」Tab / 设置页 / 全部 toast)**全是硬编码中文**,设置页里甚至写着「界面语言暂不支持」的自认。本次:① **唯一入口** `src/client/i18n.ts` 的 `t(key, vars?)`(`{name}` 插值,未知键回落默认语言再回落键本身——转换期间缺条目会**显形**而不是静默空串);② **目录按区域分片**成 `i18n-{chrome,note,card,settings,config,plugins,frame}.ts`,同一批表面的人只碰自己那一小份;③ **语言来源是同一个 `uiLanguage`**:host 在 `/status` 回 `lang`(`RouteDeps.langOf` ← `WikiInstance.uiLanguage()`),客户端 `fetchStatus()` 调 `setLang()` 并写 `localStorage` 缓存 ⇒ **刷新后首屏即为该语言**;④ **顺手修掉设置页作用域条的真问题**(v0.28.12 定过规则却漏了这一处):它把**库 id** 当名字显示、默认态写死「正在配置:默认知识库」——现在读同一份 `/admin/wikis` 名册取**显示名**(拿不到就中性兜底),界面上不再出现「默认库」;⑤ **8 处守门断言改用「目录里有该文案 + 代码里有 `t()` 调用点」的形状**(`verify-wiki-focus.mjs`),因为文案搬进目录后,再去匹配源码里的中文字面量就是假红——其中「切单库必须直呼默认库之名」改成断言目录里有 `{name}` 占位符 + 调用点传了 `name`。**守门**:新增 `scripts/verify-client-i18n.mjs`(进 `verify:unit`)——分片 zh/en 键集合一致、每个 `t('…')` 的键都存在、没有死键、**客户端源码里不得残留中文文案**(剥注释后扫描;`t()` 第一参数与 ALLOWLIST 里的协议串除外);未转换的文件列在 `PENDING_CONVERSION` 里,清单**自清洁**(转完了还留着 = 红),所以它只会缩短。本次实到:设置核心 4 个文件(`settings-page.ts` / `settings-page-wikis.ts` / `settings-icon-picker.ts` + 160 条键、含 42 个图标名)已转完,其余 20 个仍未转(清单在脚本里,逐文件转换即可)。
91
- - **v0.30.5**(2026-09-29):**`tiddlywiki_git_sync` 改为同步「所有开启了 git 同步的库」**(作者裁定:「同步所有开启了git,并且有同步库的wiki库」)。此前它只作用于**会话作用域那一个库**(再往前是"作用在默认库、回执却标注会话的库",v0.29.0 只补了注释没补实现),于是多库安装**没法用 Agent 同步其余库**。现在:① **凡是有效 `git.remote` 非空的已登记库都在同步范围内**(`autoCommit` 关掉不算理由 —— 手动同步本来就是手动);② **按仓库去重**:多个库共用一个工作树时 pull/commit/push 各只跑一遍(跑 N 遍会互踩同一个 index),回执按仓库合并成一项并列出库名与**库 id**;③ **逐仓库独立成败**:一个仓库冲突/失败不再吞掉其余仓库的结果,总 `ok` 反映整体、总 `message` 点名是哪个库失败;④ 没配 `git.remote` 的库出现在 `skipped` 里("同步完成"不允许被读成"全同步了"),一个可同步的库都没有时 `ok:false`;⑤ 冲突回执给出**带 `wiki=<库 id>` 的解决指引**,`tiddlywiki_git_resolve` 新增可选 `wiki` 参数(多库各有独立仓库时**必须**传,否则会改错仓库);⑥ 排干(`flushPendingWrites`)仍只针对调用方刚写过的那个库(它是唯一够得着的队列、也是重启风险所在),失败照样在回执里显形。实现走 `ToolsDeps.gitTargets()`:由 `index-git.ts` 从**注册表**(不是运行态)枚举每库目录 + `repos.repoRootOf()` 缓存出的仓库根 + 每库有效 `git.remote`,所以**停止的库也同步**(同步是磁盘操作,不需要 TW 子进程)。**守门**:新增 `scripts/verify-git-multi-sync.mjs`(进 `verify:unit`,**行为级**、不 spawn TW):用假 deps 驱动注册出来的真工具,断言"按仓库去重/逐库标注/skipped/失败隔离/冲突带 wiki= 指引/排干失败显形/无目标 ok:false/`git_resolve wiki=` 选对仓库并拒绝未知 id" 九条。**验证**:`typecheck` / `build` / `verify:static` / `verify:unit`(含新守门)/ `verify:e2e` / `verify:large` / `selftest` 全绿。
92
- - **v0.30.4**(2026-09-29):**清理 + 守门加固 + 作者对剪藏桥的姿态落地**(无行为变化)。① **作者裁定「剪藏桥不做认证」并写入代码与文档**:桥保持对任意页面开放 —— 「任意网页能给你的 wiki 建笔记」是**许可的**,不允许的只有**覆盖系统项**(`$:/…` 标题一律拒收,v0.30.0 起生效)。模块注释里那句「随机网页不知道 token,所以污染不了 wiki」是对安全模型的误述(token 默认空、且设计上不打算要求它),已改为如实陈述;`bridge.token` 降级为**可选的额外收紧**,同步更新了会被写进用户 wiki 的《本地剪藏桥》文档(`seed-clip-bridge.ts`)、`docs/usage.md` 与 `AGENTS.md` §2。② **守门加固:`readFamily()` 读空/缺 `<base>.ts` 现在直接抛错**。此前族文本会静默退化成空串 ⇒ **反向断言与 `assertNo` 全部自动成立**(本仓库最危险的一种假绿)。③ **`verify-prompt.mjs` 的多库预算不再是孤立魔数**:手写的 `1800` 在 v0.29.0 把 slim 瘦到 1011 之后就再没跟上(等于一个永远成立的数假装在守门),改成 `SLIM_BUDGET + 200`。④ **清理**:删除被 `gen-wiki-icons.mjs` 取代的开发助手 `scripts/extract-dsh-icons.mjs`;`pad()` 的两份逐字相同实现合流到新的 `client/format.ts`;`tool-views.ts` 里与 `settings-page-runtime.ts` **同名但语义相反**的 `fetchJson` 更名 `fetchJsonOrNull`;删掉从未被读的 `RightbarTabBodyProps.sessionId`;`seeds.ts` 的 `SeedMeta.gate` 是**死字段**(`defineSeed` 只读第二个参数里的 gate,写在 meta 里会被静默忽略)——删掉后放错位置会变成编译错误。⑤ **一处「差点清错」值得记**:`wiki-scope-dock.ts` 的 `wrapRef` 看起来是没人读的调试锚点,实际是 `verify-wiki-focus.mjs` 明确断言的契约(「选择器的根元素必须挂 ref」——对齐按行测量,元素要有可被持有的身份)。删掉它守门当场变红,已恢复并加注释说明,免得下一个人再当死代码清掉。**验证**:`typecheck` / `build` / `verify:static` / `verify:unit`(29 套)全绿。
93
- - **v0.30.3**(2026-09-29):**剪藏桥第三刀**(纯搬迁,行为零变化)——`clip-bridge.ts` 在 v0.30.1 拆出 SSRF 层之后仍有 574 行,里面混着「payload ↔ tiddler 翻译」与「loopback 服务器」两件事。现在 payload 层独立成 `clip-bridge-payload.ts`(189 行:标题去重规则 `resolveClipTitle`、三个 tiddler 构造器、mime/扩展名映射、以及那个带 `$:/` 系统命名空间拒绝的 payload 校验器),`clip-bridge.ts` **574 → 414 行**,只剩 loopback 服务器 + CORS + 公开面再导出。三个新文件与 base 之间:SSRF 层无反向依赖、payload 层只 `import type` base 的 `ClipPayload`/tw-api 的 `Tiddler`(类型侧闭环,运行期无环),所以搬迁零改写。**验证**:`typecheck` / `build` / `verify:unit`(含 `verify-clip-bridge` 从 `lib/` 取 12 个符号的断言)全绿。
94
- - **v0.30.2**(2026-09-29):**拆掉仓库里最大的单个函数**(纯搬迁,行为零变化)——`createSessionRoutes()` 曾是一个 **432 行**的函数,里面混着三族互不相干的 handler。现在 `src/host/routes-session.ts` **632 → 274 行**,只剩「会话知识库作用域」三条(GET/POST 同路径的那组)+装配;新增 `routes-session-summary.ts`(103 行:`/session/summary` 与它那个"一次要读 41 份会话日志、探测 300 篇 tiddler"的 promise 缓存——缓存只有一个消费者,就该和它住在一起)与 `routes-session-agent.ts`(336 行:`/agent/{sessions,modes,send,create}` 四条 + 共享 token 守卫 `guardSendToAgent`,这个守卫只有这一族用)。装配收成两行工厂调用(`createSessionSummaryHandler(deps)` / `createAgentHandlers(deps)`):两个工厂拿到的都是同一个 `deps`,所以**函数体逐字未改**,搬迁零改写;两个新文件与 base 之间只有 `import type`(类型侧闭环,运行期无环)。**守门铺路先行**(前一提交):`scripts/verify-tw-origin.mjs` 里「数两处 `twUrlAbsolute`」原本**精确读** `routes-session.ts`,这次搬迁会让它假红——已改成按**模块族**读(`routes-session.ts` + `routes-session-*.ts`)并补一条「族里真的找到了 `createSessionRoutes`」的非空断言(防止基名写错时族读读空、断言静默全过);**规则没变、计数仍是 2**。这条顺序就是本仓库自己的教训:**大重构的第一笔提交是给守门铺路**。**验证**:`typecheck` / `build` / `selftest` / `smoke:client` / `verify:static` / `verify:unit` / `verify:e2e` / `verify:large` 全绿。
95
-
96
- - **v0.30.1**(2026-09-29):**纯搬迁的拆分版(行为零变化)**——v0.30.0 的修复逐字不变,本版只把三个「一个文件混了几件事」的大文件按职责拆开,为审计待办里剩下的拆分打样(每个都先确认守门是**按模块族**读还是精确读单个文件,再决定搬迁边界)。① `src/client/styles.ts` **945 → 47 行**:900 行 CSS 正文移入 `styles-css.ts`,`styles.ts` 只剩 `STYLE_ID` + `injectStyles()`;7 处守门改按 `readFamily('src/client/styles')` 读,而"CSS 模板串没被正文里的反引号提前截断"那条改读 CSS 所在文件(它才是模板串的家)。② `src/host/clip-bridge.ts` **872 → 574 行**:SSRF 姿态(URL 校验 / DNS pinning / 逐跳重定向复检 / 限字节流式下载)移入 `clip-bridge-ssrf.ts`(315 行),四个尺寸上限移入 `clip-bridge-limits.ts`(17 行)——两者之间没有反向依赖,公开面在 `clip-bridge.ts` 原样再导出(`src/index.ts` 那条 12 个符号的 re-export 与守门从 `lib/` 取的名单逐字不变)。把"代表网页去公网取数据"的那一半单独成文件,也让这条风险更容易被审读。③ `src/host/seeds.ts` **824 → 658 行**:排干/重启原语(`waitForFileWrite` / `flushPendingWrites` / `drainThenStop` / `needsRestartAfterSeeds` + 探针常量)移入 `seeds-flush.ts`(198 行)——它们是关于 TW syncer 的(铁律 #1 的唯一实现),不是关于 seed 内容的;`scripts/verify-restart-drain.mjs` 按模块族读源码(`seeds.ts` + `seeds-*.ts`),对这次搬迁免疫(原语整段留在同一文件、其后不再跟以 `\n}` 收尾的函数,符合该守门的解析前提)。**验证**:`typecheck` / `build` / `selftest` / `smoke:client` / `verify:static` / `verify:unit` / `verify:e2e` / `verify:large` 全绿。
97
-
98
- - **v0.30.0**(2026-09-29):**第二轮全面审计(五路只读审计:路由 / 数据层 / 工具与可选功能 / 客户端 / 守门与文档)之后的修复 + 清理版**。门禁当时全绿,问题同样全在门禁看不到的地方;本轮另查出**三条守门自身是空转的**(断言恒真)。① **`tiddlywiki_get` 漏了密钥命名空间守卫**(铁律 #6 的第四条路径):`/get`、`/tw`、`/api`、`/render` 都挡了 `$:/plugins/dsh-tiddlywiki/`,唯独 agent 工具直接 `wiki.get(title)` —— 模型可以原样读出 `$:/plugins/dsh-tiddlywiki/config`(`bridge.token` / `ui.sendToAgent.token` / `wechat.token` / 带 PAT 的 `git.remote`),即那些路由存在的理由被整条绕过。守卫抽成共享的 `host/proxy-guard.ts`(工具层不该为一条安全原语去 import 路由模块),`routes-tw-proxy.ts` 原样再导出保持既有调用面。② **剪藏桥可以写系统命名空间**:桥**故意**对任意网页开放(bookmarklet 跑在页面源里,加 Origin 检查会直接废掉功能)、`bridge.token` 默认空,而标题原样收下 → 恶意页面可 `PUT $:/plugins/dsh-tiddlywiki/config`(`ConfigStore` 随后读它、`wechat.command` 会被当 CLI 起、`git.remote` 会用于下次同步),或覆盖回收站索引等插件状态。现在 `parseClipPayload()` 拒绝 `$:/` 前缀(含守门用例);`bridge.token` 仍是可选的加固手段。③ **`bridge.wiki`(v0.28.8 的多库剪藏目标)在运行期恒被丢掉**:`WikiInstance.bridgeConfig()` 是手写对象字面量,重建时只带 4 个键,`wiki` 从未传给 `clipTarget()` —— 于是「剪藏写进哪个库」这个设置项在多库下**永远回落默认库且不打印回落日志**(守门只断言 `clipTarget` 源码,抓不到这条接线)。④ **`tiddlywiki_git_sync` 的排干结果是空守护**:`flushPendingWrites()` **从不 reject**(它 resolve `false`),而调用点写的是 `.catch(() => undefined)` —— 既是死代码,又把 `false`(超时/探测 PUT 失败)丢掉后照常重启,正是铁律 #1 要防的"重启杀掉未落盘写入"。策略仍与 `drainThenStop` 一致(排干失败也要能重启,否则卡死的 TW 无法自救),但结果现在**写进回执**(`drainFailed` + 一行醒目警告,让模型提示用户核对)。⑤ **`/admin/restart` 对"已登记但没在跑"的库假成功**:它没有兄弟路由 `/restart` 的 `refuseStoppedTarget`,于是 `?wiki=<停止的库>` 会走 `client: undefined` + `getWikiPath` 的默认库回落,然后回 `{ok:true, drained:true, status:undefined}` —— 设置页作用域写着那个库、却什么也没重启。现在 503 点名。⑥ **多库 + 微信公众号发布 = 会发错库的内容**:发布链路把**一个**基址(`--dsn`)交给 opencli adapter,adapter 再自行加 `/render`、`/get?title=`,所以库 id **只能走路径**、`?wiki=` 到不了那两次调用;多库下任务会拿 B 库的标题去渲染**默认库**的同名笔记并发出去(公开平台、且回执不说)。本轮**响亮拒绝**(400 + 说清原因与出路),单库安装逐字不变;彻底修法(`/w/<id>/` 作用域别名前缀)记在审计笔记里。⑦ **配置保存会把打码的 remote 写回库**:`/admin/config` 用**内存缓存**比对 `git.remote`,而 `ConfigStore.set` 合并的是**刚读到的** tiddler;两者不一致时(有人在 TW 编辑器里改过 remote)判据失效,`https://***@github.com/…` 被当成真 remote 落库 —— 判据改为**结构性**(字符串里带 `//***@` 就丢弃,不依赖任何缓存),并补了"缓存过期"回归用例。⑧ **会话作用域文件坏掉会清空所有会话**:`setSessionScope` 是"读-改-写",而坏 JSON 被当成"空状态" → 整份重写后**其它会话的库作用域一起没了**(铁律 #3 的同类,读路径可以软失败,写路径必须中止)。现在中止并保留原文件。⑨ **微信任务输出截断方向反了**:`capOutput` 保留**前** 64k,而判据(`-f json` 的 JSON 行、stderr 末行)都在**末尾** → 超长运行的判据退化成"未解析到结构化回执"= ok。改为保留尾部。⑩ 其余:`rename(updateRefs:false)` 不再谎报"未找到任何引用"(旧判据恒真)、FAB 重建时退订旧的焦点订阅(每次名册变化泄漏一个监听器 + 旧闭包)、快速笔记 `ensureTarget()` 不再把失败的 `/status` 记忆化(一次抖动曾把目标库永久钉在默认库)、草稿恢复后清空正文不再写出"空草稿"(v0.22.8 症状的另一条入口)、`/session/summary` 的并发缓存不再无界增长。**⑪ 三条守门空转(这是本轮最该记的部分)**:`verify-wechat-adapters` 的"必须清掉 class"写成 `/(坏正则)/.test(src) || /class/.test(src)` —— 右边匹配任意 `class` 字样(注释也算)⇒ 恒真;`verify-tool-views` 的旧拦截器反向断言 `^` 无 `m` 标志又作用在折行后的整份文本上 ⇒ 恒真;`verify-prompt` 的反向 needle 写成了全仓不存在的措辞 ⇒ 恒真。三处都换成**行为级/同源 needle**断言并补了回归(②的 `$:/` 拒绝、⑦的缓存过期、⑧的坏 JSON 中止、⑥的多库拒绝、`styles` 家族的 `readFamily` 读取)。**⑫ 拆大文件**:`styles.ts` 945 → **47 行**(900 行 CSS 正文移入 `styles-css.ts`,7 处守门改按 `readFamily('src/client/styles')` 读、模板串截断守门改读 CSS 所在文件)。**⑬ 死代码清理**:`WikiInstanceState`(零引用类型)、`DOCK_ALIGN_MIN_INPUT_WIDTH` 的多余导出(注释还写着"导出给守门断言"——已过期)、唯一无引用的 CSS 规则 `.dsh-tw-settings-icon-select`、`scripts/lib` 的 `familyFiles()`(零调用点)与 `matchRoute`/`CHROME_CANDIDATES` 的多余导出、`sanitize.ts` 里因更早的白名单判定而永不执行的 `action`/`formaction` 分支。**验证**:`typecheck` / `build`(client 708953 字节)/ `verify:static` / `verify:unit`(29 套)/ `verify:e2e` / `verify:large` 全绿。**未做完的拆分**(审计给了逐文件方案与守门约束,记在 [[dsh-tiddlywiki 第二轮全面审计(2026-09-29)]]):`routes.ts`(1387) / `routes-session.ts`(625,单函数 432 行) / `note-widget.ts`(986,`build()` 384 行) / `index.ts`(1021,`apply()` 630 行) / `clip-bridge.ts`(860) / `seeds.ts`(824) / `tool-views.ts`(940)。
99
-
100
- - **v0.29.0**(2026-09-29):**一轮只读审计(门禁全绿、0 个 open issue,问题全在门禁看不到的地方)之后的修复版**——三个 P1 都属"静默作用到错误的知识库",两条守门还是**假绿**。① **快速笔记 native 路径从不解析目标库**(默认 `ui.quickNoteMode=native`):名册与目标库的解析整段写在 `build()` 里,而 native 路径**从不建卡片** —— 于是 `targetWiki` 恒为 `undefined`、`rosterMode` 恒为 `'single'`,在 B 库写作时点「快速笔记」,笔记落进**默认库**、编辑器也是默认库的,而那个本该拦住它的「写入」下拉永远不会出现。修法:解析抽成 `ensureTarget()`(build 之外,两个入口都调),native 路径**先解析、目标库没在跑就拒绝**、再写。守门 `verify-wiki-focus.mjs` 只做源码字符串匹配(匹配的还是 helper 里的实现)→ 新增**调用点/顺序**断言(已反向验证:删掉 `await ensureTarget()` 或漏传 `wikiQuery` 都稳定变红)。② **`?wiki=<已登记但没在跑>` 静默回落默认库**(写路由也是):`targetRuntimeFor()` 注释只论证过"未知 id 回落是为了老书签",却把"已登记但没运行"一起回落了 —— 设置页写着「正在配置:books」却把配置写进默认库、`/admin/info` 改的是默认库的 `tiddlywiki.info`、`/sync`/`/restart` 动的是默认库。现在**未知 id 仍回落**(老书签不能 404),**已登记但没在跑一律 503 并点名那个库**(与 `/tw/<id>/` 代理早已给出的 503 语义一致);判断只有一份实现(`stoppedWikiFromRequest()` / `stoppedWikiMessage()`),`admin-routes` 的 state/info/config/prompt/seeds 先查 `targetProblem`,routes 侧 status/note/edit/sync/restart/upload 同样。③ **启动自举有两处裸 `server.restart()`**(`pluginAdded` 与 `uiLanguage` 分支)绕过 `drainThenStop()` —— 而守门用 `indexOf` 只看**第一处**,数不到它们。现在自举(含 seed 分支与 `start()` 的卸载中止)全部走 `instance.restart()` / `drainStop()`,`verify-restart-drain.mjs` 改成**枚举全部调用点**:每一个 `server.restart()/stop()` 都必须落在 `drainThenStop` 的 stop 回调里(`dispose()` 是唯一例外)。④ **注入提示词去重瘦身(1918 → 1011 字符,−47%)**:删掉与工具 description 逐字重复的句子(写入/并发 4 条 → 1 条;工作区标记、先窄后宽、多词 AND 交还 `put`/`search` 的 schema;intro 里手写的工具类别清单也删了,那是唯一还会漂移的副本),预算 2100 → 1200。**安全网换成 schema 侧断言**:`verify-prompt.mjs` 新增"委派的规则必须在工具 description 里",删一处可以、**两处一起删立刻红**(`attach` 的同名拒绝原本只写在提示词里,已补进它的 description)。⑤ **其余 P2**:卡片正文缓存失效时补上 wikiId(删除/批量写入后最多 5 分钟还能读到旧正文)、FAB 名册在打开菜单前对账(新增/移除/改名/启停库后不再显示过期清单)、会话「知识库」Tab 订阅作用域变化(切库后不再挂 3 分钟旧库内容)、`data-dsh-tw-wiki` 表达"host 实际服务的那个库"(多库下无显式作用域时不再跟随 GUI 焦点 → v0.28.11 报障的后半)、`catalogPending` 与配置表单重建键带上库作用域(A 库的勾选/脏补丁不再应用到 B)、侧边栏高亮按**完整名册**解析(焦点库没在跑时不再高亮错行/点成切换)、右侧栏 tab 创建即应用可见性、剪藏桥写入串行化(连点两次不再互相覆盖)、`session-scope`/`ConfigStore.set` 的读失败不再被当成"没有"(前者会丢其它会话作用域、后者会把配置写成只剩本次改动——铁律 #3)。⑥ **文档**:`usage.md` 删掉已删除的"2 秒回落"(它在教一个修掉的回归)、seed 表补 `wechat-publish`(13 项)、路由表补 `/admin/wikis` `/session/wiki` `/wechat/*` `/tw/<id>/` 并修正只读路由的方法、项目结构补 v0.28.8 拆出的模块、"重启只有一条路"按新实现改写。
101
-
102
- - **v0.28.14**(2026-09-29):**修「点击切换知识库感觉会卡那么一下」**(作者报障,接在 v0.28.13 之后)。① **先量再改**(不然只是猜):`/status` 每次要 host 跑最多 5 个 git 进程(本机实测冷缓存 **300–400 ms**);而换库后的 TW 文档本身是 **9.9 MB / 29.9 MB / 28 MB**(三个库实测),并且 TW 自己回 `Cache-Control: no-store`、没有 `ETag` —— **既缓存不了也预取不了**。② **两段来源分开处理**:内核的焦点订阅原来只调 `doRefresh()`,而它**先等一次 `/status` 往返**才动 iframe —— "点下去"到"真的开始换"之间于是空了 0.3–0.4 秒,紧接着才是那份大文档的下载与解析。现在新增 `switchFrameNow()`:焦点一变就**同步**用上一次 `/status` 的 payload(`mode` + 代理基址 + 名册)把 iframe 指到新库,`doRefresh()` 退为**后台对账**(不再挡在点击与界面反应之间);拿不到可用 payload 时它什么都不做,行为与 v0.28.13 逐字一致。③ **侧边栏不再重复打 `/status`**:焦点变化原来会重跑一遍 `applyRoster()`(再打一次 `/status` + 重建所有行 + 重走放置逻辑),可名册根本没变、变的只是哪一行亮着;现在收成 `paintFocus()`,只重画高亮。④ **给可见说明**:换文档期间在 TW 界面底部挂一条 `正在载入知识库「个人」…`(`.dsh-tw-loading`),新文档 `load` 到达即收起,并留 **15s 兜底**(`load` 不来时不能把"正在载入"永久挂在界面上);`showError` / `showStarting` / `setVisible(false)` 三条路径也都会收掉它。提示条是 view 的**最后一个孩子**,刻意**不碰** `frame` 的显隐规则(那条"未载入 TW 地址就绝不显示 frame"是 v0.22.3 的血债)。⑤ **剩下还能不能更快**:不能 —— 剩下的时间全是 TW 自己的文档大小(10–30 MB、`no-store`),不是这套代码能缩短的;面板与右侧栏共用同一条提示。**守门**:`verify-frame-surface.mjs` 新增 2 条**行为级**单测(把 `/status` 打桩成**永不返回**后切库:`frame.src` 必须**已经**指到新库,且提示条可见、写明目标库名、`load` 后收起、15s 兜底收起);`verify-wiki-focus.mjs` 新增 1 条源码级(`switchFrameNow()` 必须早于 `doRefresh()`,订阅里不许 `await`;侧边栏焦点变化不许重跑名册 —— 反断言跑在**剥掉注释**的代码上;提示条必须**真有 CSS**,v0.28.9 的教训)。**已反向验证**四种注入(退回"只 doRefresh" / `load` 后不收提示 / 焦点重跑名册 / 提示条没有 CSS)都稳定变红,恢复即绿。单库安装行为逐字不变。
103
-
104
- - **v0.28.13**(2026-09-29):**修「左侧多 wiki 入口有时候要点两次才能切到对应的库」**(作者报障)。① **症状**:面板正开着「工作」时点侧边栏「个人」那一行,得点两次才切过去。② **根因**:入口行的点击**一律**走 `state.toggle()` —— `setFocusWiki()` 确实把焦点换过去了(内核也开始换库),但**同一击**里的 toggle 又把面板关掉;用户看到的只是"面板没了",于是再点一次。开着面板时点**另一个**库,用户要的是切换,不是关面板。③ **修法**:判定收成一个纯函数 `resolveEntryClick()`(面板关着 → `open`;开着且就是当前显示的那个库 → `close`;开着但是别的库 → `switch`),副作用收进 `applyEntryClick()`;`switch` 只切焦点、面板保持打开,换库仍由焦点订阅驱动内核重载(`tw-frame.ts`)。**两条顺序/同源要求**:(a) 判定必须在 `setFocusWiki()` **之前**求值 —— 反了的话刚切过去的那个库会被当成"面板此刻显示的就是它"→ 判成 `close`,等于没修;(b) 行**高亮**与点击判定改用同一个解析 `shownWiki()` —— 原来高亮那一路把 `defaultId` 传成了 `undefined`,焦点还没显式设过时会点亮**第一行**而不是默认库;高亮与点击语义不一致时,"点看起来没亮的那一行"就会变成关面板(同一个报障的第二个来源)。④ **守门**:`verify-wiki-focus.mjs` 新增两条 —— 判定表(7 个用例,含单库那一行)与**行为级**用例(真 `PanelState` + 真 `setFocusWiki`,直接断言本次报障的原始场景:"面板开着时点另一个库,面板必须仍开着且焦点已切过去"),另加源码级反向断言(入口行里不得再出现 `toggle()`、判定必须在切焦点之前求值)。**已反向验证**:注入旧行为(切换判成关闭 / 退回无条件 toggle / 把判定挪到切焦点之后)三种改法都稳定变红。**单库安装行为逐字不变**(那一行没有 `wikiId`,仍是纯粹的开关)。⑤ **顺带修好一个长期红着没人看的守门**:`scripts/smoke-client.mjs`(CI 里那条「client bundle module-loader 形状」检查)的 React 替身缺 `createContext`,而 `src/client/tool-views.ts` 自 v0.28.8 起在**模块作用域**调用 `React.createContext(...)` —— 这个脚本于是每次都抛 `c.createContext is not a function`(已在 HEAD 的 `lib/client.js` 上复现确认,与本次改动无关),等于**从那时起就在 CI 里空转**。补全替身(`createContext`/`useContext`/`useMemo`/`useCallback`/`useLayoutEffect`/`forwardRef`/`memo`)并在注释里写清「stub 漏一个 API 就是漏一次检查」。
105
-
106
- - **v0.28.12**(2026-09-29):**「默认库」不再是一个名字:哪里都直接用被设为默认的那个库的真实显示名**(作者要求:「设置了默认库之后,在其他所有的地方不出现默认库的字样……开启会话时默认选中的就是被设置成默认库的库,而不是显示默认库这个选项,这个选项都不该在下拉列表中出现」)。① **会话选择器**(输入框上方那一行):删掉那个无名的 `value=''`「默认库」选项;默认那个库直接用自己的显示名排在最前,后缀「(默认)」只作**身份标记**;新会话打开时它已经是选中项 —— 靠 `/session/wiki` 返回的 `resolved{id,label}`(宿主解析出来的"这个会话实际用哪个库"),没显式选过就显示它。选中默认那一项 = 清空显式作用域(仍然跟随默认),所以以后改了默认库、没动过选择器的会话会跟着走(语义与旧的无名选项**逐字一致**,只是不再有那个名字)。② **快速笔记「写入」目标**:同样去掉名为「默认库」的占位项(清单里本来就有那个库本身),只给默认库加「(默认)」标记。③ **设置页剪藏目标**:空值那一项就地改名成默认库的显示名 +「(默认)」,并且**跳过**它自己那一行(否则同一个库在下拉里出现两次);读不到清单时给中性说法「(跟随默认的那个库)」,绝不退回「默认库」。④ **设置页「运行模式」**:`单库(只跑默认库)` → `单库(只跑 <那个库的名字>)`,切单库的确认文案同样直呼其名。⑤ 顺带清掉两处宿主日志里的「默认库」措辞(共用仓库的 git 冲突、剪藏回落),插件说明 seed 里那句也改成「运行模式、库清单、以及哪个库是默认的」。**守门**:`verify-wiki-focus.mjs` 新增一条——把四个界面模块的**注释剥掉**后断言代码里不存在「默认库」字样(注释当然要能讲这段历史),并逐条断言会话选择器读 `resolved`、带「(默认)」标记、默认选中、选中默认项发 null,快速笔记无占位项,剪藏下拉改名且不重复列默认库,单库模式文案直呼其名。单库安装行为逐字不变。
107
-
108
- - **v0.28.11**(2026-09-29):**修两个多库界面的真机报障(卡片链接开错库、新会话两个选择器)+ README 不再放版本记录**(三条都是作者当天报的)。① **点卡片上的「在 TW 打开」跳到最后打开的那个库** —— 卡片把会话作用域(`wikiId`)塞进了 `openTiddler` 的事件 detail,但 `panel.ts` 的事件处理**只读了 title**;而面板与右栏都按 GUI 的**焦点库**加载 `/tw/<id>/`,链接的库被整个丢掉(`undefined` 当时还同时表示"默认库"和"未指定",根本区分不开)。现在 `TwWikiTarget` 把三者分开(`string` = 该库 / `null` = 明确默认库 / `undefined` = 未指定、跟随焦点库):卡片与列表行传 `null` 而不是省略;`panel.ts` 拿到 `wiki` 先 `setFocusWiki()` 再路由(两个 TW 界面都按焦点库加载),并把目标库透传给右栏 tab 与中央面板。内核补齐**换库时序**:`wikiSwitchPending` 期间不落 hash —— 否则 hash 会写进正在被替换的旧文档,随后整页重载把它连同 `pendingHash` 一起吞掉(表现为"停在库首页、笔记没打开")。全局链接拦截面同样不再把裸 `/tw/#标题` 一律当默认库:链接自带 `/tw/<id>/` 就用它,裸路径取**所在卡片 / 会话汇总面板**的 `data-dsh-tw-wiki`(工具卡与会话「知识库」Tab 的根节点新增该属性),两者都没有才跟随焦点库。② **新会话出现两个知识库选择器**(其中一个与快速笔记同一行)—— 根因是 v0.28.8 给 `conversation.input.selector.context` 配的**「2 秒没等到就回落到 dock」**:回落会往 `conversation.input.dock` **再注册一个 blank-only 条目**,而 dock 里本来就有同一个选择器(v0.28.7 起由快速笔记条目的 `scope` 参数渲染在同一行)——本机 shell 恰好不声明 `selector.context`(`app.asar` 里连这个字符串都没有),于是回落成了常态、两个选择器同时渲染。现在**删掉回落**(dock 里那个才是唯一常驻的家),并把"谁在用"做成显式协议:`scope-seat.ts` 用 `isBlankSeatMounted()` 公布 context 那一格是否已挂上,dock 里的选择器在**空白会话 + 那一格在挂着**时返回 null 让位(`useSessions` 随之从快速笔记条目透传给子组件,否则子组件认不出空白会话、让位不了)。③ **README 不再放版本记录**(作者要求:只放最新最重要的信息,版本历史单独一个文档)—— 删掉 README 那一节(内容本来就是 `docs/CHANGELOG.md` 的副本),版本一致性的第三个锚点从 README 改到 `docs/CHANGELOG.md` 顶部第一条,并**反向断言 README 里不得再出现逐版本条目**(防止它随每次发布慢慢长回去)。守门:`verify-frame-surface.mjs` 新增换库时序行为单测(旧文档不得收到 hash、新文档必须收到);`verify-wiki-focus.mjs` 重写选择器座位那条(不许再回落注册 dock 条目、必须有 blankSeat 让位协议)并新增「卡片链接必须带库」的源码级断言;`verify-version-consistency.mjs` 改锚点 + 新增 README 反向断言。单库安装行为逐字不变。
109
-
110
- - **v0.28.10**(2026-09-28):**修「图标弹层出现在设置弹窗后面、被遮挡」**(作者 v0.28.9 重启后实测报障)。① **根因是层级用错了数**:设置页是 DSH 的 `Modal`,渲染进 portal 且根层就是 `position:fixed; z-index:1000`(`dsh-client-ui-primitives/Modal.module.css` 的 `.root`),而弹层写的是 `z-index:60`。**`z-index` 只在同一个层叠上下文里比较** —— 弹层虽然是 `document.body` 的直接子节点(自己也在根上下文),但 60 < 1000,所以整层被 Modal 盖住:弹是弹了,在设置弹窗**后面**。② **修法**:改为 **1100**。这不是随手挑的"大一点的数",而是 DSH 自己给「锚点在对话框内部的浮层」定的值 —— `Menu.module.css` 的 `.portal`,注释原话:*"Portaled lists must layer above modal overlays (z 1000) — an anchor inside a dialog still expects its menu on top"*。跟它对齐即高于 Modal(1000)、低于我们自己的 toast(10001)。③ **守门**:断言弹层 `z-index ≥ 1100`,并**解析规则体时先剥掉注释**(第一版按字符往后切 400 字符,结果切到了我新写的注释里那句「DSH Modal 的 1000」,解析出 1000 而误报)。已反向验证:改回 `z-index:60` → 红;恢复 1100 → 绿。④ **顺带补 `styles.ts` 模板串守门**:CSS 正文里的反引号会让 `CSS_TEXT` 提前结束,`typecheck` 报 TS1005、而 `lib/` 已被 `clean-lib` 删掉(构建到一半失败)。本仓库踩过两次(v0.25.0 注释里引 `.dsh-tw-settings`;v0.28.9 我在解释 z-index 时又写了一次)。⑤ **这条守门的第一版是错的,值得单独记**:它检查「第一个闭引号之前有没有反引号」—— 可一旦正文多了一个反引号,**那个反引号本身就变成了"闭引号"的位置**,body 在它之前就结束了,于是"body 里没有反引号"**永远成立**:注入反引号反而让断言更容易通过。**用出错的输入去验证出错的解析器,必然假绿。** 改成检查「模板串有没有被提前截断」——正文须以 CSS 注释开头、以 `}` 结尾,且闭引号之后必须紧跟正常模块代码(`/**` / `import` / `const` …)。再注入反引号即稳定变红。⑥ 复核:`typecheck` / `build`(`client.bundle.js` 704856 字节,并确认 `z-index:1100` 进了 bundle)/ `verify:static` / `verify:unit` / `verify:e2e` / `verify:large` 全绿。
111
- - **v0.28.9**(2026-09-28):**修「点击图标没有正常弹出」**(作者重启后实测报障)。本版只有一个修复,但值得单独发一版:它暴露的是**守门的失效模式**,不是普通的漏写。① **现象**:设置页知识库列表里点「图标」,弹层没有出现。② **根因**:v0.28.8 的 `settings-icon-picker.ts` 生成了完整 DOM(弹层 / 网格 / 格子 / 分页器)、接线也对,**但 `styles.ts` 里一条 CSS 都没有** —— 弹层照样被 append 到 `document.body`,而它是个无宽高约束的裸块、压在页面最底部,视觉上就等于「点了没反应」。③ **修复**:补齐 12 条规则 —— `.dsh-tw-iconpicker`(含 `position:fixed`:弹层挂在 body 上,用 absolute 会被页面滚动带走、也受祖先 overflow 影响)、`min(420px, 100vw-24px)` 宽度与 `max-height` 上限、8 列网格、格子悬停与当前项高亮、名称省略(8 列必须对齐)、分页器分隔线,以及按钮的图标位与文字位。④ **守门为什么没拦住(本版最重要的部分)**:`verify-wiki-focus.mjs` 原本断言的是 `assert.match(settings, /dsh-tw-iconpicker/)` —— 检查「TS 源码里出现过这个字符串」。但**类名当然会出现在 TS 里**(它是 `make('div', 'dsh-tw-iconpicker')` 的参数),所以组件一条 CSS 都没有时这条断言照样绿。**通用教训:断言「某个类名存在」不等于断言「这个组件可用」。** 渲染型组件的守门至少要跨两个文件——一个说「我生成了这个 DOM」,一个说「这个 DOM 有样式」。⑤ **新守门(双向)**:弹层 / 网格 / 格子 / 分页器四个类必须真有 CSS 规则;并且从 picker 源码里**解析出所有 `make()` 生成的 `dsh-tw-*` 类名**,逐个要求它们在 `styles.ts` 里有下落 —— 这条通用网能拦住「下次再加子元素、又忘了写 CSS」。**已反向验证**:删掉那段 CSS → 红;恢复 → 绿。⑥ **新守门当场抓出第二处漏写**:按钮的文字位 `.dsh-tw-settings-iconbtn-label` 同样没有样式。⑦ 复核:`typecheck` / `build`(`client.bundle.js` 701305 → **704155 字节**,并确认 CSS 真进了 bundle)/ `verify:static` / `verify:unit` / `verify:e2e` / `verify:large` 全绿。
112
- - **v0.28.8**(2026-09-28):**多库第五批反馈(12 条一次做完)+ 拆大文件**。① **图标选择器接上 DSH 官方图标系统**(反馈 1)—— 从 8 个手绘图标扩到 **36 个官方图标**:没有 `require` 上游的 React 图标组件(客户端是纯 DOM,那样会引入运行时依赖、还会让改名对不上),而是把**上游的 path 数据内联**进 `src/client/wiki-icon.generated.ts`(由 `scripts/gen-wiki-icons.mjs` 生成,`scripts/extract-dsh-icons.mjs` 是取图素材的开发助手);实心图标(data / plugin / settings / dark)与线性图标分两套渲染。弹出网格 **48 个一页**分页,另有 8 个自绘图标与一组 emoji。守门断言 host↔client 的图标名单**双向完全一致**(少一个即红)。② **修「选了图标立刻变回默认」**(反馈 2)—— 根因是 `/admin/wikis` 的**读取视图漏了 `icon` 字段**(`buildWikisView()` 逐字段手写,加字段时漏了一处):写进 `wikis.json` 了,读回来是 `undefined`,客户端拿它重渲染就回落默认图标 —— 症状"完全像没保存"。补上 `...(entry.icon !== undefined ? { icon: entry.icon } : {})`,并加**源码级守门**钉死该字段(去掉修复即红)。③ **多库下设置页按作用域分页**(反馈 3/4/11)—— 十几块面板挤一页,既分不清「改这个库」还是「改整个插件」,改完也找不到退出口。现在分「**总览 / 本库配置 / 全局**」三个 tab;「插件管理(自带官方插件)/ 主题管理 / 语言管理 / 初始化」这些 **per-库** 面板只在你点了某个库那一行的「配置」之后才出现(此前它们和全局项混排,看着像所有库共用一个设置);「配置」按钮变成**开关**(再点 = 退出配置),顶部「配置作用域」条一直显示正在改哪个库。**单库安装没有 tab 条**,就是原来那一页。④ **新会话也能选知识库**(反馈 9)—— 选择器此前只长在 `conversation.input.dock`(新会话还没有输入框)。现在挂在**预设/模式选择旁边**:先声明式探测 `conversation.input.selector.context`,2 秒内没等到就回落到 dock,两条路径带 `blankOnly` 互斥,不会同时出现两个。⑤ **剪藏可指定落哪个库**(反馈 8)—— 新增 `bridge.wiki` 配置与设置页下拉(留空 = 默认库)。剪藏来自浏览器书签、没有"当前会话"可以跟随,所以这里必须是**显式钉死**;填了不存在的 id 时 `/clip` 回执会带 `wiki` 字段说明实际落点,插件日志也提示一次。⑥ **回复流卡片与会话「知识库」Tab 按会话作用域取数**(反馈 7/10)—— 卡片取数 / 链接 / 缓存 key / 汇总的存在性探测与渲染全部带上会话作用域的那个库;不这样做会出现"A 库的笔记标题在 B 库里渲染成空白卡片"。工具卡片右上角还会显示库名徽标。⑦ **桌面版能起 TW**(反馈 12)—— TW 子进程需要真正的 `node`,而桌面版里 `process.execPath` 是 Electron。现在按 `DSH_TIDDLYWIKI_NODE` → `where node` / `which node` → `process.execPath` 的顺序解析并缓存,启动日志写明用了哪个;新增 `scripts/verify-desktop-node.mjs` 做**行为级**断言(进 `verify:unit`)。⑧ **复核后确认无需改动的两条**:注入提示词**本来就是按会话求值**的(`systemPrompt.section` 的 text 是函数,DSH 每次装配传入该会话的 agent,插件据此读作用域库的 `prompt.*`)—— 在输入框上方换库,**下一步**就生效,不需要重启或新开会话;15 个 `tiddlywiki_*` 工具在多库下全部正常(可见库 >1 时每条回执由一处包装统一标明库名)。单库安装两条都是逐字节不变。⑨ **代码清理**(反馈 5)—— `tools.ts` 1913→132、`admin.ts` 1265→24(barrel)、`index.ts` 1407→1001、`note-widget.ts` 1245→916、`settings-page.ts` 1815→400,**全部纯搬移**(15 个工具的注册顺序、`apply()` 的装配顺序、所有注释与字符串逐字保持)。配套新增 `scripts/lib/source-family.mjs`:`readFamily()` 把 `<base>.ts` 与 `<base>-*.ts` 拼成一份文本,12 个守门脚本改用它 —— **以后纯搬迁不再假红**(断言的是"规则"而不是"路径")。`verify-frame-guards` 的 runSwitch 断言跟着新形状更新(`disposed` → `deps.isDisposed()`),并顺手收紧成必须命中**那一条**消息(旁边 `repointDefault` 的「已取消修改」曾因前缀相同而误判通过),两条都做了反向验证。⑩ **文档**:`docs/usage.md` 补 `bridge.wiki` / 设置页分页 / 新会话选择器 / 每个库一个图标 / 桌面版 node;`docs/features.md` 补四条能力;README 顶部版本记录同步。**验证**:`npx tsc --noEmit` 全绿、`npm run build` OK(`client.bundle.js` 701305 字节 / 684.9 KB,远低于 900KB 闸门)、`verify:static` / `verify:unit`(26 个脚本)/ `verify:e2e` / `verify:large` 全绿(含 `WIKI FARM BOOT` 真起两个 TW 子进程、`LARGE WIKI` 3100 条 64s)。
113
- - **v0.28.7**(2026-09-28):**会话知识库选择器改成与「快速笔记」同一行 + 单库专属配置项不再在多库下出现**(两条都是作者报障)。① **选择器"没和对话框对齐"** —— 上次的修法方向就错了:`conversation.input.dock` 是**竖向 flex 列,一个条目独占一整行**,选择器和快速笔记是**两个条目**时永远是上下两行,怎么调内边距都不可能并排。现在选择器不再是独立条目,而是由快速笔记条目通过 `scope` 参数渲染在**自己这一行里**(按钮前面),整行统一与输入框右缘对齐,一行只测一次。顺手修掉合并时的一处遗留:选择器组件里还留着上一版的 `alignDockEntry` 自测,并入同一行后会把右内边距补两遍——已移除(`wiki-scope` 也从客户端 slot 表里消失,回归由 `verify-wiki-focus.mjs` 断言"input.dock 只能有一个条目"守住)。② **「侧边栏 TW 入口显示名称」在多库下不该出现** —— 多库时侧边栏是每个库各占一行、各用自己 `wikis.json` 里的显示名,`ui.sidebarLabel` 只喂单库那一行,所以多库下它是个**改了不生效的假配置项**。现在该字段只在单库模式渲染(多库时换成一行说明"去知识库列表改各自的显示名");模式探测失败**回落成单库**(保守:宁可多给一个真能用的字段,也不要因为一次请求失败把设置项藏了),并反向断言不许写成 `!== 'single'`(那会把未知模式误判成多库)。两条守门断言都做了反向验证。
114
- - **v0.28.6**(2026-09-28):**修两个多库界面的真机报障**。① **「插件管理 / 主题管理 / 语言包 / 初始化」看起来是混在一起管理的** —— 宿主侧一直是 per-request 解析目标库的(`deps.server(req)`/`config(req)`/`getClient(req)`),缺的是**客户端那一半**:设置页只有 `state`/`config`/`prompt` 三处带上了 `?wiki=`,而**插件信息与初始化 seed 的调用没带**,于是它们永远落在默认库上,「切了库、面板内容却没变」。现在 7 个 per-wiki 端点全部经 `withWiki()`,并有守门逐个断言(去掉任一处的作用域 → 红)。单库(未选库)时 `withWiki` 原样返回 URL,现有用户逐字不变。② **左侧入口点一个却三个都高亮** —— 我上一版把「面板是共享的」错解成「所有行一起高亮」,但面板一次只显示一个库(当前焦点库)。现在只有那一行点亮,并订阅焦点变化重算;单库默认行在面板打开时仍正常高亮。两条守门断言都做了反向验证。
115
- - **v0.28.5**(2026-09-28):**修「多库模式下侧边栏多出一个没有库名的 `TiddlyWiki` 入口」**(作者报障)。根因是**同一个 CSS 陷阱踩了第 12 次**:`hidden` 属性在 CSS 里只是 `display:none`,而 `.dsh-tw-entry` 有显式 `display:flex` —— **显式 display 盖掉了 hidden**,于是那行让位给每库入口的默认行照样渲染。这次不再给它单独补一条规则,而是**收敃成一条全局兜底** `[hidden] { display: none !important; }`,并删掉本仓库历史上为此在不同元素上各补的 **12 条逐元素规则**(面板 iframe / 错误块 / 快速笔记 / FAB 菜单与提示 / 右侧栏 / 目标库下拉 / 草稿栏 / 最近列表…)。守门同时改为断言「全局兜底存在 + 不允许再出现逐元素版本」,并反向验证(把全局规则改成挂在类名下 → 红)。
116
- - **v0.28.4**(2026-09-28):**每库自定义图标 + 修「右键菜单切库后选中项不更新」**。① **右侧「知识库」菜单的选中项停在旧值** —— 根因是选中标记(`●`/`○`)只在**菜单构建时**算了一次,之后无论怎么切都不重画;现在改为从当前焦点重算,并在 `setFocusWiki` 订阅里即时重画、每次打开菜单前再兜一次(切库也可能来自侧边栏入口或快速笔记卡片)。② **每个库的入口图标可自定义**:`wikis.json` 的条目新增可选 `icon` 字段,值可以是内置图形名(book / briefcase / home / notebook / flask / globe / star / archive,各是一枚继承 `currentColor` 的手写 16×16 线性 SVG,两套主题下都对)或**一个短 emoji**(📚 / 💼 / 🏠 …,用来一眼区分四五个库,不用下载任何资源)。设置页「知识库列表」每行多了图标下拉(当前值若不在候选里会保留,不会被抹掉)。校验口径:名字或 ≤8 字符的可打印短串,超长与控制字符一律拒绝;空值 = 清除自定义回落默认。**emoji 用 `textContent` 渲染而不是 `innerHTML`** —— 这个值来自用户可编辑的文件,绝不能变成注入点。缺省不设 = 内置默认图标,升级用户看到的一模一样。
117
- - **v0.28.3**(2026-09-28):**修三个真机报障 + 插件说明补「多库 / 升级」两节**(作者拆库实测中发现)。① **单库模式下快速笔记露出一个没有选项的「写入」下拉** —— 根因是 **CSS 的 `hidden` 属性只是 `display:none`,任何显式 `display` 都能盖掉它**:我给 `.dsh-tw-note-wiki` 写了 `display:inline-flex`,于是 `hidden` 失效,那个空下拉照常渲染。修法两层:CSS 显式加 `[hidden]{display:none}`(且必须写在设 `display` 的那条**之后**,同优先级靠后者生效)+ 组件侧兜底(单库时 `disabled` 并直接 `display:none`)。② **会话级知识库选择器没有和输入框对齐** —— 根因是**规则只写在了一个使用者的内部**:找 composer 输入卡片、按差值补 `paddingRight` 的整套测量逻辑原本长在 `quick-note-dock.ts` 里,第二个 dock 条目加进来时没人知道要对齐。已抽成 `client/dock-align.ts`(`alignDockEntry`)两处共用,并守门断言「条目内不得再自带测量实现」。③ **左侧入口按库显示** —— 多库时每个**在运行**的库一个入口行、用各自的显示名;点该行**先切焦点库再开面板**(否则点 A 打开 B);当前焦点库有左侧标线。单库安装仍走原来那一行 + `ui.sidebarLabel`,逐字不变。④ **插件说明新增两节**:升级用户最大的困惑是"装完了不知道有这个东西",所以明确写了——升级后**不会自动变成多库**、行为与升级前一致;想用多库去哪点;`wikis.json` 在哪;旧 `location.json` 在哪些模式下还权威;每个库要有自己的 `tiddlywiki.info`。守门 `verify-wiki-focus` 扩到 19 条(`hidden` 那条已反向验证)。
118
- - **v0.28.2**(2026-09-28):**设置页去掉重复的「知识库位置」**(作者反馈:多库配置做完后它重复且逻辑冲突)。这一块的语义是「把**唯一的那个库**换到别处」——写的是 wiki 之外的指针文件 `location.json`,措辞也是单库时代的("切换到这个位置" / "恢复为配置默认")。现在**按模式二选一**:**单库模式**保留它(那里它是唯一正确入口);**多库模式整块不渲染**,某个库换目录收到「知识库列表」那一行的**「改目录」**(默认库经 `/admin/wiki/switch` → 改清单 + farm 收敛 + 校验;非默认库直接 `update`)。宿主接口**没有**冲突也**没有**改动:`/admin/wiki/switch|reset` 从 v0.28.0 起就按模式分派。守门:`verify-wiki-focus` 新增"多库模式下不得渲染该块"的断言;farm-boot 补上 location 面接线(22 条),保证藏了界面之后**接口仍按模式正确分派**。
119
- - **v0.28.1**(2026-09-28):**修「拆库 skill 在 wiki 启动慢 / 起不来时永远装不上」+ 快速笔记的目标库可切换(R7)**。① **真实事故**(作者升级 0.28.0 后重启宿主时发现,测试没拦住):技能安装写在了 `farm.startAll()` **之后**,而作者的知识库很大、TW 冷启动要几十秒 —— 于是"技能装没装"取决于"wiki 起得快不快";更糟的是 wiki 若因配置问题**永远起不来**,技能就永远不装,而它跟 wiki 毫无关系(只往 `$DSH_HOME/skills/` 写一个文件)。已挪到启动任务**最前面**,并新增**顺序守门**:`verify-skill-install` 断言 `installSplitSkill()` 出现在 `farm.startAll()` 之前(已反向验证:在安装前插一次 startAll → 该条红)。② **R7 快速笔记目标**:卡片新增「写入」目标库,**默认跟随 GUI 的焦点库**(你在看哪个库,快速笔记就进哪个库),卡片内可临时改且改过之后不再被焦点带走。设计的核心是**整张卡片一起换库** —— 标签建议(memo 以 URL 为键,换库自动重读)/ 最近列表 / 草稿读取 / 附件上传 / 保存 / 随后弹出的 TW 编辑器,六处全部经 `withWikiQuery()`(`?wiki=<id>`)与 `twProxyFor()` 定向;只改保存那一处会产出"标签列表来自 A、笔记写进 B",比不做还糟。目标库没在运行时卡片会**先讲清楚**("先在右下角「知识库」菜单里打开它"),而不是让用户写完一段再失败;这里刻意**不自动启动** —— 启动是「知识库」菜单的职责,保存路由顺手 spawn 进程不该由一次误点触发。单库安装不显示选择器、请求 URL 逐字不变。守门 `verify-wiki-focus` 扩到 16 条(含 `withWikiQuery` 的四条纯逻辑断言与"整张卡片同库"的源码级断言,已反向验证);`verify-tw-origin` 的弹窗接线断言跟着间接层更新(保留强度)。
120
- - **v0.28.0**(2026-09-28):**多知识库**——同一进程内可同时运行多个 TiddlyWiki,每个库独立配置、独立 git,Agent 侧按**会话**选择作用域,且每个库可对 Agent **彻底隐身**。起因是作者自己的库:`书籍` 语料 2468 条与日常笔记挤在一个 800MB 仓库里,Agent 的检索 / lint / backlink 每天在约 **30 倍于工作集**的内容上跑(「先窄后宽」是缓解不是解法)。而拆库必须**同时在线**——只切换位置的话,你切到语料库翻资料时 Agent 也跟着切过去了。**① 三个模块各司其职**:`host/wiki-registry.ts`(清单 / 模式 / 动作规则,纯函数)、`host/wiki-instance.ts`(**一个**库的运行时:TW 子进程 + REST 客户端 + 配置覆盖层 + fs 监听 + 自身自举)、`host/wiki-farm.ts`(**谁该在跑**:reconcile 规则 + 会话作用域解析);`apply()` 从"抱着一个实例"变成"驱动一个农场"。**② 模式开关**:`wikis.json` 的 `mode: 'single' | 'multi'`,**缺省 `single`** —— 升级用户的行为逐字不变(位置仍由旧指针文件 > cordis 默认决定,registry 此时只记住模式与候选列表)。**③ 路由按库定向**:`/tw/<id>/…`,且每个库把自己那份同源代理基址 seed 进**各自的** `$:/config/tiddlyweb/host`(TW 用它拼**所有** API URL;不区分 id 就会出现"页面显示 A、每次读写落在 B");裸 `/tw/` 保留为默认库别名(旧链接 / 书签 / 已 seed 的值都不失效)。为此引入**保留 id 名单**(`status`/`files`/`recipes`/`render`… 与 TW 自己的根路径撞名时无法区分,一律拒绝注册)。非代理路由统一吃 `?wiki=<id>`。**④ git 按仓库重构**:多个库可以共用**一个**仓库(按文件夹物理划分)。`git add -A` 在子目录执行也作用于整个工作树,所以自动提交必须**按仓库**去重(`host/repo-committers.ts`,提交在仓库根执行;`dir → repo` 只缓存**肯定**结果——否定结果缓存会让全新安装的自动提交永久静默失效);`pull()` 回传改动路径,**只重启内容真的变了的库**("这次 pull 改了东西"与"这个库快照过期了"是两个问题)。**⑤ Agent 侧**:会话级作用域(`$DSH_HOME/dsh-tiddlywiki/sessions.json`,单选,软失败——读不到就只是"没选过")+ `agentVisible`(**彻底隐身**:不进选择器、不进提示词、工具不可达)。工具按会话路由,且**可见库多于一个时每条回执都标明库名**(一处包装实现,不是改 15 个 render);注入提示词改为**按会话求值**(`PromptSection.text` 支持函数,DSH 每次装配传入该会话的 agent)并读**作用域那个库**的 `prompt.*`——单库安装的 slim 仍是 1918 字符,逐字节不变。**两条刻意的拒绝**:作用域指向隐身库 → 忽略(隐身是硬边界,过期选择不能绕过它);指向可见但**没在跑**的库 → **报错并带上库名**,绝不静默落到别的库(静默换库 = 笔记进了用户没指定的知识库而且看起来一切正常,这是本功能唯一真正危险的失败方式)。**⑥ GUI**:`/status` 回传知识库名册;输入框上方的**会话选择器**(只在可见库多于一个时存在);右下角「知识库」菜单的知识库分组(点开一个没在跑的库会**显式 POST** 启动它——代理路由绝不在 GET 里 spawn 子进程);设置页新增「知识库列表」(模式切换、增删改、启停、设为默认、可见性、开局自启、新增;「移出列表」明确写着**不删目录**)与**显式配置作用域**(每个库的配置在它自己的 tiddler 里,不说清就会出现"改了但不生效")。**⑦ 拆库引导**:`docs/wiki-split.md`(人读 + 顶部一整段**可直接复制给 Agent** 的话)+ 随包 skill `skills/wiki-split/SKILL.md`(启动时装到 `$DSH_HOME/skills/`;**只覆盖带标记的文件**,用户自己写的 skill 一个字节都不碰)。**守门**:新增 6 个脚本——`verify-wiki-registry`(18 条)、`verify-wiki-farm`(12 条,注入假实例钉死 reconcile)、`verify-wiki-farm-boot`(**22 条,真起两个 TW 子进程**)、`verify-session-scope`(13 条)、`verify-repo-keying`(11 条,**真 git 仓库**)、`verify-skill-install`(7 条)、`verify-wiki-focus`(13 条,真读 client 源码),**全部反向验证过**;另有 5 个既有守门(drain / language-pin / frame-guards / ready-policy / tw-origin / conflict-config)跟着间接层更新,同样保留强度并反向验证。**未做**:per-wiki `auth.*`(仍是全局)、快速笔记目标切换(目前写默认库也显示默认库,一致但不是最终形态)。
121
- - **v0.27.0**(2026-09-28):**重新定位:从「AI 记忆工具」改写成「你和 Agent 共用的本地文档中心」——纯元数据 + 文档发布,运行时行为零变化。** 触发原因:生态里「记忆 / 知识库」这一格已挤进 30+ 个插件(最大的 `MemOS` 已 11,603★),而本插件真正的差异从来不是「更自动的记忆」,而是**人机共用同一份纯文本文件**:人能读、能改、能审计、能随时带走,Agent 用同一套工具读写它。本次把这件事搬上门口那块招牌:① `package.json` 的 `description` 由英文功能清单改为双语定位,并明确写出**不是自动记忆插件、不做后台抓取**,`keywords` 补 `knowledge-management` / `local-first` / `journal` / `publishing` / `wechat`;② GitHub topics 同步补位,便于目录站归类;③ 修正 README 一处过时数字(「Agent 用 10 个工具」→ 15 个)。**无代码改动**——「装完空手能用」(自动建库 + 起步 seed + 快速开始 7 步)自 v0.22.0 起就已具备,本次只是把它讲清楚。
122
- - **v0.26.7**(2026-09-26):**修「DSH 桌面版里 TW 面板没有『添加条目』按钮」——嵌入式 TW 被渲染在自定义 scheme 上,于是它根本没有同步器**。现象(用户报障,桌面版):TW 面板能看能搜,但页面控件里 **添加条目 / 添加日志 / 添加图片 / 导入 / 条目管理器** 整排不见,单条笔记的 **编辑 / 克隆 / 删除** 也不见,像装了个只读版 TW。**根因不在本插件,而在 TW 内核的一处协议判定 + 桌面版新增的渲染 scheme**:TiddlyWiki 自己的 TiddlyWeb sync adaptor 只在**文档协议以 `http` 开头**时才导出自己(`plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js`:`if($tw.browser && document.location.protocol.substr(0,4) === "http")`);而桌面版用自定义 scheme `dsh-app:` 渲染界面(已在本机运行中的 Electron 渲染进程命令行里核实 `--standard-schemes=dsh-app --secure-schemes=dsh-app …`),于是 iframe 里的 TW **没有 syncadaptor → 没有 `$tw.syncer`**:`$:/status/IsReadOnly` 永远没人写,TW 自己的只读样式表 `$:/plugins/tiddlywiki/tiddlyweb/readonly`(`<$reveal … default="yes">`,**状态未知即当只读**)就把那批按钮 `display:none` 掉;同时 saver 退化成本地 `dirtyTracking`,**在那儿做的编辑根本存不回 wiki**。实测复现(不依赖桌面版):把同一份 wiki 页面用 `file://` 打开(同样非 http),+ 同样消失——所以这不是「按钮坏了」,而是「这个文档里 TW 没有同步器」。**修复思路:让嵌入式 TW 跑在真正的 http origin 上**。① 宿主 `/status` / `/edit` / `/session/summary` 在原有相对字段旁,新增**绝对孪生字段** `twProxyAbsolute` / `twUrlAbsolute`(`absoluteHostBase()`,由请求 `Host` 头推导;脏头一律拒绝 `/^[A-Za-z0-9.\-[\]:]+$/`,避免把路径/凭据拼进回给浏览器的 URL);② 客户端新增 `resolveTwUrl()`(`endpoints.ts`)——**页面本身是 http(s) 时继续走相对路径**(loopback / 局域网 / Tailscale / 域名 / HTTPS 行为一字不变,绝不把 https 页面改源),**只有页面不是 http(s) 时**才改用宿主回环 http 基址,于是桌面版 iframe 里 TW 的协议变成 `http:`,sync adaptor 正常加载、同步器回来、按钮与保存一起复原;③ 随之而来的跨源:桌面版下 TW frame 与宿主页面不同源,`tw-frame.ts` 的 hash 导航把「读跨源 `$tw` 抛 SecurityError」包进 try/catch 并直接走整页兜底(不再冒泡、链接不丢),`theme-sync` 本就是 best-effort 跨源静默。守门:新增 `scripts/verify-tw-origin.mjs`(7 条:Host 头推导 / 脏头拒绝 / http(s) 忽略绝对基址 / `dsh-app:`+`file:` 采用绝对基址 / 老服务端不回归 / 两处接线断言 / 跨源兜底源码断言),`verify-frame-surface` 再增 2 条行为单测(`dsh-app:` 页面必须把 frame 指到宿主 http 基址、且 http 页面仍用同源相对地址;跨源 frame 读 `$tw` 抛错时必须整页兜底)。**注意**:这修的是「TW 被非 http(s) 文档嵌入」;「在浏览器里直连 TW 子端口(`http://127.0.0.1:<port>/`)」是另一回事——那样 TW 的 API 基址会被指到 DSH 代理前缀而 404,同样显示成只读,请一律用 DSH 同源入口(面板 / 右侧栏知识库 tab / `<DSH origin>/dsh-tiddlywiki/tw/`)。
123
- - **v0.26.6**(2026-09-26):**修 `tiddlywiki_append` 的 `heading` 在 CRLF 笔记上完全失效、且静默回退到文末**(作者本机知识库的一篇 25KB / 454 行长笔记上连续踩两次后定位到源码)。现象:对这篇笔记连做两次带 `heading` 的 append,内容**全部**落到了文末,而回执照样打印「段落「X」」——看起来完全成功。**根因**:`insertIntoSection` 用 `base.split('\n')`,而 CRLF 正文的**每一行结尾都留着 `\r`**;JS 里 `(.*)` **不匹配 `\r`**、`$` 也**不匹配 `\r` 之前的位置**,于是 `/^#{1,6}\s+(.*)$/` 对**每一个**标题都匹配失败 ⇒ `start < 0` ⇒ 走「追加到文末」的回退分支。**只在 CRLF 笔记上复现**(纯 LF 笔记一切正常),所以它看起来像偶发;落盘文件的「**376 个 CRLF + 78 个裸 LF**」混合行尾正是走过该分支的签名(`lines.join('\n')` 保留每行原有 `\r`、只把新块用 `\n` 接上)。**修复三处**:① 标题匹配前**只**剥掉行尾 `\r`(原文行尾一个字节都不动;**没有**改成 `split(/\r?\n/)`——那会让每次 append 都把整篇重写成 LF,炸出全文件 diff);② 新块按**文档自己的行尾**写入(`base.includes('\r\n')` 判定),CRLF 笔记不再越写越乱;③ `insertIntoSection` 改为返回 `{ text, matched }`,`tiddlywiki_append` 把它作为 **`headingMatched`** 一并返回,未命中时回执改为「⚠️ 未找到标题「X」,已改为追加到文末」——**不再把「定位失败」伪装成「写进了那一节」**(Agent 因此完全无法自检,这是本 bug 最坑的一点)。顺带把 `heading` 的真实落点语义写进 schema:实为「该标题之后、**下一个任意级别标题之前**」,标题后紧跟子标题时落在两者之间(=章节开头),并非 schema 原先暗示的「整节末尾」。守门:`verify-tools` +4 条 E2E(CRLF 正文的笔记必须 `headingMatched=true` 且标记落在目标段内、段内不得混入裸 LF;未命中必须 `headingMatched=false` 且回执含「未找到标题」、不得再宣称写进该段落;命中时的反向保护;wikitext `!` 标题同样可定位),**已反向验证**(撤掉 lib 里的行尾 CR 剥离即红 1 条)。
124
- - **v0.26.5**(2026-09-23):**修 P0:`fields` 参数被按字符拆成几十个垃圾字段**(用户实测复现两次后报障)。现象:`tiddlywiki_put` / `batch_put` / `append` 传 `fields: {"review-after":"2026-12-22"}` 后,条目上出现 `0={`、`1="`、`2=r`… 几十个**单字符字段**,真正的自定义字段**一个都没写进去**;又因为 `fields.type` 是**改内容类型的唯一入口**,这期间也无法把条目写成 wikitext / `text/css`。**根因两层**:① 参数 schema 声明为 `{type:'json'}`,而 DSH 的 schema 里 `type:'json'` 是**纯注解**——编译后连 `type` 都没有(`src/sdk.ts` 的 `compileValue`),模型收不到「这是个对象」的信息,于是把 JSON 当**字符串**发来;② 新建笔记 + 自动工作区标记走 `withWorkspaceMark()` 的 `{ ...(fields ?? {}) }`,而**展开字符串是按字符展开**的(`{...'{"r"'}` → `{0:'{',1:'"',2:'r'…}`),这些索引键随后被当成自定义字段写入;旧 `applyCustomFields()` 对非对象又是**静默 return**,所以全程没有任何报错。**修复(三处)**:① 三个写工具的 `fields` schema 改成真正的 `object` + `additionalProperties: true`(模型能直接看到类型);② 新增 `normalizeFieldsArg()`(`write-policy.ts`)——对象原样返回、`undefined`/`null`/空串视同未给(保留基底)、**JSON 字符串解析成对象**(兼容仍按字符串发参的模型)、数组/数字/解析不出对象的字符串**明确抛错**;`defineTool` 相应新增 `normalizeArgs` 钩子,让归一化跑在 schema 预校验**之前**(放到之后会被「must be an object」先拦掉,兼容层就没机会生效);③ `applyCustomFields()` 遇到非对象**抛错**而不是静默忽略,`withWorkspaceMark()` 只为已归一化的对象做展开。守门:`verify-write-policy` +5 条纯函数(字符串解析 / 对象原样 / 三种坏输入拒绝 / 字符串在 `buildWriteTiddler` 处被拦 / 归一化后无索引键),`verify-tools` +4 条 E2E(三个工具的 schema 必须 `type:object`、按字符串发参仍写对字段且不产生 `0=` 垃圾、坏输入明确报错且不留半成品条目、`append`/`batch_put` 走同一套归一化)。**注意**:本次修复只影响写入端;已被写进条目的垃圾字段需要重建该条目(`fields` 只能覆盖、不能删除单个字段),本机知识库已扫描确认无残留。
125
- - **v0.26.4**(2026-09-23):**修「发送给 Agent」因配置被浏览器自动填充而 404 + 设置页文本框防自动填充**。实测:设置页「TW 端请求基址」(`ui.sendToAgent.endpoint`)被浏览器自动完成当成 URL/账号字段填进 `/dsh-tiddlywiki/tw/root`,于是 send-to-agent 把请求拼成 `/dsh-tiddlywiki/tw/root/agent/sessions`,命中 TW 代理子路径 → 404「获取会话列表失败」。**两层加固**:① 设置页 `textField` 一律 `autocomplete=off`(`tokenField` 早已如此,URL 类文本框 `endpoint`/`git.remote`/`wechat.dsn` 等不再被自动填充污染);② send-to-agent bundle `baseEndpoint()` 校验自定义 endpoint——一旦含 `/tw/`、`/api/`、`/recipes/`、`/files/` 等 TW 子路径或非 origin/根绝对路径(判为浏览器误填),**回退到 `location.origin + "/dsh-tiddlywiki"` 自动推导**,按钮不再因误配 endpoint 而 404。另修 `scripts/gen-seed-send-to-agent.mjs` 模板字符串里一处未转义反引号(`ok:false`)导致再生成 seed 必崩的 bug。bundle send-to-agent 0.3.5→0.3.6。
126
- - **v0.26.3**(2026-09-23):**修正 starter-docs 教程里一处技术性错误说法**:「方法一」原来写「点标签后 TW 会自动生成该标签的页面,列出所有带此标签的笔记」——这不准确。实测(TiddlyWiki 5.4.1 内核,已核对 `$:/config/ViewTemplateBodyFilters.multids` 与 `$:/core/ui/TagTemplate`):内核**没有**"标签页"这种自动页面,点标签丸/侧边栏标签弹出的是**下拉列表**(`list-tagged-draggable`,全量不分页),直接打开以标签命名的条目只会看到条目本身(缺失则提示),不会自动列出成员笔记。教程现在如实描述弹层行为与它的局限(不分页、不可分享链接),并指出想要「一个页面 + 可翻页」的标签列表应自行写汇总页(笔记多时套分页)。纯 seed 文案修正,不影响任何运行时行为;新装 wiki 的「示例与文档」seed 应用新文案,已有 wiki 不受影响(seed 是 ONE-SHOT)。
127
- - **v0.26.2**(2026-09-22):**修「catalog 标题用目录名推导」——`codemirror-fullscreen-editing` 的真名其实叫 `$:/plugins/tiddlywiki/codemirror-fullscreen`**。`bundledCatalog()` 一直用 `$:/plugins/tiddlywiki/${目录名}` 拼标题,而 npm 包里这个插件目录名与 plugin.info 声明的 `title` 不一致(全包仅此一例,本机实测确认)。v0.26.0 引入的状态徽标按**完整 tiddler 标题**匹配,于是这个插件一旦在 TW 里被禁用,行内不会出现「TW 内已禁用」徽标(勾选不受影响——勾选按 `tiddlywiki.info` 的插件名)。**修复**:标题改为**优先取 plugin.info 的 `title`**,缺失/为空才回落目录名(plugin.info 才是 TW 真正加载的那个 tiddler,是权威来源)。守门:`scripts/verify-plugin-runtime.mjs` 新增 2 条——① 行为断言(fixture 里目录名与 `title` 不一致时必须采用 `title`,无 `title` 时回落目录名);② **不变量断言**(遍历真实 npm 包的全部自带插件,catalog 标题必须逐条等于其 plugin.info 声明的 title,并单独锁定 codemirror-fullscreen 这一例),**已反向验证**(把优先逻辑去掉即红 2 条)。
128
- - **v0.26.1**(2026-09-22):**修 P0:设置页插件/主题/语言的勾选框全部显示为「未勾选」,且误点「应用」会把启动清单整个清空**。回归由 v0.25.0 的 `CatalogPending` 引入(commit 883196f):契约是「`undefined` = 用户还没碰过、完全跟随服务器」,而挂载时把它初始化成了 `{ plugins: new Set(), themes: new Set(), languages: new Set() }`——`desiredPlugins()` 用 `??` 回退,而**空 Set 不是 `undefined`**,于是「没碰过」被读成「用户期望集合为空」:① 所有勾选框全空(用户看到「TW 里明明装着的插件,这里一个都没勾」——本次由作者实测报障);② 更严重的是,什么都没改就点「应用插件」会 POST 一个**空数组**,把 `tiddlywiki.info` 的 `plugins` 清单**整个清空**(主题清单、语言同理:一次误点等于卸载 codemirror/menubar/全部主题/中文语言,TW 重启后只剩内核 + 自动补装的 markdown)。**修复**:初始化改为空对象 `{}`;并给三个「应用」按钮加闸门——`pending.* === undefined`(未碰过)时按钮禁用、点击直接返回,只有真正改过勾选才允许提交(「用户刻意取消全部勾选」仍是合法提交,host 的 no-op 守卫照旧兜底)。顺带把「wiki 内已装」徽标的判据从 `desiredPlugins()` 改为服务器集合 `serverPlugins`——徽标描述当前事实,不该随未应用的勾选抖动。守门:`scripts/verify-plugin-runtime.mjs` 新增 4 条(空对象初始化 + 三个闸门 + 三个 sync 函数必须在 change 回调里重算按钮 + 徽标判据),**已反向验证**(改回空 Set 即红)。
129
- - **v0.26.0**(2026-09-22):**修「设置页插件管理与 TW 实际状态对不上」——两套真相源不再互相说谎**。现象(作者本机实测):TW 控制面板里明明启用着的插件(TiddlyFlex、mermaid、pinboard 等经 TW 原生插件库装的),设置页勾选清单里**根本不出现**;反过来 menubar / comments / help 在 tiddlywiki.info 里(勾选着),但用户早在 TW 面板里禁用了它们。根因:**设置页只读 `tiddlywiki.info` 的 `plugins` 数组(启动安装清单),而 TW 运行时集合 = tiddlywiki.info ∪ 以 tiddler 形式装进 wiki 的插件 − `$:/config/Plugins/Disabled/<title>`(正文为 `yes`,TW 核心自己的判定)**——两个面板读的不是同一份真相,谁也没错但谁也不全。**修复(方向「修真」)**:勾选语义保持「是否写入 tiddlywiki.info」不变(它只管引擎自带插件的离线启停,这是 TW 原生界面做不到的唯一能力),但把缺失的另一半真相显示出来——① 每个插件行加状态徽标:「TW 内已禁用」(勾选着但被 TW 禁用)/「wiki 内已装」(不在启动清单但 wiki 里有同名 tiddler 版本,TW 运行时已在用);② 新增只读小节「**wiki 内插件(经 TW 原生安装)**」,列出第三方插件 tiddler(名称/版本/描述/禁用状态),启停/卸载引导去 TW 控制面板;③ 区块说明文字写明两边分工。**实现**:`GET /admin/state` 新增 `runtimePlugins` 字段,由 `scanWikiRuntimePlugins()`(`src/host/admin.ts`)扫 tiddlers 目录得出——插件库安装的插件是 `$__plugins_*.json` + `.json.meta`(只读几十字节的 meta,不碰几 MB 的插件 payload;无 meta 的裸 `.json` 还可能是拖拽导入的**多 tiddler 数组**,一并解析);禁用标记 `$__config_Plugins_Disabled_*.tid` 按文件自身 `title:` 头还原插件标题(不做有损的文件名反解码)。扫描失败返回 `null`(读失败 ≠ 不存在),客户端隐藏徽标与小节而不是显示空的假象;`dsh/*`、`dsh-tiddlywiki/*` 命名空间(插件自己的种子 bundle)排除在外。产品哲学不变:插件对 wiki 的强制定制依旧只有 `--init server` 基线 + 自动补装 markdown + 3 个核心种子,官方自带插件之外的安装/管理全部留给 TW 原生界面。
130
- - **v0.25.1**(2026-09-21):**修「回复流里的 wiki 链接点了打不开」**——根因在 **DSH web 的 markdown 渲染器**把 `[标题](/dsh-tiddlywiki/tw/#标题)` 这类相对链接**解析成绝对 http(s) URL**,再按 protocol 判为「外链」(`target="_blank"` + 自己的 `openExternalLink` onClick)。旧 `installWikiLinkInterceptor` 只匹配相对路径正则 `^/dsh-tiddlywiki/tw/#(.+)$`,绝对 href 匹配不上 → 拦截器不接管 → 点击落入 DSH 外链逻辑,在新标签页打开 TW 编辑器而不是中央面板(用户感知「打不开」)。**修复**:拦截器改用 `new URL(href, window.location.origin)` 归一化后判**同源 + `pathname === TW_PROXY_BASE` + hash 非空**(相对/绝对两种形态都接),在 capture 阶段 `preventDefault + stopPropagation` 抢在 DSH 外链 onClick 之前打开中央 TW 面板;`scripts/verify-tool-views.mjs` 新增源码级断言(必须用 `new URL` + 同源校验 + 代理 pathname,且旧相对正则已移除)。headless Chrome 实测:TW 面板 hash 导航(含 TiddlyFlex 的 `$:/StoryList-1`)本身正常,问题确在拦截器漏匹配绝对 href。
131
- - **v0.25.0**(2026-09-20):**第五轮代码审计的修复版**(审计那天被用户判定为「本地单机可接受、不修」的只有一条:`POST /render` 的 **title 分支**可以经被渲染条目正文里的 `{{…}}` 转写把插件配置读出来——本机实测复现,`text` 分支早已 403;不修的理由是单机自用、且真正的边界本就不在这条路由(任何笔记正文的 `{{$:/plugins/dsh-tiddlywiki/config}}` 在嵌入式 TW 面板里同样会被转写渲染)。其余全部修掉,按性质分四组。**① 工具契约(数据安全)**:(a) **`tags: []` 现在真的清空标签**——此前 `normalizeTagArg()` 把空数组归一成 `undefined`(=「未传 = 保留原标签」),而 `tags` 又是 `RESERVED_TIDDLER_FIELDS` 成员(`fields.tags` 会被拒),于是**没有任何途径**把一篇笔记的标签改成空:模型照 `tiddlywiki_lint` 的 junk-tags 建议去清标签、传 `[]`,什么都不会发生,回执还把旧标签原样列出来(本机实测)。(b) `tiddlywiki_append` / `tiddlywiki_rename` / `tiddlywiki_batch_put`(逐条目 `expectedModified`)补上**乐观并发令牌**:这三条与 put/delete/attach 一样是「读全文 → 整篇回写」(append 读旧基底再加一段),而可传令牌的只有后三条 —— 注入提示词让模型回传 `expectedModified`,append 却**没有这个参数**,人类在 TW 编辑器里的并发修改会被旧基底静默回滚。(c) `tiddlywiki_rename` 更新引用时**逐个重读引用者**(列表只是快照),遍历期间被改动过的跳过并在回执里报出数量——跳过一条链接可以手工补,被覆盖掉的一段正文不可恢复。(d) `tiddlywiki_attach` 的 `noteTitle` 指向二进制附件时明确拒绝(会把 base64 写坏而回执看着正常)。(e) `tiddlywiki_lint` 的 `checks` **拼错不再谎报干净**:本机实测 `checks:["broken-link"]`(少了 s)在 2933 条笔记的知识库上**一个检查都没跑**,回执却是「扫描 2933 条文本笔记,发现 0 个问题 / 没有发现问题」;现在回执列出**实际运行**的检查、显式报出无法识别的名字,且「一个检查都没跑」不再等于「没问题」。(f) `valid-until` / `review-after` 的纯日期(`YYYY-MM-DD`)改按**当地日终**判过期,此前按 UTC 0 点算,东八区当天 08:00 就报「已过期」。**② 设置项(都是「保存了却不变」)**:`git.autoCommit`/`git.debounceMs`/`git.remote` 保存后**对运行中的插件立即生效**(`AutoCommitter` 在构造时把 enabled/debounceMs 快照进不可变 options,remote 只在启动 bootstrap 时应用——此前关掉自动提交仍会 commit、换远端仍推老地址,直到重启 dsh web);「重启 TW」按钮的 15s 超时改为 120s(`/admin/restart` 要等 TW 就绪,软窗口默认 60s、大知识库自述 44s+,此前会弹「重启失败」而宿主其实成功);数值项(`bridge.port` / `startup.readyTimeoutMs` / `ui.allArticles.pageSize` / `git.debounceMs`)加 `min`/`max` 与字段旁红字,非法值**拒绝保存**(宿主对越界值是静默夹取/回落,提交只会继续制造「回显 ≠ 生效」);**空补丁不再发请求**、宿主在序列化文本逐字节相同时**跳过 PUT**(每次空点「保存配置」都会重写配置 tiddler 并刷新 `.meta` 的 `modified` → 白造 git diff、两台机器各点一次就冲突在 `.meta` 上,与 v0.24.2 刚修的 `$__language.txt.meta` 同类);保存被拒(409,如配置 tiddler 残留冲突标记)时错误**持久显示**在配置区而不是一闪而过的 toast;微信发布的 `command`/`adapter`/`token`/`dsn` 补齐 UI(token 密码型 + 👁 显隐切换,掩码回填语义照旧);插件/主题/语言的未应用勾选提升为页面级状态,能活过「同步 / 重启」触发的重刷;表单有未保存内容时若配置在**别处**被改过,保留你的输入并显式提示,而不是重建表单把输入清掉;首屏加「加载中…」、加载失败不再清空已填表单。宿主侧 `/admin/config` 的补丁现在必须过新的 `normalizeConfigPatch()`:**已知数值夹取到真实边界、已知布尔/字符串类型错误返回 400 并点名字段、`__proto__`/`constructor`/`prototype` 一律拒绝、未知键透传**(此前任意 JSON 都会落进配置 tiddler——数组体变成 `{"0":1,"1":2}` 这种永久垃圾键,`git.debounceMs: "abc"` 会经 `??` 进 `setTimeout` 被当 0 → 每次写入都 commit)。**③ 会话「知识库」Tab**:`NOTE_TOOL_NAMES` 补 `append`(「增量写入」)/`attach`(「附件」)/`delete`/`trash`(`restore` 计产生),新增「🗑 删除 / 回收站」一节——此前**用 append 写的笔记在这个 Tab 里完全不可见**,而注入提示词恰好推荐「纯增量内容优先用 append」,于是一整轮工作会被显示成「本会话暂时没有产生、读取或检索过任何知识库笔记」;事件日志读不出来的会话**在页面上明说**(不再静默吞掉、把「读不出来」显示成「没碰过笔记」),`counts.sessions` 只统计成功读取的会话;读日志从串行改 **4 路有界并发**、探测整段加 **12s 墙钟预算**(最坏 ~375s → 有界,客户端 25s abort 之后不再继续压着 TW 打),生成加 **3s 单飞复用**;客户端自愈改为**三态探测**(在 / 被清 / 状态未知——未知既不计失败也不清零,此前 `catch { return true }` 把「探测失败」当「一切正常」),自愈 interval 与 `phase` 解耦(进 error 态后不再永久失去重试),并新增**按 3 分钟节拍的静默刷新**(tab 长期开着也能跟上会话,不闪 loading 不砸内容);`escapeInline()` 中和 `{}`(会话日志里出现的 `{{…}}` 会被 `/render` 转写展开)并改为**先截断再转义**(否则 `&amp;` 会被截成 `&am`);`scanRefs` 不再把标题里的 `)`/`]` 一律截断;`TitleState.snippet` 从死字段改为真的渲染。**④ 其余**:检索卡片的范围与工具结果对齐(卡片把模型可见回执里读到的 `workspace` 回传 `/search`,v0.24.0 只改了工具侧——卡片会列出**更多**的结果且不显示范围);`/status` 的 git 冲突内容扫描加上限(200 个文件 / 2MB,批量导入期间不再每 30s 同步读几千个文件阻塞事件循环);`tiddlywiki_delete` 的描述写明 `$:/` 系统条目实为永久删除;`TiddlywikiConfig.note` 补上漏掉的 `workspaceMark`(三处形状 lockstep);`tiddlywiki_attach` 有专属卡片。**守门**:新增 `scripts/verify-config-patch.mjs`(6 组纯函数断言:非对象拒绝 / 越界夹取 / 类型错误点名字段 / 危险键拒绝 / 未知键透传)与 `scripts/verify-tool-views.mjs`(5 条源码级断言:`TOOL_VIEW_KEYS` 与 host 工具名完全一致 / `TOOL_LABELS` 全覆盖 / 工作区前缀两处一致 / 搜索卡片仍回传 `workspace`),两者进 `verify:unit`;`verify-tools.mjs` +4 条真实 TW 断言(`tags:[]` 清空且「不传」仍保留 / append 令牌不匹配拒绝、匹配放行 / batch 逐条失败 / lint 未知检查名不得谎报);`verify-write-policy.mjs` +1 条(空数组 vs 不传的语义边界)。**已反向验证的路径**:把 `normalizeTagArg` 还原成「空数组 → undefined」、把 lint 的 `checks` 还原成自由集合、把 append 的令牌检查删掉,对应断言分别变红。
132
- - **v0.24.2**(2026-09-20):**修「`$:/language` 每次 dsh web 启动都被无条件重写 → 两台机器每次 pull 都冲突」**。现象:`git pull` 稳定卡在 `tiddlers/$__language.txt.meta` 上,而**正文文件根本不冲突、diff 出来只有 `created`/`modified` 两行**;本仓库的 git 历史里甚至已有一条专门清理该冲突残留的提交,而 2026-09-20 一次会话内又撞了两次。根因定位到行:启动自举应用 `uiLanguage` 时、以及设置页改动语言时,两处都**无条件 PUT** `$:/language`(正文每次都一样,但 TW 会给 `.meta` 盖新时间戳)——两台机器各自启动过就都改了同几行。现在抽出 `pinLanguageTiddler()`(`src/host/admin.ts`):**先读,内容一致就不写**,内容不同/条目不存在才写。⚠️ 关键边界:**读失败绝不等于「内容相同」**(铁律第三条)——`client.get()` 只有 404 返回 undefined、其余抛错,抛错时**照写并记一条日志**,否则用户的语言会永久卡在错的值上且重启也修不回来。**守门**:新增 `scripts/verify-language-pin.mjs`(进 `verify:unit`)——4 条桩 client 行为断言(一致不写 / 不存在要写 / 不同要写 / 读失败照写并报警)+ 源码级断言(`index.ts` 与 `admin.ts` 除原语自身外**一处裸 PUT 都不许有**,且两处都必须经 `pinLanguageTiddler`)。**已反向验证**:还原启动路径为无条件 PUT → 红 1 条;把读失败分支改成「直接 return false」→ 红 1 条。(附带一条方法论教训:第一次做这条反向验证时**构建因我的改写而失败**,而我把构建输出丢进了 `$null`,于是守门测的是旧 `lib/`、假绿了一轮;重做时把构建输出放回台面才抓到真红。反向验证必须确认「改动真的被编译进去了」。)
133
- - **v0.24.1**(2026-09-20):**修「重启/停止 TW 的四个路径里有三个没排干 syncer 队列」——一类静默丢写入的缺陷**。本仓库铁律第一条要求「重启 TW 前必须把 syncer 队列排干」,但那条规则**只写在文档里**:`POST /restart`、`POST /admin/restart`、`POST /admin/info`(改插件/主题后重启)与「知识库切换」的 `stopServer` 全都是**直接杀 TW 子进程**,而 TW 的 REST PUT 返回 204 时条目还在内存里、文件系统 syncer 要 ~250ms 后才落盘 —— 在那一秒内点「重启 TW」,刚写的笔记就随子进程一起消失(与 v0.19.0 丢 `tw-web-host`、v0.22.0 才发现 throttle 竞态同一类)。只有启动自举 / seed / `/sync` 三处走了 `flushPendingWrites`。**根因不是谁写错了某一行,而是「规则」和「代码」之间没有强制关系** —— 所以这一版不是补四个调用点,而是把不变量变成**原语**:新增 `drainThenStop()`(`src/host/seeds.ts`,**排干发生在它内部**,签名是 `{client, tiddlersDir, stop}`),四个路径全部改为经它停/重启,调用方**没法**绕过;`stop` 的返回类型放宽为 `Promise<unknown>`(`restart()` 回状态视图、`stop()` 回 void,排干都不关心)。`/restart` 与 `/admin/restart` 的回包新增 `drained`,排干不完整时如实上报而不是静默。**守门**:新增 `scripts/verify-restart-drain.mjs`(进 `verify:unit`)——① 顺序断言:`drainThenStop` 内部「排干的 await」必须早于「stop 的 await」,且排干失败必须走显式分支后**仍然 stop**(best-effort:不能因为排不干就把用户锁死在起不来的 TW 上);② 源码级断言:`routes.ts`/`admin.ts` 里**每个** `deps.server.restart()` 都必须作为 `drainThenStop({stop: …})` 的参数出现(裸 restart 即红),`index.ts` 的 `stopServer` 必须包在它里面;③ 行为断言:用桩 client 真跑一遍,确认「写入真的落盘之后才 stop」「client 缺失(TW 没在跑)是安全 no-op」「排干失败也继续 stop 并把警告交出去」。**已反向验证**:分别把 `/sync` 路径、知识库切换还原成裸调用、并删掉 `drainThenStop` 里的 `stop`,三次都稳定变红。顺带把 `/sync` 与 seed 两条**本来就在 flush** 的路径也收敛到同一原语,从此「重启前排干」只有一个实现。
134
- - **v0.24.0**(2026-09-20):**三处「Agent 读写 wiki 的规矩」按用户要求改进 + 一个 lint 误报修复**。① **新建笔记自动标工作区**:`put`/`batch_put`/`append` 新建条目时自动带上 **`ws/<项目名>` 标签 + `workspace` 字段**,项目名取自调用会话的工作目录(新 `src/host/workspace.ts` 纯函数:Windows/Unix 路径、尾斜杠、CJK、非法字符规范化;`home`/`tmp`/`desktop` 这类通用目录与**驱动器根目录**一律拒绝——后者是写这个模块的守门脚本抓出来的真缺陷:`C:\` 会被算成 `C:`,在盘根开会话时全盘笔记都挂上 `ws/C:`)。标记是**附加式**(调用方标签在前)、**仅新建**(覆盖不追打,也不会改写已有 `workspace` 字段)、`$:/` 豁免、`note.workspaceMark: false` 可关、调用方显式 `fields.workspace` 优先;`exec` 缺失 / 会话无 `cwd` / 未知会话一律**安全降级为不标记**(不猜、不抛错)。为什么标签必须带 `ws/` 前缀:裸项目名与真实业务标签撞车——作者库里 `dsh-tiddlywiki` 已挂着 2510 篇导入的书籍章节。② **检索先窄后宽 + 多词 AND**:`tiddlywiki_search` 先在工作区内查、**区内 0 条才自动扩大到全库**,回执明确写出「已在工作区 ws/X 内缩小范围」或「工作区 ws/X 内 0 条,已扩大到全库」(**「工作区内 0 条」不等于库里没有**,这是把「搜不到」错判成「不存在」的主要来源);`query` 从**整串子串匹配**改为按空白切词、**全部词命中**才算(旧行为连 `alphaprobe betaprobe` 这种自然的双词查询都永远匹配不到);调用方显式传 `tag`/`field`/`value` 时**不自动收窄**(显式范围不被二次猜测)。③ **过期内容淘汰策略(只提示,绝不自动删)**:阶段性笔记写 `valid-until`(硬过期)/ `review-after`(该复查),被取代时写 `superseded-by` + `superseded` 标签、**保留旧笔记**;`tiddlywiki_lint` 新增 `stale` 检查(默认开启、`staleAfterDays` 默认 180),报 `stale-expired` / `stale-review` 两类**硬判定**加一类 `stale-candidates`(带版本号或 `done` 标签且长期未改动,**明确标注「候选,非判定」**)。**淘汰只由人决定**——报告全程只读,不删不改不归档。④ **修 lint 死链误报**(v0.23.6 的内容并入本版):死链正则 `\{\{([^}]+)\}\}` 会把 `{{{ [tag[todo]count[]] }}}` 这类**三花括号过滤器表达式**从第 1 个 `{` 开始捕获成「转写目标」并报死链(实测线上 480 条死链报告里 8 条属此类),现在用前后瞻 + 捕获内容守卫排除。**明确不改的**:≈440 条真实死链**保留不修**——多数是用户手工删掉的笔记,属预期状态(只读报告是对的,自动「修复」是错的)。**守门**:新增 `scripts/verify-workspace.mjs`(进 `verify:unit`,9 组纯函数断言,含「任何 cwd 算出的标签都不得是垃圾标签 / 含空白 / 丢前缀」这条与 `isJunkTag()` 的跨模块一致性检查,以及 `ws/` 前缀不得被「简化」掉的回归),`verify-tools.mjs` +12 条真实 TW 断言(工作区标记六种形态 + 多词 AND + 先窄后宽 + `fellBack` + `stale` 三类),`verify-prompt.mjs` +1 条「三条新治理规则必须在场」并把 slim 长度预算从 1800 调至 2100(**预算上调与新增的逐条断言绑定**:删掉任何一条规则即红)。**所有新守门都做过反向验证**:分别还原旧行为(整串子串检索 / 关掉工作区收窄 / 关掉自动标记 / 关掉 stale 检查 / 去掉驱动器根目录判定 / 从规范化字符集里删掉 `|`)后,对应断言稳定变红。slim 正文实测 1918 字符(full 2722)。
135
-
136
- - **v0.23.5**(2026-09-18):**修 15 处审计缺陷**(第三轮全面审查列出的 18 条,逐条核实后修掉其中 15 条真缺陷;跳过 3 条纯理论性的信息泄露。完整审计报告见知识库笔记「dsh-tiddlywiki 第三轮代码审计」——审计报告是工作文档,不再进 git)。按性质分四组。**① 数据安全(最重)**:`tiddlywiki_delete` 原先**先删原条目、后读回收站索引**,于是「快照已写 → 原条目已删 → 索引读失败」时报"已中止",但笔记其实已经没了、且没进索引——`trash list` 看不见、`restore` 找不到、`empty` 清不掉,成为永久孤儿(只能从 git 捞);现在**读校验索引排在所有破坏性步骤之前**,中止即等于"没删"。同一处还有两个洞:`$:/` 系统条目会被**软删除**(删掉 `trash-index` 自己 → 下一次读 404 → 被当成"回收站为空" → 索引被重写成 1 条,丢掉全部记录),现在系统条目一律走永久删除分支,且**删回收站索引被显式拒绝**(要清空请用 `trash action=empty`)。`tiddlywiki_put` 不再把**二进制附件**写成文本(保留基底 `type: image/png` 会让 base64 解不出、图裂而回执说"类型没变";要转换得显式 `fields.type`)。`git pull` 不再**无条件 `rebase --abort`**——此前只要 pull 失败(含没配 remote、离线等与被 rebase 无关的原因),用户在 wiki 仓库里正在做的 rebase 与冲突解决就被清掉;现在只 abort「我们自己发起」的那个。乐观并发的两个令牌从 **OR 改回 AND**:`revision` 是 TW 的内存计数器(重启/拉取后复位),旧逻辑「`modified` 命中就放行、否则看 `revision`」会让跨重启的写入被判"一致"而**静默覆盖**人类改动,同时传两个令牌也**并不更安全**。配置 tiddler 的 `modified` 不再被钉死成 `created`(`ensureTiddlerTimestamps` 只补缺的那一半,用 now)。**② 崩溃与资源泄漏**:`fs.watch` 补上常驻 `error` 监听(Node 在无监听器时把 `error` 抛成未捕获异常 → 整个 `dsh web` 进程退出;Windows EPERM / Linux ENOSPC 都能触发);`WikiServer.startOnce()` 在 `spawn` 前**复查 `stopping`**(`stop()` 可在 `await findFreePort()` 期间跑完,此后 spawn 的子进程没人杀 = 孤儿 TW);知识库切换**回滚失败也必须重装 extras**(此前从 catch 直接返回、跳过 `setupExtras()`,此后所有 wiki 写入都不再被提交,而"迟到就绪"复探让 `/status` 看着一切正常——静默停摆);`runSwitch` 补 `disposed` 守卫(热重载/关闭期间的在途切换不再重新挂上 committer/watcher,也不在 teardown 之后再 spawn TW)。**③ 行为瑕疵**:`escapePromptBraces` 对**连续花括号**失效(`{{{name}}}` 会残留 `{{`,而 DSH 对未知变量直接抛错、能炸掉整个系统提示词装配正是该函数存在的理由);`?limit=` 空值不再被夹成 1(`Number('' ?? fallback)` 是 0,`/recent?limit=` 曾只返回 1 条而不是默认 15);`tiddlywiki_append` 在 `mode=prepend` 时不再回执谎报「写进了某段落」(`heading` 只对 append 生效);`POST /render` 的超限 body 从 400 改为 **413**。**④ 两处零成本的安全收口**:代理路径判定不再只看字面量 `/tiddlers/`——TW core 的 `get-tiddler-html` 路由是**单段**(`/^\/([^\/]+)$/`),把 `/` 编码成 `%2F` 即可绕过旧守卫(本机实测:修复前 `/tw/...%2Fconfig` 与 `/api/...%2Fconfig` 都返回 **200 + 715 字节配置 JSON**);`/render` 的 **`text` 分支**也不再是旁路(TW 会解析 `{{…}}` 转写,实测修复前 `{"text":"{{$:/plugins/dsh-tiddlywiki/config}}"}` 返回 200 + 3123 字节配置正文,净化器只管标签、管不住正文),`contextTitle` 一并校验。**守门**:`verify-write-policy` +6 条(令牌 AND 语义 +「证据不足判冲突」+ `onlyCreated` 不再钉死 modified)、`verify-prompt` +1 条连续花括号、`verify-frame-guards` +4 条源码级生命周期断言(`fs.watch` error 监听 / spawn 前复查 `stopping` / 回滚 finally / `runSwitch` disposed)、`verify-audit-fixes` +4 条 E2E(删除顺序:索引读失败后原条目必须仍在、`$:/` 与索引不得被软删、二进制附件拒写、`?limit=` 空值)、`verify-conflict-config-guards` +1 条**真 git E2E**(在真 rebase 中途 pull 失败,用户的 rebase 必须原样保留)、selftest +8 条(编码/双编码代理路径 403、`/render` 转写与 `contextTitle` 403、普通转写仍 200)。**全部 15 条修复都做过反向验证**:把修复还原后,新断言稳定变红。
137
- - **v0.23.4**(2026-09-18):**三处硬化,全部来自 v0.23.3 上线当天暴露的真实事故**。① **提交前拦冲突(数据安全)**:`git pull --rebase --autostash` 的 **autostash 重新应用**冲突后,`rebase --abort` 已无事可 abort,冲突标记留在工作树里,随后 60s 的 AutoCommitter 照常 `git add -A && commit` —— `<<<<<<< Updated upstream` 就这样被**永久提交并推送**进了 wiki 的配置 tiddler(`0b19ca6`,2026-09-17),插件随即解析不了自己的配置(微信发布一直是关的、`prompt.extra` 静默失效)。现在 `GitFace.conflictState()` 三重探测(进行中的 rebase / 未合并路径 / **工作树里残留的冲突块**),`commit()` 直接抛 `GitConflictStateError` 拒绝提交;`/sync` 回 409 + 文件名、agent 工具返回结构化失败、AutoCommitter 去重上报一次(不去刷屏);`pull()` 在 abort 后**复检**(autostash 形态正是 abort 之后才暴露);`/status` 与设置页状态行显示「冲突未解决(N 个文件,已阻止提交)」。⚠️ `MERGE_HEAD`/`CHERRY_PICK_HEAD` 故意**不**硬拒——`git commit` 正是收尾它们的动作(真 git E2E 抓出了这个设计错误)。② **配置解析失败拒绝写(数据丢失)**:`ConfigStore.set()` 在存量 tiddler 解析失败时,原先拿**空缓存**合并 → 一次设置页保存就把配置写成只剩表单里那几个字段的"残版"(18 日 13:38 实测:`prompt.extra`/`git.remote`/`ui.*` 全丢)。现在抛 `ConfigUnreadableError`、不 PUT,并把原因经 `/admin/state` 的 `configError` 渲染成设置页顶部红色横幅;瞬时**读**失败仍走"合并到缓存"(回归断言守住)。③ **adapter 版本校验**:`/wechat/ready` 原先只数文件在不在,旧版 adapter(不认识 `--title-file`、标题仍是必填位置参数)也会报"就绪",点按钮才在 opencli 里报"缺少必填参数"。现在按**定义**判新旧(`weixin-flow.js` 必须 `export function resolveNoteTitle(`、两个入口必须声明 `name: 'titleFile'`——**不是**字符串出现,注释里提到不算),`missing`(不存在)与 `stale`(过旧)语义互斥;按钮预检与路由 503 分别说「版本过旧」和「缺少发布脚本」。bundle 升 `0.2.0`。守门:新增 `scripts/verify-conflict-config-guards.mjs`(23 条:伪 exec 纯逻辑 + **真起临时 git 仓库的冲突 E2E**(真 merge 冲突 / 真残留标记,且解决后必须能提交)+ ConfigStore 拒绝写/回归/接线断言;**已反向验证**:拆掉 `contentScan` 或配置拒绝各红 2 条),`verify-wechat-publish.mjs` 17→24 条(+注释里提及新符号必须判旧、真源码符号对齐),bundle 守门 +3 条。
138
- - **v0.23.3**(2026-09-18):**TW 笔记工具栏新增「发布到公众号」按钮**(可选功能,与既有 CLI 等价,只是不用敲命令)。链路:TW 按钮 → `POST /dsh-tiddlywiki/wechat/publish` → 宿主 spawn `opencli weixin publish-note` → `GET …/wechat/publish/status` 轮询 → 覆盖层显示进度与日志尾部。**只到草稿箱为止**,发表仍由人工扫码(沿用 v0.23.2 定的策略)。三处硬骨头:① **标题不进 argv**——Windows 上 `opencli` 是 `.cmd` shim,必须经 cmd.exe 启动,于是标题里的 `&`/`|`/`^`/引号成了命令注入面、中文标题还会被 cmd 的代码页解成乱码;改成宿主写 UTF-8 文件 + adapter 新增 **`--title-file`**(位置参数照旧可用,两者都给时位置参数优先)。② **任务化而非同步请求**——命令要跑几十秒到几分钟,`POST` 立即返回 `jobId`,单并发(第二次 409)、15 分钟上限,超时用 `taskkill /T` 杀**整棵进程树**(只杀 cmd.exe 会留下孤儿 node 抓着 job 目录,实测 `EBUSY`)。③ **可选功能默认不打扰**——`wechat.enabled` 关着时三条 `/wechat/*` 路由一律 403、按钮插件不写进 wiki(新增 gated 起步 seed `wechat-publish`,与 `publish-spec`/`wechat-setup` 同进同退)。新增 `src/host/wechat-publish.ts`(命令组装纯函数 + job registry + 就绪探测)、三条路由(方法/同源守卫 + 可选 `x-wechat-publish-token`;`wechat.token` 与其它密钥一样在 `/admin/state` 打码)、TW bundle `$:/plugins/dsh/wechat-publish`(`scripts/bundle/wechat-publish/` + build/gen/verify 流水线,`wechat-publish.bundle.json` 逐字节守门)。守门:`scripts/verify-wechat-publish.mjs`(17 条:纯函数 + **真跑 spawn 的「假 opencli」E2E**(含标题不进 argv 的逐字断言)+ 真实 HTTP 路由守卫;**已反向验证**:拆掉 `guardWechat` 即红)+ `scripts/verify-wechat-publish-bundle.mjs`(进 `verify:static`)+ `verify-wechat-adapters.mjs` 新增「两个 publish adapter 都必须走 `resolveNoteTitle`」。
139
- - **v0.23.2**(2026-09-17):**多图发布命令 `publish-note-imgs` + 发布策略定为「到草稿箱为止」**。TW 笔记内嵌 `[img[...]]` 经 `/render` 是 data URI,微信存草稿会过滤非 mmbiz 图——新命令先把正文全部图片经 DataTransfer 逐张上传 CDN(轮询计数到期望值),按 DOM 顺序重写正文 src 再 insertHTML,从正文第一张设封面;发布前同样读 `pub-state`/`no-publish`(只告警不阻断)。`selectCoverFromContent` 封面校验改轮询,修「实际成功却报失败」的假阴性(实测 `list_ex` 的 `cover` 已是 mmbiz 而流程报失败)。安装脚本 FILES 收编至 5 个 adapter,守门断言同步。**策略**:adapter 负责到草稿落盘为止,发表(含原创声明/作者/留言等确认弹窗 + 管理员扫码)由人工完成——`--publish` 保留但 best-effort,遇弹窗会等到超时。文档新增:发表确认弹窗说明、`stale page identity` 续作法、`list_ex`/`appmsgpublish` 核实命令、GitHub ext release 落后 Web Store 备注、npm allowScripts 拦 postinstall 无害备注、**eval 诊断后台必须过滤不可见 toast 残留**(踩过:把残留文案当实时状态误判账号被拦,实际账号正常)。
140
-
141
- - **v0.23.1**(2026-09-17):**新增 `wechat-setup` seed——「微信公众号发布指南」也随插件分发**(补齐 v0.23.0 的缺口:当时只有「发布元数据规范」进了 seed,387 行的安装/换机还原指南只存在于仓库与 npm 包的 `docs/` 里,wiki 里那篇「换机还原清单」是手写指针笔记)。与 `publish-spec` 完全同模式:起步层(`startup: true`)+ `gate: (ctx) => ctx.wechat === true`——**只在设置页开启「微信公众号发布」时**启动写入(笔记「微信公众号发布指南」,`dsh-docs` 标签自动进首页插件文档栏),不开该功能的用户 wiki 不出现、设置页手动「初始化」不受 gate 约束。**内容单一来源**:新 gen 脚本 `scripts/gen-seed-wechat-docs.mjs` 从 `docs/wechat-publish-setup.md` 生成 `src/host/seed-wechat-docs.ts`(照 send-to-agent 的 gen 流水线,勿手改常量),新守门 `scripts/verify-wechat-docs-seed.mjs`(进 `verify:unit`)断言 seed 常量与文档**逐字节一致** + 注册表 gate 接线(2 个 gated seed = gate 谓词恰好 2 处)。顺带:`verify-package-contents.mjs` 的 `npm pack` 从管道捕获改为**文件重定向 + 临时缓存目录**(DSH 沙箱禁止命名管道 stdio → 原 `exec` 实现 EPERM;npm 默认缓存在沙箱外也要绕开),本地终于能跑这条守门。selftest / verify-seed-error-policy 的 gate 断言改为**从注册表派生** gated 清单(新增 gated seed 不再漏改硬编码);设置页与 config 注释同步「开启后写两篇文档」;docs/seed-initialization.md 补齐 publish-spec / wechat-setup 两行表格。
142
-
143
- - **v0.23.0**(2026-09-17):**新增「发布到微信公众号」+ 发布元数据**(`tools/wechat/`,**可选功能,默认关闭,需额外安装**,不动 host 代码)。把 wiki 笔记一键发到公众号**草稿箱**,可选点发表。
144
-
145
- **⚠️ 可选功能,默认关**:真正干活的是仓库 `tools/wechat/` 下的 opencli adapter + Browser Bridge 浏览器扩展——**插件本体不含它,也不会替你装**。开关 `wechat.enabled` 默认 `false`:**关闭时**不注入任何发布相关提示词、也不往 wiki 写「发布元数据规范」文档;**打开后**仅多这两项,不会安装任何东西、不启用任何后台服务。不装/不开它,插件其他功能完全不受影响。开启方式:设置 →「TiddlyWiki 知识库」→「可选功能:微信公众号发布」→ 勾选 → 保存配置。完整安装步骤(opencli、浏览器扩展、扫码登录)与排错见 [docs/wechat-publish-setup.md](wechat-publish-setup.md)。
146
-
147
- **为什么不用官方 API**:2025-07 起官方回收了「发布能力」接口对个人主体/未认证账号的调用权限(`freepublish/submit` 不可用、`draft/add` 常回 48001);即便可用还要配 API IP 白名单、封面永久素材、正文图片必须走 `media/uploadimg`。**本方案改走浏览器自动化**,复用你**已登录**的公众号后台会话(opencli + Browser Bridge 扩展),个人号可用、零凭据落盘。数据流:笔记标题 → DSH `POST /render`(TW 自己渲染成语义 HTML,**代码高亮白蹭**)→ `wechat-html.js` 补**内联样式**(实测微信会剥 `<style>` 并删 class,**只认内联 style**;而 TW 输出零内联样式,所以必须有这一步)→ `weixin-flow.js` 驱动后台填表/写正文/传图/设封面/存草稿。
148
-
149
- **发布元数据**(本轮追加,解决「哪些发过 / 哪些不能发」):每篇笔记用自定义字段记录对外发布状态——`pub-state`(`draft`/`published`/`excluded`)、`pub-platform`、`pub-wechat-at` / `pub-wechat-title` / `pub-wechat-url`(**按平台分字段**,将来 `pub-zhihu-*` 直接平铺)、`pub-note`;标签 `no-publish` 作为给人看的镜像。**规范全文放进 seed**(「发布元数据规范」,`dsh-docs` 标签),因为注入提示词 slim 形态实测只剩几十字符余量,只在其中放**一句指针**;该 seed 带 `gate`——**只有开启可选功能才会写**。⚠️ 三个名字**已被占用**故特意避开:`publish`/`publishyear` 是 Obsidian 导入书籍的「出版社/出版年」,`发布记录` 是本插件自己的版本说明。存量来源可辨的公众号文章(`source-path` 含「公众号」,本 wiki 实测 18 篇)用 `tools/wechat/backfill-publish-state.mjs` 回填(**默认 dry-run**;先 GET 完整 tiddler 再只添加字段整体写回;已有 `pub-state` 跳过;正文为空跳过;发表时间无法考证就留空)。发布前检查**只告警不阻断**(读 `pub-state`/`no-publish` 命中就打印警告并继续,`--force` 仅改措辞),回执里附**建议回写值**供 agent 回写。
150
-
151
- **四个实测踩坑**:① **必须带 `--trace retain-on-failure`**——不带就对 `mp.weixin.qq.com` 稳定报 `Navigation rejected`(trace 开 5/5 成功、关 8/8 失败;`--site-session ephemeral` / `--keep-tab false` / 前后台窗口 / 重置标签页 / 重启 daemon 全部无效,是 opencli 1.8.7 的 bug);② **轮询里绝不能读 `document.body.innerText`**——微信编辑器 DOM 极大,读一次强制整页 layout,**实测单次 ≈17 秒**,8 次轮询把命令拖过 210s 超时而**草稿其实已保存成功**(假阴性);改成只查少量 toast 节点后 `saveDraft` 从 **137 秒降到 3.9 秒**;③ **图片上传必须用 DataTransfer 注入**,不能用 `page.setFileInput`(后者依赖 CDP `Page.fileChooserOpened`,本机扩展版本组合下稳定失败;DataTransfer 在页面上下文直接塞 `input.files`,实测图片真进 `mmbiz` CDN,代价是单图 8MB 上限);④ **正文必须用 `execCommand('insertHTML')`**,`insertText` 会把 HTML 当字面文本(opencli 内置 `weixin create-draft` 就是这样,实测 Markdown 符号原样进库)。**发表需管理员扫码**——微信的账号安全机制,无法自动化,「一键」的真实含义是「脚本做完排版/上传/填表,你只扫一次码」;默认走**发表**(不推送粉丝、不占群发额度)而非群发。
152
-
153
- 命令:`opencli weixin publish-note "标题" [--cover x.png] [--preview out] [--publish] [--force]`(另有 `create-article` 从本地 HTML 文件建草稿)。**换机器还原**:`node tools/wechat/install-wechat-adapters.mjs` 一条命令装好 adapter 并自检扩展/登录状态;完整步骤与排错见 `docs/wechat-publish-setup.md`。守门:`verify-package-contents.mjs` +5 断言(发布包必须含 publish-note / install / backfill / seed-now 与 setup 文档)、`package.json` 的 `files` 白名单补 `tools`,新增 **`scripts/verify-wechat-adapters.mjs`**(进 `verify:unit`,10 条源码级断言:文件齐全、**轮询不得出现 `body.innerText`**、**不得用 `setFileInput`**、装饰器注入内联样式、发布前检查存在且**只告警不抛错**、返回行 key 与 `columns` 一一对应、install 清单与磁盘一致、三个坑的注释在位;**已反向验证**:塞回 `body.innerText` 即红)。调研与本机实测全过程沉淀在 wiki 笔记「公众号发布插件调研」。
154
-
155
- - **v0.22.10**(2026-09-17):**修「写入丢失 `created`/`modified`,新笔记在按修改时间排序的页面上沉到最后一名」**(用户实测报障:日记在「主题页·日志」排 175/175,看着像没被收录)。根因:插件把这两个字段列进 `CLEAN_SKIP_FIELDS`(假设「TW 服务端会补」),但 TW 服务端的 PUT 路由**从不补**时间戳——`getCreationFields()`/`getModificationFields()` 只在 TW 自己的浏览器 UI 里调用,于是插件写的条目落盘只有 tags/title/type;而 TW 的 `sortTiddlers` 对缺失字段取 `fields[sortField] || ""`,`!sort[modified]` 降序时空串直接沉底。**修复**:`buildWriteTiddler()` 统一负责写这两个字段,语义与 TW 编辑器一致——新建 = 都取当前时刻,覆盖 = 保留原 `created`、只刷新 `modified`(基底无 `created` 的迁移存量则补当前时刻);格式用新增 `formatTiddlerDate()`(TW 的 17 位紧凑 UTC,与 `$tw.utils.stringifyDate()` 逐字节一致,别用 ISO)。两者继续留在 `RESERVED_TIDDLER_FIELDS`(`fields` 不得覆盖),只是不再被 CLEAN_SKIP 丢弃。`TiddlyWebClient.put()` 另有 `ensureTiddlerTimestamps()` 兜底(**只补缺、绝不改写**已有值),覆盖剪藏桥 / seed / 配置 tiddler 等手拼 PUT 的路径。顺带统一:`rename`(含引用改写)与回收站恢复改走共享写策略(原先手拼 body 会把旧 `modified` 原样带过去);回收站快照与恢复保留原时间戳(恢复 = 恢复原状,`trash-at` 单独记删除时刻);`/edit` 生成的 TW 草稿携带原笔记的 `created`——TW 保存草稿时草稿字段会覆盖新生成的时间戳,草稿不带它就会把笔记的创建时刻重置成「刚才」。守门:`verify-write-policy` +7 条纯函数(格式往返 / 三种语义 / `$:/` / `fields` 不可覆盖 / 兜底只补不改写),`verify-tools` +5 条 E2E(含用 TW 真实 `!sort[modified]` 过滤器验证排序),selftest 增配置 `created` 保留断言。**存量数据不自动回填**:线上 wiki 1161 个 `.tid` 中 948 个缺 `modified`(多为历史迁移脚本所致),它们仍会在 `sort[modified]` 页面沉底,可用 wiki 根目录的 `backfill-timestamps.mjs` 一次性补齐(默认 dry-run,按 **git 历史**取首次出现/最后变更时间,`--write` 才落盘)。
156
-
157
- - **v0.22.9**(2026-09-16):**修「TiddlyWiki 编辑者署名被静默清空」**(用户实测报障:控制面板 →「信息」→「基础」里的「编辑者署名」填完保存不了、刷新就没了)。根因是 DSH 启动 TW 子进程时(匿名 loopback 分支)**没传 `anon-username`**:`get-status.js:21` 回落到 `""`,而 TiddlyWeb adaptor 判定登录用的是 `isLoggedIn = json.username !== "GUEST"`(`tiddlywebadaptor.js:93`)——`"" !== "GUEST"` 为真,于是**匿名读者被当成已登录用户**;`syncer.js:281-283` 随即在**每次页面加载**时把 `$:/status/UserName` 覆写成那个空串。名字在当次会话里看着还在,一刷新就没了;而一旦 `$:/config/SyncFilter` 放行了 `UserName`(本仓库 wiki 的现状,有一篇专门的踩坑笔记记录),这个空串还会**同步落盘并进 git**,从「内存里丢了」升级成**真实、可追溯的数据丢失**。现在匿名分支显式传 `anon-username=GUEST`(`wiki.ts` 的 `ANON_USERNAME` 常量):`isLoggedIn` 变 false → syncer **根本不再写** `UserName` → 用户填的名字稳定保留。⚠️ `GUEST` 是**承重的魔法值**,不是随便取的名字:TW 判的就是 `!== "GUEST"` 这个哨兵,填任何人名(含你自己的名字)都仍然算「已登录」,只会把署名覆盖成人名而不是空串。**为什么拖到现在**:`src/host/wiki.ts` 从 v0.16 起就写着一句注释「Passing anon-username/readers/writers here was verified to 401 every request」——它把**两个独立参数**混为一谈,等于给后来每个维护者发了张「此路不通」的牌子。实测(TW 5.4.1)只有 `readers`/`writers` 会关掉匿名访问(`server.js:63-66` 里显式的 `readers` 会让 `(anon)` 不再是 principal,于是 `isAuthorized()` 为 false,`requestHandler` 用当时为 `undefined` 的 `authenticatedUsername` 拼出 `'undefined' is not authorized` 的 401);`anon-username` **单独**传完全安全(`/status` 200、GET 200、PUT 204)。那句注释已改正并写明真实机制。**副作用(外观级)**:`$:/status/IsLoggedIn` 恒为 `no`,TW 的 SyncerDropdown 会多出一个用不上的 Login 按钮;`wiki.js:1690` 的 `generateDraftTitle` 会从 `Draft/Title` 形态切到 `Draft/Attribution` 形态(内核既有行为,与「快速笔记」的 `/edit` 草稿路由无关——后者按 `draft.of` 字段匹配,不依赖标题字面量)。**影响面**:默认 SyncFilter 的用户**不会**丢盘(`$:/status/` 被默认过滤器整个排除,空串走不到落盘),实际受数据丢失影响的只有**已经放行 `UserName` 的人**——所以这一版主要是消除一颗静默的数据丢失地雷 + 拔掉那句误导注释。守门:新增 `scripts/verify-anon-username.mjs`(进 `verify:e2e`,真实 TW 子进程)——`ANON_USERNAME` 必须字面量 `GUEST`、`/status` 必须回该哨兵、落盘署名不得被覆盖、匿名模式仍可正常写条目、auth 模式不受影响且对匿名请求仍 401,外加源码级断言(旧误导注释必须已删、鉴权分支不得顺手传 `anon-username`);**已反向验证**:把 `anon-username` 那行去掉即红 3 条。
158
-
159
- - **v0.22.8**(2026-09-15):**第六轮代码审计修复**——host 与 client 各做一份只读审计,逐条核实后修 10 处真缺陷,并清掉一轮重复实现与死代码。① **数据丢失(P0)**:`/edit` 仍会把「调用方文本或笔记已保存正文」PUT 进**刚复用**的那个草稿,而那个草稿里装的是用户在 TW 原生编辑器里敲的**未保存内容**——触发路径极常见:打开快速笔记(标题精确到分钟)→ 写 → 关掉 → 同一分钟内再打开 → 草稿被覆盖。现在只有**我们创建的**草稿才由我们填充,复用的仅在调用方显式给了文本时才写。② **设置页超时(P0)**:`fetchJson` 展开 `init` 后又硬编码 `signal`,于是「知识库切换 / 恢复默认 / 重启 TW」的 120s 预算被 15s 掐断——宿主其实切成功了,用户却看到「切换失败」(大 wiki 冷启动实测 44s+),且重试会再走一遍停/启。③ **净化器崩服务**:`&#1114112;` 这类越界数字实体会让 `String.fromCodePoint()` 抛 `RangeError`,而净化器没有 try——一条笔记正文就能让 `POST /render` 502,打坏回复流工具卡与会话「知识库」Tab。④ **「幽灵草稿」**:保存/「丢弃」后重置编辑器会触发防抖,把「空正文 + 新时间戳标题」又写成草稿,下次打开误报「已恢复未保存草稿」并跳过默认 tag。⑤ `/recent` 一直丢掉 `since`(工具与卡片都发它),于是模型看到过滤结果、人看到未过滤列表——与 v0.20.0 修过的 `/search` 同类漂移。⑥ 隐藏的 TW 面板会因为一个在途 `/status` 响应重新起一轮 30×1.5s 轮询。⑦ `/tw` 代理补齐显式方法白名单。⑧ `/render` 的 404 改为结构化异常(原先按错误消息文本正则,改词即把「不存在」变 502)。⑨ `/get` 改用共享的 `isBlockedProxyTitle()`(原为硬编码字面量副本)。⑩ 侧边栏显示名改完设置即时生效(原只挂载时读一次)。**去重/死代码**:新增 `render-fetch.ts`(唯一渲染调用)、`describeSyncResult()`(同步回执)、`formatLocalMinute()`,`ui-config.ts` 承担全部 `ui.*` 投影;删除生产零调用的 `ensureTwWebHost()`(与 `tw-web-host` seed 重复)与 `listTags()`,多个仅供模块内部使用的 `export` 与「算了却从不显示」的字段一并清除。守门:`verify-render-sanitizer.mjs` +1、`verify-frame-guards.mjs` +3、`verify-audit-fixes.mjs` +3(草稿覆盖那条**已反向验证**:还原修复即红)。
160
-
161
- - **v0.22.7**(2026-09-14):**设置页「预览注入文本」改为按表单当前值渲染**(用户实测提问「两种内置文本形态差别是什么?为什么选了不同形态后查看当前注入文本显示的东西一样?」)。两种形态的差别其实只在 intro 一段:`slim`(默认)是一句能力概览(实测 1735 字符),`full` 换成由工具注册表实时生成的 15 行参数索引(2504 字符),三块治理约定(写入与并发 / 同步纪律 / 笔记约定)完全相同。但预览按钮此前只发 `GET /admin/prompt`,读的是 **host 已保存的有效配置**,而下拉框的值只活在浏览器 DOM 里——于是「切换形态 → 不点保存 → 点预览」看到的是逐字节相同的旧文本,用户自然以为两种形态没差别。现在:① `prompt.ts` 新增 `normalizePromptPreview()`(把不可信的 body 白名单化成 4 个字段,未知键/错类型丢弃并回落内置默认)+ `describePrompt()`(`applyPrompt()` 与预览**共用同一份拼装**,预览不可能与保存后的注入不一致);② `POST /admin/prompt` 接收 `{enabled,mode,extra,override}` 草稿并渲染全文,**写入零副作用**(不落盘、不触发 `onConfigChanged`),同源守卫 + 畸形 JSON 400;GET 保留为「已保存/正在注入」的读取;③ 设置页按钮改名「预览注入文本(按表单当前值)」并 POST 草稿,标题栏在表单有未保存改动时标注「含未保存的修改,保存后才真正注入」。守门:`verify-prompt.mjs` 新增 3 条纯函数断言(白名单、`describePrompt` 与 `buildPromptText` 逐字节一致、两种形态在草稿里必须不同),`verify-seeds-admin.mjs` 第 8 段把「POST 必须 405」换成 7 条草稿断言(slim/full 草稿不同、extra 追加、`enabled:false` 为空、GET 仍是旧的已保存状态且 `onConfigChanged` 未触发、垃圾字段回落、畸形 JSON 400、跨站 403)。
162
-
163
- - **v0.22.6**(2026-09-14):**修「快速笔记」两处用户实测报障**。① **原生编辑弹窗里删掉笔记后,弹窗变永久白板、再点「快速笔记」也回不来**。`ui.quickNoteMode=native` 的编辑器是**同一个 iframe 复用**的,而 `openEditorPopup()` 只在 **URL 变了**才给 iframe 赋 `src`(刻意如此:赋 `src` 即整页重载,会丢掉 TW 里未保存的草稿 / 滚动位置 / 撤销栈)。可 TW 编辑器的「删除」是 `tm-delete-tiddler`:它把**原 tiddler 与草稿一起删掉**、并用 `removeTitleFromStory` 把 story 清空——于是弹窗里剩一块白板;而此时主机重建的草稿标题往往与删除前**完全相同**(默认标题是分钟级时间戳 + canonical `Draft of "…"` 复用),URL 不变 → 不重载 → 白板永远摆在那儿,再点按钮也只是「收起/重新打开」同一个空 iframe。现在三处一起兜住:新增 `isEditorPopupBlank()`(同源读 iframe 文档里的 `.tc-story-river`,**一条 `.tc-tiddler-frame` 都没有**即判空;跨源 = TW 里点了外链、文档仍在 loading、读不到文档时**一律不判空**,绝不擅自重载一个可能正在编辑的编辑器),空掉的弹窗在 `openEditorPopup()` 里**强制重载**;`closeEditorPopup()` 同时清掉 `frame.dataset.loaded`,所以「关闭再打开」也一定是新的编辑器载入(关闭意味着丢掉上一次的 view 状态);输入框上方的按钮与 `openNative()` 的「已打开」幂等守卫都改成 `isEditorPopupOpen() && !isEditorPopupBlank()`,用户再点一次就能拿回编辑器。② **card 模式底部操作条把按钮文字压成两行**(用户截图反馈的美化问题)。340px 的卡片里 5 个控件本就放不下,旧 CSS 让 flex 默认收缩,于是「📎 上传」「🕘 最近」「✏️ 在 TW 中编辑」「保存」**每个按钮里的文字都折成了两行**(无头 Chrome 实测:每个按钮 `textRects=2`、高 46px)。现在按钮一律 `white-space: nowrap` + `flex: 0 0 auto`(文字永不折行、永不被压扁),装不下时**整个右组换行**并用 `margin-left: auto` 继续贴右缘,「Ctrl+Enter」提示允许收缩让位——实测全部按钮恢复单行(高 30px),左下是「上传 / 最近 / Ctrl+Enter」、右下是「在 TW 中编辑 / 保存」两行布局。守门:新增 **`scripts/verify-editor-popup.mjs`**(进 `verify:unit`,tsx 直跑源码 + 最小 DOM 打桩,11 条行为断言——同 URL 有内容不重载 / story 被删空必须重载 / 关闭清 `dataset.loaded` / 关闭后重开必重载 / 空白判据的四种保守边界;**反向验证过**:把两处修复改回去会红 3 条),`verify-frame-guards.mjs` 另加 2 条接线断言(空弹窗不得被当成「已打开」)。真机复核:无头 Chrome 里确认「同一个 URL 再赋一次 `src` 确实会重新加载」「清空 story 后重载能恢复内容」,并用真实 CSS + 真实卡片结构对比修复前后的按钮排版。
164
-
165
- - **v0.22.5**(2026-09-14):**修「大知识库冷启动被误判为启动失败」**(用户实测报障:dsh 启动时 `[dsh-tiddlywiki] startup issue (self-healing is armed): Error: wiki server did not become ready in time`)。根因不是 TW 没起来,而是**就绪判定太急 + 判失败后再也没人复探**。证据来自 `/status` 的环形日志:`01:43:51 spawn … tiddlywiki.js … --listen` → `01:44:11 wiki server did not become ready in time` → `01:44:35 [out] Processing background action …` + `[out] Serving on http://127.0.0.1:52323`。也就是说这个 5099 个 tiddler / 26MB 的知识库在**冷文件缓存**下真的需要约 44 秒才打印 `Serving on`,而旧代码只有**一个 20 秒硬超时**,于是:① 抛错中断整条启动管线——配置 tiddler 没加载、核心 seed(发送给 Agent / 渲染路由 / 同源代理 / markdown 插件)没写、剪藏桥没起,全都会拖到下一次重启;② 写死 `health='failed'` + 一条粘性 error,而**超时后没有任何东西会再探测**,所以 TW 明明在正常服务,`/status`(面板 / FAB / 悬浮提示)却一直报故障。现在把「就绪」变成一条明确的三段策略(新的 `src/host/ready-policy.ts`,纯函数 + 注入时钟,可单测):**软窗口**(默认 60s,可用 `startup.readyTimeoutMs` 在设置页调,夹在 5s–600s)之内安静等待;超过它只写一条 `slow start: /status not ready after Ns — still loading` 并**继续等**(状态保持 `starting`,不再粘住 error);**硬上限 = 3× 软窗口**仍无响应才判失败——并且失败时**不杀子进程**,而是挂一个**有界(10 分钟)、`unref` 的「迟到就绪」后台探测**,TW 真起来的那一刻立刻把状态改回 `running` 并清掉那条旧错误(`ready (late): /status 200`),`stop()` 与新一次 `start()` 都会取消它。`startup.readyTimeoutMs` 走「cordis 配置 < 配置 tiddler」双层,设置页保存后对**下一次启动/重启**生效,无需重启 dsh web。守门:新增 `scripts/verify-ready-policy.mjs`(进 `verify:unit`,tsx 直跑源码 + 虚拟时钟)——归一化/夹取、**44s 冷启动必须判成功**(本次事故形状的回归断言)、慢启动只警告一次、子进程退出 → 立即 `exited`、始终不就绪 → 到硬上限才 `timeout`、轮询节奏有界(慢窗口降到 2s,不空转),外加源码级接线断言(`wiki.ts` 不得再出现 20s 硬编码、必须有 `awaitReady` / `armLateReadyWatch` / `clearLateReadyWatch`,config/index/client 三处链路都在);`verify-resilience.mjs` 增加一条真实 TW 的重启断言(窗口热改 + 夹取 + 仍判就绪)。
166
-
167
- - **v0.22.4**(2026-09-13):**重构:把 TW frame 的生命周期收敛成唯一一份实现**(无功能变更,纯去重)。v0.22.3 那个「空 `src` 把 DSH 载进 iframe」的 P0 之所以发生,根因不是谁写错了某个判断,而是 `panel.ts`(中央列面板)与 `tw-frame.ts`(右侧栏 tab)**各自抄了一份** `showError` / `showStarting` / `showFrame` / `applyPendingHash` / `fallbackLoad` / `doRefresh`——修 bug 时守卫只补到 panel,tw-frame 那份照旧,同一个坑于是踩了两次(`panel.ts` 靠「`!hidden` ⇒ 已载入真实 twProxy」这个不成文不变量侥幸安全,tw-frame 连这个都没有)。现在这六个函数只在 `tw-frame.ts` 新增的 **`createTwFrameSurface(skin)`** 里各有一份,`createTwFrameController()` 退化成它的 rightbar 薄包装;一个 surface 只提供三样东西:**皮肤**(类名 + 可选内联样式)、**惰性构建点**(`build()` 返回 view,由调用方决定挂到哪里)、**可见性**(`setVisible()`)。`panel.ts` 因此从 469 行降到 280 行,只剩 rect 钉住 / 惰性构建 / 共存 chrome / 面板互斥。顺带三处行为改进(都是去重后的自然结果):① 面板打开链接改为**先 `state.openPanel()` 再 `surface.openTiddler()`**(内核以 `visible=false` 拒绝隐藏 surface,顺序反了链接会静默丢失,脚本里有守门断言);② `setVisible(false)` 现在会**隐藏 frame 并停掉有界轮询**(下次显示重新给满预算);FAB「重载面板」刻意**不受可见性限制**(唯一判据是 `dataset.loaded`)——显式重载请求要能覆盖**关掉的**面板,否则「关掉面板 → 点重载 → 再打开」会看到旧资源,而展示陈旧内容比多一次重载更糟;③ 内核的 `build()` 会记住「DOM 还没建就被要求显示」的情况并在建好后补一次 `/status` —— 此前中心列若在面板挂载之后才出现,面板会停在空壳上直到用户再切一次。守门:`verify-frame-guards.mjs` 加了**计数去重断言**(panel 里出现任何一个生命周期函数就直接判红,反向验证过:植入回来必失败),并新增 **`scripts/verify-frame-surface.mjs`** —— 用 tsx 直跑源码 + 最小 DOM 打桩,真正执行 `createTwFrameSurface()` 跑 16 条行为断言(空 `src` 的两条重载路径、只显示真的载入过 TW 的 frame、同一 URL 不重复赋 `src`、启动/错误/重试恢复、同源 hash 导航与跨源兜底、共享 chip 标签、dispose 摘监听且幂等),已进 `verify:unit`。测试环境提示:本机沙箱里 node 再 spawn 孙进程会崩,`npm run` 偶发段错误,`verify-package-contents` 与 `verify-git-resolve` 的失败属环境问题(手动复核过断言全过),与本版无关。
168
-
169
- - **v0.22.3**(2026-09-12):**第五轮代码审计的客户端修复版**(host 侧零缺陷:CSRF 门禁 / 片段净化 / SSRF 守卫 / 密钥打码 / 写策略 type 保留 / 乐观并发 / git 互斥 / seed 内容哈希 / 切换回滚逐条对照都成立;`tsc` 与 8 个 `verify-*` 全绿)。① **P0:iframe 空 `src` 被当成合法地址使用**。`tw-frame.ts` 的 FAB「重载面板」处理是 `frame.src = frame.src`、hash 兜底加载是 `frame.src.split('#')[0]` 拼 hash——而 `iframe` 从未赋过 `src` 时,**读 `iframe.src` 得到的是宿主 DSH 页面自己的 URL**。`setVisible(true)` 会在首个 `/status` 回来之前就取消 `hidden`(切走再切回即可命中),于是这两种写法会把 **DSH 页面载进 iframe**:iframe 里再起一份 DSH(重复 FAB、重复全局监听、白屏)。现在两处「整页重载」路径都只认 `dataset.loaded`(只有 `showFrame` 写它)并抽出共享的 `loadableFrameUrl()`,`panel.ts` 原本靠「`!hidden` ⇒ 已载入真实 twProxy」这个不成文不变量侥幸安全,现也改成显式判据。② **P1:主题「活动」单选预选错误,且会影响写入**。`/admin/state` 只回 `info.themes`(**已加载**集合),设置页就拿它的**最后一项**冒充「活动主题」;用户显式激活过非末位主题时,打开设置页显示错误选中项、**不动任何单选直接点「应用主题」也会静默把活动主题改回末位那个**。现在 host 读 `$:/theme` 并在 `/admin/state` 回 `info.themeActive`(去掉 `$:/themes/` 前缀归一),设置页优先用它、猜末位只作兜底,并且只在 catalog 里存在时才认。③ **P1:`applyToFrame` 缺 try/catch**。`setThemeSyncConfig()` 是**同步**遍历调用各 frame 的 applier 的(`doRefresh`/`load` 都在 await 链里),而 `frame.contentWindow.$tw` 这次属性访问在 iframe 导航到跨源页面(TW 里点外链)时**自身**就抛 `SecurityError`(内层各助手的 try/catch 还没轮到),异常直接变成未处理 rejection 并中断那次刷新;现在整段包住。④ **P2 清理**:会话汇总的挂载 effect 补 `mountedRef` 卸载守卫(`genRef` 只认「被更新取代」、覆盖不到卸载,在途请求 resolve 后仍会 setState 并递归再发一轮)、快速笔记按钮的 `fetchUiConfig().then(setMode)` 补 alive 守卫、`note-widget` 卸载不再删除**设置页也在用**的页面级 toast 单例(原来会掐掉别人正在显示的提示);删掉三处无人消费的死标记/死字段(`view.dataset.visible`、`data-dsh-tw-rightbar`、`BuiltUi.recentBtn`、`ConfigField.input`),工具卡标签 key 补下标(同标签重复会撞 key),并把 `panel.ts` 里重复的 `requestRestart` 收敛为复用 `tw-frame.ts` 的那一份。守门:新增 `scripts/verify-frame-guards.mjs`(已进 `verify:unit`)——空 src 陷阱、活动主题优先级、跨源 try/catch、共享 toast、死标记共 13 条断言,其中 `readActiveThemeName()` 走真实函数(前缀 / 裸名 / 缺失 / 读失败),静态断言会剥掉注释行以免匹配到自己的说明文字。
170
-
171
- - **v0.22.2**(2026-09-12):**修「主题汇总页·模板」seed 内容被渲染成几千遍重复**(用户实测报障)。模板里留了两个「可选」大段落,其中一个标题里有一个**裸 `<$list>`**(没有 `filter` 属性)——TW 渲染时把它当成「列出**全部** tiddler 的列表」,于是整段「可选:把本页收进『一页多主题』tabs」说明被跟着重复渲染几千次(实测渲染出 1.1MB、约 2941 遍重复)。现在模板精简为**一个列表 + 一行指向教程的说明**:删掉两个「可选」段落(自定义 `<$list>` 样式版 / tabs 收录法),自定义需求统一见 [[教程:按主题/标签做汇总页]];模板正文的提示也改为「把正文里的 `主题A` 全部替换」(原来写「两处」,实际有 3 处)。**已有 wiki 的模板不会自动更新**(seed 是 ONE-SHOT):本机线上 wiki 已手工覆盖为新模板并同步 seed 标记哈希;其余 wiki 可在设置页「初始化」里对 `starter-docs` 点「重新初始化」(会覆盖你对模板的改动,改过请先备份)。顺带修好一个校验脚本的兼容问题:`scripts/verify-package-contents.mjs` 假定 `npm pack --json` 返回数组,但 npm 12 返回 `{name:{...}}` 对象,导致「发布包内容」检查假失败(与本次改动无关,但会卡住发布流程)。
172
-
173
- - **v0.22.1**(2026-09-11):**修首页「快速记笔记」创建出的条目日期时间错误**(用户实测报障)。首页快速笔记在标题留空时用 `<now "YYYY-MM-DD-HHmm">` 生成标题,而 TW 的日期格式 token **只有小写** `YYYY`/`0MM`/`0DD`/`0hh`/`0mm`—**`HH` 根本不是 token**,未知字符被**原样输出**,于是生成的是 `2026-9-11-HH20` 这种标题(还伴随月份/日不补零)。现在改为 `YYYY-0MM-0DD-0hh0mm`,并新增 `verify-constants.mjs` 守门(首页 seed 不得含 `HHmm`)。同一处还把一个反模式改成 TW 的正确命名空间:`$savetitle` 原本写 `$:/state/home-note/last-created`——`$:/state/*` 会被 syncer **落盘进 git**(设置页覆盖层之外的有一份状态污染),改用 **`$:/temp/*`**(tiddlyweb adaptor 显式排除,浏览器端临时状态不进 wiki)。顺带修好一个**一直跑不起来的构建脚本**:`scripts/gen-seed-home.mjs` 的生成模板里有两处反引号没转义(第 148/181 行的 `` `ok:false` ``),整个脚本**语法错误**——也就是说「改首页 → 重新生成 seed」这条路早就断了,本次同时修复。**已有 wiki 的首页不会自动更新**(seed 是 ONE-SHOT):设置页「初始化」里对 `home-index` 点一次「重新初始化」即可拿到修好的首页(会覆盖你对首页的改动,若你改过请先备份;本次作者的线上 wiki 已手工打上同样的两处修改)。
174
-
175
- - **v0.22.0**(2026-09-11):**知识库位置可在设置页运行时切换 + seed 文档「过期」检测 + 一个隐藏很久的数据丢失竞态**。① **知识库位置可切换**:`wikiRoot`/`wiki` 之前只能写死在 cordis 配置里(改一次要动配置文件 + 重启),现在设置页顶部「知识库位置」显示当前实际目录、这个位置**怎么决定的**(指针文件 / cordis 配置 / 默认值)、指针文件路径与同目录的候选 wiki;填一个绝对路径即可**运行中切换**——停 TW → 释放自动提交与监听 → 改指 → 起 TW → 重载新 wiki 的配置 tiddler → 跑核心 seed(markdown 插件 / 发送给 Agent / 渲染路由 / 同源代理 / 语言)→ 重建 git → **最后**才写指针;目标目录没有 `tiddlywiki.info` 会自动 `--init server` 建一个全新知识库;**失败自动回滚**到原知识库并如实回报 `rolledBack`。选择存在 `$DSH_HOME/dsh-tiddlywiki/location.json`——一个**在 wiki 之外**的指针文件:设置页的覆盖层就存在 wiki 自己的配置 tiddler 里,若「用哪个 wiki」也存那里,切过去就会把这个选择一起丢掉(鸡生蛋);指针损坏只会报告并回退配置默认,绝不乱跑。② **seed 更新检测**:seed 标记从一行 `seeded-once` 升级为 `{version,hashes,at}`——记下**我们写下的内置正文的哈希**,于是设置页能区分「⬆ 内置内容有更新」(可一键「更新到内置版本」)与「✏️ 本地已修改」(覆盖前二次确认);旧标记(无哈希)会显示「更新检测尚未启用」并按文本比对判定归属,**不会**被误判成「用户改过」;`updateAvailable` 只是提示,**绝不自动改写你的 wiki**。③ **文档不再手抄易变事实**:插件说明笔记里的工具清单由**工具注册表生成**(`docNoteText(tools)`)——它此前写死「10 个 agent 工具」而实际已有 15 个。④ **修掉一个潜伏的数据丢失竞态(本次最重要的修复)**:`flushPendingWrites()`(「重启 TW 前把 syncer 队列排干」的哨兵)假设「哨兵落盘 ⇒ 队列已空」,但 TW 的 syncer 会**跳过最近 1 秒内保存过的标题**(`syncer.js` 的 `throttleInterval` / `chooseNextTask`)——哨兵是另一个标题、随时可写,于是它会**插队**先落盘,被 throttle 的写入仍留在队列里、随即被重启吞掉。这个竞态一直存在(AGENTS.md 记录过 v0.19.0「force-all 随机丢 `tw-web-host`」),v0.22.0 新增的 seed 标记写入改变了时序,使它变成**必现**(`verify-seeds-admin` 稳定复现)。现在改为**两段式**:写哨兵 A 等落盘 → 睡一个 throttle 窗口(从 `$:/config/SyncThrottleInterval` 读,默认 1s)→ 再写哨兵 B 等落盘;这期间所有被 throttle 的标题都会变可用并被写出,第二枚哨兵才是真正的队尾。守门:新增 `scripts/verify-wiki-switch.mjs`(真起两个 wiki:切换 / 回滚 / 指针文件 / 非法输入,已进 `verify:e2e`),`verify-seeds-admin.mjs` 增加 5 组内容哈希断言(新鲜 / 用户改过 / 内置更新 / 旧标记两种 / 重新初始化后刷新)。
176
-
177
- - **v0.21.0**(2026-09-11):**注入提示词改为「默认精简 + 可配置 + 不再过期」**。旧提示词手抄了一份工具参数清单,最后一次同步停在 v0.19.0,此后 v0.19.4(`list_tags limit`)、v0.19.5(`delete`/`attach`/`batch_put` 并发令牌与 `attach` 默认拒绝同名覆盖)、v0.20.1(`append` 的 `fields`)都改过工具层——模型实际看到 **6 处过期签名**。现在:① 新增 `prompt.{enabled,mode,extra,override}` 配置(设置页「系统提示词」区块):默认 `slim`(~1.7KB,只保留工具 schema 表达不了的约定:写入/并发纪律、同步纪律、标签约定、可点击链接格式),`full` 形态额外附参数索引但**由 `tiddlywikiToolSummary()` 从注册表实时生成**,不可能再脱节;② **保存后无需重启 dsh web**——section 即时重新注册,当前会话从下一步起生效(DSH `system-prompt/change` 会更新历史里的系统消息),设置页还能一键**预览**即将注入的全文(`GET /admin/prompt`);③ 用户文本里的 `{{…}}` 会被转义(DSH 对未知变量直接抛错,会炸掉整个系统提示词装配);④ 守门:新增 `scripts/verify-prompt.mjs`(slim 不得出现参数清单 / full 的每个工具与每个参数都必须在场 / 两种形态都必须保留治理约定块 / 转义与 extra-override 语义),已进 `verify:unit`。
178
-
179
- - **v0.20.1**(2026-09-11):**修复「覆盖已有条目时内容类型被静默重置」**(数据正确性)。`tiddlywiki_put` / `tiddlywiki_append` / `tiddlywiki_batch_put` 覆盖既有条目时会丢掉它的 `type`:`cleanTiddler()` 把 `type` 当成「构造 PUT body 时跳过的字段」,于是 `finalTypeForWrite()` 再补默认值——`put` 把 `text/css` 的样式条目改成 `text/markdown`(整篇 CSS 被当 Markdown 渲染、样式静默失效)、把 wikitext 笔记改成 Markdown;`append` 更彻底:它自己手写 PUT body,连 `type` 都不写,TW 于是回落 `text/vnd.tiddlywiki`(磁盘上 `.md` + `.meta` 变成 `.tid`,`##`/`**粗体**`/表格全按 wikitext 解析)。现在:**覆盖路径一律保留原 `type`**,Markdown 默认值只给**新建**条目(`$:/` 系统条目除外);`tiddlywiki_append` 与 `put` 共用同一套写策略(`buildWriteTiddler`),并补上了此前缺失的 `fields` 参数;`rename` 与回收站恢复同样不再丢类型。回执也补了诚实提示:新建默认时写「新建且未指定,已默认 markdown」,覆盖时若类型真的变了会写「⚠️ 内容类型已从 X 改为 Y」。守门:新增 `scripts/verify-write-policy.mjs`(7 条纯函数单测,已进 `verify:unit`)与 `verify-audit-fixes.mjs` 的 6 条 E2E(css 保持 css / wikitext 保持 wikitext / markdown 追加不变 / 新建仍默认 markdown / `fields.type` 是唯一改类型入口 / rename+回收站恢复保类型)。
180
-
181
- - **v0.20.0**(2026-09-11):**第四轮代码审计的修复版**(安全 / 用户可见缺陷 / 工程守门 / 死代码清理)。**安全**:① `POST /dsh-tiddlywiki/render` 没有像 `/get`、`/tw`、`/api` 那样拦插件命名空间——TW 的 `/render` 对**任意标题**都渲染,于是 `{"title":"$:/plugins/dsh-tiddlywiki/config"}` 能把配置 tiddler 连 `bridge.token` 与带 PAT 的 `git.remote` 一起渲染出来(本机临时 wiki 实测复现;片段净化器只管标签、管不住正文)。现在抽出一份 `isBlockedProxyTitle()` 并覆盖 `/render`(selftest 断言 403)。② `maskConfigSecrets()` 此前**只遮 token 与 git remote**,而文档一直声称 `auth.password` 也被遮——实测原样返回;现在 `auth.password` 一并打码并附 `passwordSet` 展示位,回填掩码同样被 `stripMaskedSecrets()` 丢弃。**用户可见缺陷**:③ TW 的 `$tw.notifier.display()` 只认**已存在的 tiddler 标题**、不存在时**什么都不做**,而「发送给 Agent」的 `notify()` 传的是自由文本——**所有成功/失败提示都是空操作**(用户点完按钮毫无反馈);现在先写 `$:/temp/dsh/send-to-agent/notice` 再 display(bundle 0.3.4 → 0.3.5)。④ 快速笔记卡片丢掉了 `404 + {notFound:true}` 的响应体,`notFound` 分支永不成立,于是「条目不存在」被显示成「wiki 服务不可用」;`fetchJson` 现在保留 404 正文。⑤ `/edit` 的草稿查找在 v0.19.5 重构时把分支写反:规范草稿**已存在**时反而直接覆盖(丢用户未保存的编辑),扫描到的异构草稿被改名为新条目(留孤儿);恢复 `free ? canonical : 复用既有草稿 ?? 时间戳` 的避让语义,并补 E2E 断言。⑥ 会话「知识库」Tab 的「修改」时间用 `Date.parse()` 解析 TW 紧凑格式(`20260101000000000` → NaN)后原样回显成 17 位数字;改用 `parseTiddlerDate()`。⑦ 设置页点「同步 / 重启 TW」会 `refresh()` 重建整块配置表单,**静默丢弃未保存的输入**;现在只有服务端配置真的变了才重建。⑧ `tiddlywiki_search` 的 `field`/`value` 只在工具层生效,客户端卡片不发送、host 路由也不读——卡片列出的是**未过滤**结果;两侧已打通。⑨ 草稿自动保存调用了带副作用的 `getTags()`,会把 tag 框里没回车的半截词提交成标签;新增纯读的 `peekTags()`。**工程守门**:⑩ CI 之前各自维护一份脚本清单,与 `package.json` 双向漂移——`verify-status-cache` 与 `verify-audit-fixes`(v0.19.5 全部数据安全修复的唯一回归)**从未在 CI 跑过**;现在 CI 只调 `npm run verify:static|verify:unit|verify:e2e|verify:large`。⑪ `verify-render-bundle` 的 `module-type` 断言查的是源码注释而不是 tiddler 字段(假断言,字段丢了照样绿);`verify-package-contents` 不守 `src`/`docs` 也不禁 `scripts/`;均已修正。⑫ 三个浏览器脚本硬编码 `D:/npm-global/...puppeteer-core` 且 Chrome 候选表漂移(Linux 上恒 SKIP),抽出 `scripts/lib/browser-env.mjs`;`waitFor` 与迷你路由分发器各抽成 `scripts/lib/tw-harness.mjs`;`TEXT_LIST_FILTER` 长度预算移进新的 `verify-constants.mjs`(static 档,不再只在两个最重的 TW 套件里断言)。**死代码 / 重复**:⑬ 开启 `tsconfig.noUnusedLocals` 并清掉 16 处死 import/死类型;删 `FLUSH_PROBE_FILE`、`fetchUiConfig({force})`、`sidebar-entry` 的 `initialLabel`、`ToolCallOwnerProps` 未用字段、四个卡片的死 `text` prop、`headerTitle` 的 7 个空 case、`__twDebug`、`panel.ts` 的兼容 re-export、一行包装 `setSessionSummaryTabLabel`;`invalidateStatus()` 由 `invalidateUiConfig()` 一并调用(设置页保存后 FAB/侧栏立即读新值);`GitStatusView` 三份合成一份,`snippetOf`/`safeEqual`/`assertPublicImageUrl` 各合成一份;客户端端点字面量统一收进 `endpoints.ts`;`defineTool` 从不被读取的 `output.schema` 参数移除。**文档**:修掉过期注释与文档(客户端仍写 `/tw/render`、`tool-views` 头部仍称「HTML 来自本地 wiki 所以安全」、`host/routes.ts` 文件头只列 6 条路由、生成的 seed docstring 仍写 `Never throws.`)。守门:`verify-send-to-agent-bundle` 补外层 version 断言与 notify 回归,`verify-secret-masking` 补 auth 用例,`verify-status-cache` 补 invalidate 联动,selftest 补 `/render` 403。
182
-
183
- - **v0.19.5**(2026-09-11):**第三轮代码审计的修复版**(数据安全 / 正确性 / 客户端网络卫生 / 工具性能)。**数据安全**:① `tiddlywiki_attach` 此前**完全不读旧条目**就 `PUT`——附件标题一旦撞上既有笔记(`会议纪要.png` 之类)会把那篇笔记连 tags/自定义字段/正文一起**静默替换成 base64**,是本轮最危险的一条。现在先读后写(004 = 新建,其余错误照常抛出),撞名时**默认拒绝**并要求 `force: true`(并发令牌只证明「读过」、不等于「同意覆盖」),覆盖时仍保留原 tags 与自定义字段,新建附件也会补 `agent-written`;嵌入笔记(`noteTitle`)同样走写策略。② `/edit`(快速笔记直达 TW 原生编辑器)此前把草稿写死 `text/markdown`,而 TW 保存草稿会把字段抄回原条目——**任何 wikitext 笔记被这样编辑一次就降级成 Markdown**,`!` 标题 / `<$list>` / `[[链接]]` 下次全渲染成源码。现在草稿类型跟随原条目,只有新笔记才回落 Markdown。③ 回收站索引读失败/JSON 损坏时,`delete` 此前把它当「空索引」再全量覆盖 → **之前所有回收站记录变成既查不到、也清不掉的孤儿**(体积还留在 git 里)。现在读失败即中止(新增 `TrashIndexUnavailableError`),损坏也不静默重建,`trash list/restore/empty` 一律显式报错。④ `rename` 先写新标题、再删旧标题,此前若删除失败会**抛裸错**让调用方以为整个重命名没发生;现在如实回报「两份副本都在,请手动删除旧标题」。⑤ `tiddlywiki_delete` 支持 `expectedModified`/`expectedRevision`/`force`——删除比覆盖更具破坏性,此前却没有任何乐观并发保护(`get` → 人类在 TW 里改动 → `delete` 会把新改动一起丢进回收站)。**正确性**:⑥ 会话汇总只探测前 300 篇,却把**所有**条目按探测结果渲染,超出上限的会被谎报成「⚠️ 已删除/不存在」;现在未探测的条目标「(未探测,超出单次查询上限)」。⑦ `/api` 透传补上方法白名单(宿主按 pathname 分发,此前 TRACE 之类的任意方法都会被转发给 TW)。⑧ `/tw` 代理遇到带 body 的 DELETE 不再把请求体留在 socket 上(此前会挂到 30s 超时)。⑨ `/admin/config` 不再把所有失败都当 400(服务不可用/超限走 413/500);`/upload` 的「超限」判定与 `errorStatus` 统一。⑩ `WikiServer` 成功启动时清掉上一次失败的 `error`(自愈成功后 `/status` 与面板提示不再长期显示过期故障)。**工具性能**:⑪ `batch_put` 从「顺序 2N 次 REST」改为 **4 路有界并发**(结果按入参下标回填,顺序与逐条容错完全不变),`autoCommit` 移出循环。⑫ 快速笔记找草稿从「拉整份 listing」改为**先单条 GET 规范草稿名**,listing 只作兜底。**客户端网络卫生**:⑬ 新增共享的 `/status` 读取器(2s TTL + **在途合并**)——此前中央面板、侧栏框架、同步按钮、快速笔记配置**四处各有一份 `fetchStatus`**,页面加载时会同时发多次请求,而 host 每处理一次 `/status` 要起最多 5 个 git 进程;⑭ `ui-config` 的缓存同样改为 promise(并发调用共享一次请求),失败不缓存。**其它**:`ToolsDeps.noteTag` 死代码删除;`put`/`batch_put` 描述改为「防抖自动 commit(默认 60s)」以免模型误以为写完即提交。守门:新增 `scripts/verify-audit-fixes.mjs`(10 条 E2E,真实 TW + 真实路由,每条都能复现旧缺陷)与 `scripts/verify-status-cache.mjs`(5 条单测,`tsx` 直跑源码),均已并入 `verify:unit` / `verify:e2e`。
184
-
185
- - **v0.19.4**(2026-09-11):**标签列表全面「有界」——最后一个没有上限的列表接口**。`GET /dsh-tiddlywiki/tags` 此前会把**每一个**去重标签(大 wiki 上千个)连同计数全量回给浏览器,而回复流工具卡只是展示前 60 个;`tiddlywiki_list_tags` 更严重——同样全量灌进**模型上下文**。现在:① `/tags` 支持 `limit`(1–500,缺省仍是全量,快速笔记的标签自动补全保留完整词表)与 `sort=alpha|count`(默认 `alpha`,`count` 按使用次数降序),回包新增 `total`(去重后的真实总数)与 `truncated`;② 工具 `tiddlywiki_list_tags` 新增可选 `limit`(默认 200、上限 1000),截断时 render 明说「共 N 个,仅列出最多的 M 个」,不再让模型以为那就是全部;③ 客户端 `TagsCard` 改为直接请求 `?limit=60&sort=count`(不再下载上千条再丢掉),并用 `total` 显示「共 N 个 · …另有 M 个」;④ 顺手消掉重复实现:路由与工具现在共用 `TiddlyWebClient.tagStats()` 一份计数逻辑(此前路由自己遍历一遍,且不跳过 `$:/` 系统标题,两侧口径可能不一致),`/recent`、`/search`、`/tags` 的 limit 解析收进 `readLimit()`/`readOptionalLimit()` 两个助手。守门:selftest 增 6 条断言(截断/`total` 不丢分母/`tags` 与 `items` 同集同序/上限 500/`sort=count` 单调递减/无 limit 时 `truncated=false`),`verify-tools` 增 `list_tags` 的 limit 用例。
186
-
187
- - **v0.19.3**(2026-09-11):**清掉审计清单里剩下的中优先级项**(安全面 / 健壮性 / 正确性)。**密钥不再外泄**:`bridge.token` / `ui.sendToAgent.token` 会被 `GET /admin/state` 与 `POST /admin/config` 的回包替换成 `********`(并给 `tokenSet` 标记),设置页原样保存不会把真 token 覆盖成掩码、清空即删除;`git.remote` 里的 PAT 同时打码,回填同值也被丢弃。更要紧的是**代理旁路**:`/get` 早先禁止读插件配置 tiddler,但 `/tw` 与 `/api` 会把任意路径转发给 TW 子进程,而它的 TiddlyWeb REST 对 `$:/…` 标题照答不误——`GET /dsh-tiddlywiki/tw/recipes/default/tiddlers/%24%3A%2Fplugins%2Fdsh-tiddlywiki%2Fconfig` 实测能拿到配置正文。现在两个代理都拦截 `$:/plugins/dsh-tiddlywiki/` 命名空间(selftest 有回归断言)。**健壮性**:所有路由统一经 `guardHandler` 包装——此前每个注册都是 `void handleX(req,res)`,而宿主没有 `unhandledRejection` 处理器,任何一次 rejection(含代理里 try 之外的 `new URL()`)都会**直接结束 dsh web 进程**、请求挂死;剪藏桥 listen 之后补上**常驻 `error` 监听**(此前移除唯一监听后,运行期 server error 就是未处理事件 → 崩进程),`stop()` 用 `closeAllConnections()` + 1s 上限,keep-alive 不再是卸载时的死等;`/tw` 代理在客户端断连时**中止上游 fetch 并销毁流**(此前会一直拉到 30s 超时)。**正确性**:会话汇总不再把查询失败(超时/5xx)报成「已删除/不存在」(只有 404 才算不存在),来自请求体/会话日志的 sessionId、rename 旧标题、检索词一律 HTML 转义(TW 会原样透传 HTML,汇总片段又会被注入 DSH 页面),sessionId 还加了字符集白名单;`/upload` 改**原子写**(`wx` + 仅对 EEXIST 走后缀链,修掉 TOCTOU 与「任何 access 错误都当名字可用」)、文件名末尾点/空格先规范化(`evil.html.` 此前能绕过可执行扩展名黑名单);SSRF 白名单补上多播 `224/4`、保留 `240/4`(含 `255.255.255.255`)、`192.0.2.0/24`、`198.51.100.0/24`、`203.0.113.0/24`、`192.88.99.0/24`;`readWikiInfo` 对顶层 `null`/数组给出可读错误而不是 `TypeError`;`/admin/info` 空操作不再重写 `tiddlywiki.info` + 重启 TW,校验失败 400 / 内部失败 500 分开;`/agent/create` 先校验工作模式再建目录(此前未知模式会留下孤儿目录);body 超限统一 413。
188
-
189
- - **v0.19.2**(2026-09-11):**v0.19.1 的内容 + 让 flush 哨兵真正生效**。v0.19.1 在 npm 上只是被 **staged**(未确认),而那份 tarball 少了下面这个修复,所以补一个版本号(**请以 0.19.2 为准,0.19.1 可以取消掉**)。修复内容:`flushPendingWrites`(「重启 TW 前把 syncer 队列排干」的哨兵)**从来没成功过**——① 它写 `type: 'text/plain'` 却去等 `.tid` 文件,而 TW 的文件系统适配器按内容类型决定扩展名(`text/plain` 实际落 `.txt` + `.txt.meta`);② 它用 `mtime >= startedAt` 判定,而哨兵是紧接在等待之前写的,Windows 上新文件的 mtime 可能落在同一/前一个时钟刻,条件永不成立。两者叠加 → 每次调用都超时返回 false,而**所有调用方只把它当 warning**:`/sync`、工具 `git_sync`、seed 重启与启动路径的「排干 syncer」全部静默失效(启动路径还白等 8 秒)。现在按「文件名含 `dsh-tiddlywiki_flush-probe` + 文件内容含本次随机戳」判定(与扩展名/时钟无关),实测 <1.2s 返回;selftest 加了断言,另修掉一处「轮询结果被忽略就重启」的测试竞态(首轮 GitHub CI selftest 抓到的 404)。**其余同 v0.19.1**:CI 复活(`.gitattributes` LF + sourcemap 可复现)、渲染片段净化(存储型 XSS)、`/note` `/edit` 共享写策略(不再清掉人类笔记的标签/自定义字段)、重启单飞与启动/teardown 握手、`tiddlywiki_get` 字段摊平、`lint` 两个坏检查、客户端泄漏与草稿回写等。
190
- - **v0.19.1**(2026-09-11):**第二轮代码审计的修复版 + 让 CI 重新变绿**。**顺带抓出一个"静默失效"的哨兵**:`flushPendingWrites` 一直用 `type: 'text/plain'` 写哨兵、却去等 `.tid` 文件(TW 的文件系统适配器按内容类型决定扩展名,`text/plain` 实际落 `.txt` + `.txt.meta`),叠加 `mtime >= startedAt` 的判定在新写入的同一时钟刻可能永不成立 → 每次调用都超时返回 false,而所有调用方只把它当 warning。也就是说 v0.19.0 声称的「重启前排干 syncer」**其实一直没生效**(`/sync`、工具 `git_sync`、seed 重启与启动路径全都受影响,启动路径还会白等 8 秒)。现在改成**按内容随机戳匹配**(不依赖扩展名、不依赖文件时间),实测 <1.2s 返回,并有 selftest 断言守门。**CI 其实是红的**:`lib/index.js.map` 内嵌源文件原文(sourcemap 的 `sourcesContent`),Windows 工作区是 CRLF、GitHub runner 检出是 LF,Linux 上重建出的 map 与提交版必然不同 → `static` 作业的 `git diff --exit-code -- lib/` 每次失败,而它一挂,另外三个 job(`needs: static`)**根本不会跑**(v0.19.0 声称的四档守门实际停摆)。修法:新增 **`.gitattributes`(`* text=auto eol=lf`)** 把工作区钉成 LF 并重建提交 lib/;顺带补上 **send-to-agent 的「bundle ↔ seed 逐字节」守门**(render 早就有,缺这条时「跑了 build 忘跑 gen」会 CI 全绿却发旧按钮)、`prepublishOnly` 预检、CI 失败时能看懂的错误提示。**安全(存储型 XSS)**:回复流卡片与会话汇总把 TW 片段 `dangerouslySetInnerHTML` 进 DSH 页面,而 TW 的解析器只剥 `on*`——实测 `<iframe src="javascript:…">`、`<a href="javascript:…">`、`<form action="javascript:…">` 全部原样通过(任何写进 wiki 的正文——agent 笔记、剪藏、导入——都能在 DSH 同源页面执行脚本并调用未鉴权的 `/dsh-tiddlywiki/*` 路由)。新增 **host 侧片段净化器**(`src/host/sanitize.ts`:丢 script/style/iframe/object/embed/form/svg/math…、丢 `on*`/`srcdoc`/`srcset`、URL 属性只放行 http(s)/mailto/tel/相对路径/栅格 `data:image/*`,并先做实体解码挡 `&#106;avascript:` 这类混淆),新增 **`POST /dsh-tiddlywiki/render`** 作为客户端唯一渲染入口(客户端不再直连 TW 的未净化 `/tw/render`,因此**老 wiki 也立刻受保护**);配套 payload 回归 `scripts/verify-render-sanitizer.mjs`(已进 CI)。**数据安全**:`/note` 与 `/edit` 此前不读旧条目就整体 PUT,用同名标题保存会**清掉人类笔记的标签与自定义字段**——现在两条人类路径与 agent 工具共用新的 **`src/host/write-policy.ts`**(先读后写、保留字段、`agent-written` 只给 agent 新建的条目);快速笔记卡片对「🕘 最近」载入的笔记回传 **`expectedRevision`**,人类在 TW 里的并发修改会让保存得到 **409** 而不是被覆盖;**重启 TW 前先排干 syncer**(`/sync` 与工具 `git_sync` 此前漏了 flush 哨兵——刚保存的笔记可能被重启吞掉);`WikiServer.restart()` 改为**内部单飞**(admin 路由不受 routes 层互斥保护,并发重启会留下孤儿 TW 进程);启动任务是 fire-and-forget,teardown 现在**先置 `disposed` 并等它收尾**(插件热更新/禁用时不再「disposer 先跑完、startup 后 spawn」留下孤儿进程与泄漏监听)。**工具层**:`tiddlywiki_get` 的自定义字段此前渲染成 `fields=[object Object]`(单条 GET 的 `fields` 嵌套没摊平)——`q`/`due`/`clip-url` 对模型完全不可见,已修;`tiddlywiki_lint` 的死链检查把**每一条指向图片/附件的链接**都误报成死链(标题集合取自排除二进制的列表),`missing-type` 则**永远不触发**(TW 服务端会给无 type 的条目补 `text/vnd.tiddlywiki`,改用 `[!has[type]]` 过滤器判定),描述与 `checks` 里的 `large-binary` 从未实现,已删除;`tw-api` 的瘦列表(`/tags`、lint 用)加短 TTL 缓存。**客户端**:TW 就绪重试的 40×150ms 定时器、`sync-button` 卸载后的迟到回调、`injectStyles` 的共享 `<style>` 被旧 disposer 删掉、渲染阶段改模块级缓存(改到 effect 阶段)、`tiddlywiki_list_tags` 卡片不截断(现最多 60 个 + 提示余量);快速笔记「在 TW 中编辑」成功后不再把同一份内容重新写成「未保存草稿」。**工程**:工具层 E2E 从 10 个工具补齐到 **15 个**(并断言一个都不能少)、selftest 新增「人类写路径保留字段 / 409 冲突」与「host /render 净化」端到端断言、`package-lock.json` 版本与 npm 描述同步。
191
-
192
- - **v0.19.0**(2026-09-11):**按代码审计结论做的全量修复 + 一批新能力**(安全 / 数据安全 / 工具层 / 客户端 / 工程)。**安全**:① 每个路由现在**校验 HTTP 方法**(此前 `http.ts` 主动放行 GET、宿主按 pathname 分发,于是 `GET /dsh-tiddlywiki/sync` 会 pull+commit+**push**、`GET /restart` 重启 TW、`GET /upload` 落文件——任意网页一张 `<img>` 即可触发;实测复现,现已一律 405 且无副作用);② 剪藏桥 SSRF 守卫的 IPv6 判定改为**按字节解析**(此前 `::ffff:7f00:1`、`0:0:0:0:0:0:0:1`、`::0:1` 等回环写法全部放行——实测复现);图片下载改为**逐跳 pin 住已校验的 IP**(堵 DNS rebinding TOCTOU)、**流式限长**(不再先全量缓冲再判 15MB)、禁用透明解压;③ TW 子进程的 spawn 日志**不再写明文口令**(该日志经无需认证的 `GET /status` 返回),`/status` 还会脱敏 git remote 里的 token、剪藏桥健康接口不再回显 port/tag/tokenSet、`/get` 不再暴露插件配置 tiddler、`/agent/sessions` 补上 token 守卫、token 比较改常数时间、`/tw` 代理改为**流式转发**(大附件不再整包进内存)、上传拒绝 html/svg 等可在同源执行的文件、`/note`/`/edit` 拒绝 `$:/` 标题。**数据安全**:④ 新增 `tiddlywiki/markdown` **插件自举**——全新 wiki 是用 `--init server` 建的,不含 markdown 插件,而插件默认把每篇笔记写成 `text/markdown`,此前新用户开箱即见 Markdown 源码;⑤ `tiddlywiki_put` 覆盖已有笔记时**不再丢掉原有的标签与自定义字段**(PUT 是整体替换,旧实现只发 title+text;单条 GET 的自定义字段还嵌套在 `fields` 里,一并修掉);⑥ 新增**乐观并发**:`expectedRevision`/`expectedModified` + `force`,人类在 TW 编辑器里的改动不再被静默覆盖;⑦ `tiddlywiki_delete` 默认**软删除进回收站**(`tiddlywiki_trash` 可 list/restore/empty);⑧ `readWikiInfo` 只把 ENOENT 当「没有配置」(此前读失败=空配置,设置页保存会整文件覆盖 `tiddlywiki.info`),写入改为备份 + 原子替换;seed 的 `tw-web-host` check 不再吞错;重启 TW 前用**flush 哨兵**排出 syncer 队列(force 重新初始化曾随机丢掉 `tw-web-host`)。**工具层(10 → 15 个)**:新增 `tiddlywiki_append`(增量追加 / 段落定位)、`tiddlywiki_backlinks`、`tiddlywiki_attach`(本机文件或公网 URL → 二进制附件)、`tiddlywiki_lint`(垃圾标签 / 死链 / 空笔记 / 缺内容类型)、`tiddlywiki_trash`;`search` 改为**相关度排序 + 命中处上下文片段 + 字段过滤 + limit 上限 + TW 紧凑日期解析**(此前 `since` 过滤恒为空)、`list_tags` 不再统计只挂在二进制附件上的标签、`batch_put` 的单条失败不再被参数预校验整批打断、列表加短 TTL 缓存。**客户端**:草稿卸载/刷新前落盘、草稿按窗口分键(不再跨标签页互相覆盖)、`session-summary` 自愈循环与卸载后 setState 修复、中央面板 `hidden` 状态修正、三个常驻定时器加上停止条件、wiki 链接不再吞掉 Ctrl/中键、编辑器弹窗补 `role=dialog` 与焦点、列表项键盘可达、21 处 `:focus-visible`、移动端 `touch-action`、`numField` 的 `0` 不再被吞。**工程**:新增 **GitHub Actions CI**(静态检查 + 自测 + 单元 + E2E 四档,含 `git diff --exit-code -- lib/` 抓「改了 src 忘了 build」)、9 个新验收脚本(auth 模式此前零覆盖、工具层、seed 读失败反例、git 冲突、崩溃自愈、大 wiki、render bundle 逐字节、发布包内容、版本一致性)、`npm run verify:*` 方便本地一键跑、`lib/client.bundle.js` 不再入库、AGENTS 的 `grep @deepseek-ai` 检查修正为「真实 import」。**升级提示**:重启 dsh web 后新会话生效;客户端改动需刷新页面。
193
- - **v0.18.0**(2026-09-10):**一轮全量代码审计后的修复版**(数据安全 / 安全面 / 前端稳定性 / 工程卫生,共 40+ 项)。**数据不再静默丢失**:① seed 只在**真的 404** 时才认为条目缺失(此前任何读取错误都被当「不存在」,瞬时故障会让非 force 的启动 seed 覆盖你改过的同名笔记、`$:/DefaultTiddlers`、send-to-agent / render bundle);② `wiki/.gitignore` 不再每次启动被整份重写(用户自定规则改前会被冲掉);③ 配置 tiddler 读失败不再清空内存覆盖层(随后在设置页保存一项配置会丢掉其余全部覆盖);④ 只有 **render-route 真的重写后**才重启 TW,且要等它落盘(否则重启会丢掉未 flush 的写入——这条是本版新引入又被测试抓出的回归)。**正确性**:`/render` 现在按 tiddler 自己的 `type` 渲染——**Markdown 笔记在回复流卡片/汇总里终于按 Markdown 渲染**(此前硬编码 wikitext,`## 现象` 会被当成 wikitext 列表,实测复现)。**安全**:所有写路由(`/admin/*`、`/note`、`/edit`、`/upload`、`/sync`、`/restart`、`/api`、`/tw` 的写方法)加**同源/CSRF 守卫**(跨站写请求 403,跨站 GET 不受影响);剪藏桥图片下载加 **SSRF 守卫**(仅公网 http(s),逐跳校验重定向,拒绝回环/内网/云元数据地址);`bridge.port` 设置页改动现在**真的生效**(此前只读 cordis 基座);`auth.username/password` 从此**可用**(内置 REST 客户端与就绪探测带 Basic 认证,代理转发 `WWW-Authenticate` 让浏览器弹登录框,此前配置了用户名会让插件 20s 后启动失败)。**前端**:编辑器弹窗不再因同草稿重开而整机重载、并纳入面板互斥;中央面板自愈列元素重建;右栏 Tab 重新可见会重新探测状态;侧边栏入口插入不再抛 `NotFoundError` 连带拖垮 FAB/面板;同步中不再被 30s 轮询覆盖状态;`injectStyles` 可卸载(热重载拿到新样式);每处 mount 独立 try/catch;面板的 DOM 观察/滚动测量合并到 requestAnimationFrame(流式输出时不再每帧强制样式重算)。**工具与仓库**:`put`/`batch_put` 不再把读取失败当新条目而误打 `agent-written`,`fields` 不能覆盖 `title/text/tags`,`batch_put` 逐条报告失败;git 的 commit 与 pull 串行化(不再争 `.git/index.lock`);`/status` 的 git 摘要加 2s 缓存(每次调用少跑 5 个 git 进程);请求中途断开不再挂住路由;自动端口被占时会重新探测;`tiddlywiki.info` 损坏不再让后台 500;会话汇总对后代会话/条目数设上限。**工程卫生**:bundle 版本单一来源(`scripts/bundle/versions.mjs`,渲染路由 0.2.0)、生成器从 bundle 读版本(外层 0.3.2 漂移修正为 0.3.4)、发布包不再带 `client.bundle.js` 中间产物、`verify-*` 假阳性修复(未 await 的用例、被当成断言的 console.log)、selftest 失败也会回收 TW 子进程、`gen-seed-home` 默认剥离私有人口、`verify-menubar-theme` 不再默认打作者本机端口。
194
- - **v0.17.0**(2026-09-10):**移除:DSH Better Sidebar(dsh-better-sidebar)Tab 集成**。旧版通过 `ctx.betterSidebar.registerTab({id:'dsh-tiddlywiki'})` 注册的 tab kind 会与其它注册方冲突,浏览器控制台报 `sidebarRight: tab kind "dsh-tiddlywiki" is already registered (extension)`;本版**整体删除**该注册路径(删除 `better-sidebar-tab.ts`、client 入口的挂载块,以及 `ui.showBetterSidebarTab` 配置项/设置页开关),插件不再引用 `ctx.betterSidebar` 服务。与 dsh-better-sidebar 的**界面共存**(中央面板 z-index 让位于其侧边栏按钮)保留;右侧边栏入口请用 DSH 原生 rightbar(`ui.showRightbarTab`)。**更新后刷新页面**生效;旧版可临时用 `ui.showBetterSidebarTab=false` 规避冲突。
195
- - **v0.16.28**(2026-09-10):**修:拖拽版剪藏书签在知乎等真实页面报 `SyntaxError: Unexpected token ';'`**。根因(真实 Chrome 实验证实):0.16.27 把书签 href 做 **HTML 实体转义**(`&quot;`/`&amp;`/`&lt;`),但浏览器的 `a.href` **不做实体还原**——拖拽成书签后存的就是实体文本,执行时即解析失败。修复:href 改为 **percent-encoding**(`CLIP_BRIDGE_DRAG_HREF`,属性里只剩 `%XX`,天然无需任何属性转义);Chrome 执行 `javascript:` URL 前会 percent 解码(实测通过)。浏览器 E2E 升级为**直接以编码 href 走 `location.href` 执行**(完全等价拖拽书签的执行路径)并断言浮层弹出;静态验收改为 `decodeURIComponent(href) === 代码常量` 一致性校验。行为不变——**已拖过旧版书签的用户需重新初始化 seed 后重新拖一次**(旧书签仍然坏的,因为里面的代码是实体文本)。
196
- - **v0.16.27**(2026-09-10):**剪藏书签支持「拖拽安装」**。seed 文档新增一个**可拖拽按钮**(markdown 内嵌原始 HTML 锚点,href 即书签代码)——按住拖到浏览器书签栏/地址栏松手即装好,免去复制粘贴;代码 fence 保留作兜底。书签代码抽成单一常量 `CLIP_BRIDGE_BOOKMARKLET`(锚点 href 做 HTML 实体转义),并新增**同步一致性校验**(`verify-clip-bridge.mjs`:href 解码后与代码逐字相等)与**浏览器 E2E**(`verify-clip-bridge-browser.mjs`:真实 TW + 无头 Chrome 渲染真实 seed 文档,断言 javascript: href 原样保留 / draggable / 解码一致 / 真浏览器里执行书签弹出浮层 / 无 JS 异常)。
197
- - **v0.16.26**(2026-09-10):**修:剪藏书签在真实页面上崩溃**(`Cannot set properties of null (setting 'value')`)。根因:浮层字段在 `p` 尚未挂到 document 前就用 `document.getElementById` 取值(脱离文档树查不到 → null);同时 `cb_*` id 可能与页面自身元素冲突。修复:所有浮层字段改为 `p.querySelector` 作用域内查找 + 字段赋值挪到 `appendChild` 之后;并把书签放进 **jsdom 真实 DOM 端到端跑通**(浮层/填充/选图/提交载荷/结果态 12 项全过)作为验证,`verify-clip-bridge.mjs` 新增静态回归断言(禁未限定 getElementById + 挂载时序)。行为无变化,仅书签代码(seed 文档)修复。
198
- - **v0.16.25**(2026-09-10):**剪藏桥支持图片剪藏(浮层选图)**。书签升级为**选择式浮层**:可改标题/编辑选中文字,浮层列出页面图片缩略图(自动跳过 <80px 小图标与 data:/blob: 占位、去重,`og:image` 标「封面」并默认勾选)勾选图片后一键剪藏。图片由桥(DSH 进程侧)**下载字节存为二进制附件 tiddler**(`type: image/*` + base64 正文,即 TW 原生附件形态;`clip-url`/`clip-note` 溯源),笔记改用 wikitext 用 `[img[标题]]` 内嵌展示;某张下载失败自动降级为链接、不阻塞整次剪藏;纯文字剪藏保持原 markdown 路径不变。安全不变(127.0.0.1 + Host 校验 + 可选 token;下载带 referer/UA 应对常见防盗链)。验收新增 image-flow 测试 + 书签语法检查(`verify-clip-bridge.mjs` 24 项全过)。
199
-
200
- - **v0.16.24**(2026-09-09):**新:本地剪藏桥 + 书签小工具**。DSH 进程内新增只监听 **127.0.0.1** 的 HTTP 桥(配置组 `bridge.*`:`enabled` 默认关、`port` 默认 8618、`token` 可选共享口令、`tag` 默认 `clip`)——浏览器书签(JS 小工具)把当前页标题/URL/选中文字 POST 给它,经唯一写入通道落进 wiki(markdown、重名自动 `(2)` 去重、默认 `clip` 标签、随 wiki 自动 git commit)。安全:Host 头白名单防 DNS rebinding、CORS/PNA 预检放行(https 页面书签可用)、`bridge.token` 强烈建议设置。附**可选 seed** `clip-bridge`:「本地剪藏桥(书签小工具)」说明文档(含书签代码/启用步骤/安全说明/curl 用法,打 `dsh-docs` 标签进「📚 插件文档」栏,可反初始化);设置页新增 4 个配置字段。`enabled`/`token`/`tag` 设置页保存即生效,改端口需重启 dsh web。
201
-
202
- - **v0.16.23**(2026-09-09):**新:DSH Better Sidebar(dsh-better-sidebar)集成**。插件通过 `ctx.betterSidebar` 服务注册 TiddlyWiki 为 Better Sidebar 侧边栏的 tab 类型——+ 菜单点击即以 tab 打开完整 TW 编辑器(同源代理 iframe,与聊天并排);TW iframe 机制抽成共享模块(rightbar/better-sidebar 复用:lazy-load / status 轮询 / 主题同步 / hash 导航 / 互斥协议),回复流 wiki 链接在同一处统一路由到任一可见的侧边 TW tab。配置 `ui.showBetterSidebarTab`(默认 true)可开关;未安装 Better Sidebar 自动跳过。**更新后刷新页面**即可看到入口。
203
- - **v0.16.22**(2026-09-09):**seed 体系三层化 + 文档中心起步包**。① 分层:**核心**(自动、不可移除:发送按钮/渲染路由/代理基址)+ **起步**(首次安装默认写、可移除:插件说明 + 新增「示例与文档」seed——主题汇总模板/教程/三个示例主题页)+ **可选**(手动:首页/所有文章/新增「自定义样式」seed/顶栏主题);所有种子**安全跳过**(同名 tiddler 已存在绝不覆盖)。② 新增 **`dsh-docs`** 文档合集约定:所有 seed 文档打该标签,首页「📚 插件文档」tabs 栏自动收录。③ seed 版首页改为**通用版**(`gen-seed-home --strip-private`:剥离作者私有人口,注入文档栏)。④ **修**:首页快速记笔记的 tags 筛选器 bug(`then[[todo]]` 操作数双括号导致「筛选器错误」被拆成 6 个 tag → 改为 `then[todo]`)。
204
- - **v0.16.21**(2026-09-09):**新:DSH 右侧边栏(rightbar)集成**。注册 TiddlyWiki 为右侧栏 tab 类型——rightbar 首页出现「**TiddlyWiki 知识库**」入口盒,点击即在右侧栏以 tab 形式打开完整 TW 编辑器(同源代理 iframe,跟随 DSH 主题),**与聊天并排**;配置 `ui.showRightbarTab`(默认 true)可开关。**更新后刷新页面**即可看到入口。
205
- - **v0.16.20**(2026-09-09):**修:`search`/`recent` 在大 wiki 上超时**。根因:列表请求把全部 tiddler(含图片等二进制附件)的 base64 正文拉回来(2418 个 tiddler ≈ 515MB/17s)。修复:`search`/`recent`/`get` 在**服务端**只取文本 tiddler(无 `type` 或 `text/*`);二进制 tiddler 完全排除,`get` 对二进制只回元数据。实测 515MB/17s → **6.24MB/0.3s**。
206
- - **v0.16.19**(2026-09-07):**修:会话「知识库」Tab 把汇总当源码显示**——TW 5.4.1 把 `$:/temp/` 条目一律按代码块渲染;改为 `/tw/render` 原生片段管线(与回复流工具卡同链路),不再用 iframe/story view。
207
- - **v0.16.18**(2026-09-07):README 全面精简重写;seed 优化——首页 seed 跟随 wiki 现状、修「所有文章」回主页死链、doc-note 文案修正。
208
- - **v0.16.17**(2026-09-07):点击快速笔记可选直达 TW 原生编辑页(`ui.quickNoteMode`)+ 修「在 TW 中编辑」弹窗 ✕ 关不掉。
209
- - **v0.16.16**(2026-09-07):修会话知识库 Tab 误入编辑草稿显示源码——三层防误编辑(清残留草稿/吞草稿创建/禁 ✏️)。
210
- - **v0.16.15**(2026-09-07):`put`/`batch_put` 未指定类型自动默认 `text/markdown`;警告 `fields.type` 是内容类型保留字段。
211
- - **v0.16.14**(2026-09-07):修知识库 Tab「佚失条目」根因——浏览器端 TW 天生排除 `$:/temp`,改为 iframe 就绪后注入 + 原生 hash 导航。
212
- - **v0.16.11**(2026-09-06):新增会话顶部「知识库」Tab(本会话相关 wiki 汇总,volatile `$:/temp`)。
213
- - **v0.16.0**(2026-09-05):回复流原生 TW 工具卡片 + 可点击 wiki 链接(新增 `render-route` seed)。
214
- - **v0.15.0**(2026-09-05):初始化不强制 + 可反初始化(seed 拆核心/可选两层)。
215
- - **v0.14.0**(2026-09-05):「发送给 Agent」按钮独立图标 + 附加说明 + 权限选择。
216
- - **v0.13.0**(2026-09-04):menubar 顶栏主题自适应。
217
- - **v0.12.0**(2026-09-04):新默认主页 + 「所有文章」两列分页页。
218
- - **v0.11.0**(2026-09-04):「发送给 Agent」支持选择工作模式(Agent 预设)。
219
- - **v0.10.0**(2026-09-04):统一 seed 注册表 + 设置页「初始化」。
220
- - **v0.8.0**(2026-09-04):嵌入式 TW 跟随 DSH 深浅主题。
221
- - **v0.7.0**(2026-09-03):TW 一键把当前笔记发送到 dsh 会话。
222
- - **v0.6.0**(2026-09-03):同源代理(远程访问核心)。
223
- - **v0.5.0**(2026-09-03):Agent 工具扩充 + 快速笔记草稿自动保存 + 统一知识库 FAB。
224
-
225
- ---
226
-
46
+ ## 历史版本(一句话,150 条,新的在上)
47
+
48
+ | 版本 | 日期 | 变更 |
49
+ |---|---|---|
50
+ | v0.30.65 | 2026-10-09 | 新增 tiddlywiki_replace 工具(笔记正文局部替换) |
51
+ | v0.30.63 | 2026-10-09 | 默认提示词把「笔记内引用」与「聊天引用」拆成两条链接规则;`config.prompt.override` 设置页说明勘误 |
52
+ | v0.30.62 | 2026-10-09 | 第二轮审查三类修复(回收站两种格式合并 / `git_sync`·`git_resolve` 改用会话作用域目录 / 剪藏目标库没在跑改 503 点名)+ 提示词 −14% |
53
+ | v0.30.61 | 2026-10-09 | 修 tiddlywiki_trash list 少报一条 —— TW 给块级片段的首元素套 <p> |
54
+ | v0.30.60 | 2026-10-09 | 修 v0.30.59 的 tiddlywiki_trash 静默谎报「回收站是空的」 |
55
+ | v0.30.59 | 2026-10-09 | 软删除改为「跟着 wiki 已有的回收站机制走」;同时把一条会误导人的注释改成实测事实 |
56
+ | v0.30.58 | 2026-10-09 | tiddlywiki_git_sync 不再要求显式配 git.remote —— 配置为空时回落到该库所在仓库自己的 origin |
57
+ | v0.30.57 | 2026-09-30 | 给 AGENTS.md 的注入预算装上守门 |
58
+ | v0.30.56 | 2026-09-30 | AGENTS.md 的 wiki 指针现在有守门了 |
59
+ | v0.30.55 | 2026-09-30 | 把新守门接进 prepublishOnly + 修 verify-all 的收尾 |
60
+ | v0.30.54 | 2026-09-30 | 铁律 #12 终于有了执行者 —— 范围本身一致 |
61
+ | v0.30.53 | 2026-09-30 | 让「本地全绿 = 可以提交」这条等式真正成立 |
62
+ | v0.30.52 | 2026-09-30 | 新增 `verify:sync` —— `lib/` 与 `src/` 的同步此前只有 CI 查(全仓唯一「本地全绿、CI 才红」的类别) |
63
+ | v0.30.51 | 2026-09-30 | 把「barrel 公共面」从人肉纪律变成机器棘轮 |
64
+ | v0.30.50 | 2026-09-30 | index.ts 724 → 674:拆出自举阶段 `index-startup.ts` |
65
+ | v0.30.49 | 2026-09-30 | 修 v0.30.48 引入的真缺陷 + 补「barrel 公共面」守门 |
66
+ | v0.30.48 | 2026-09-30 | index.ts 955 → 724:拆出路由 / admin 装配 `index-routes.ts` |
67
+ | v0.30.47 | 2026-09-30 | note-widget 588 → 573:拆出底栏 `note-widget-foot.ts` |
68
+ | v0.30.46 | 2026-09-30 | note-widget 615 → 588:拆出上传接线 `note-widget-upload-ui.ts` |
69
+ | v0.30.45 | 2026-09-29 | note-widget.ts 拆分第四轮 —— 抽出「保存 / 在 TW 中编辑」整组(677 → 615 行),并在同一轮 —— 其余守门一条都不用改 |
70
+ | v0.30.44 | 2026-09-29 | note-widget 750 → 677:拆出草稿生命周期整族(两个状态跟着函数走) |
71
+ | v0.30.43 | 2026-09-29 | note-widget 817 → 750:拆出卡片定位与拖动 `note-widget-placement.ts`;顺手修 7 处写错版本号的注释 |
72
+ | v0.30.42 | 2026-09-29 | index.ts 1036 → 955:拆出纯配置面 `index-config.ts`(先切纯的那半边) |
73
+ | v0.30.41 | 2026-09-29 | note-widget.ts 拆分第一轮 —— 抽出两片,993 → 817 行 —— 守门同步三处(都是"钉语义不钉排版") |
74
+ | v0.30.40 | 2026-09-29 | tw-frame.ts 内核抽出 tw-frame-hash.ts —— 关键设计:只读状态用访问器传,不改名;守门同步 |
75
+ | v0.30.39 | 2026-09-29 | tw-frame 状态合并第二片:`pendingHash` / `wikiSwitchPending` 进 `surfaceState`(行为零变化) |
76
+ | v0.30.38 | 2026-09-29 | tw-frame.ts 内核:第一步「状态合并」起手 —— 为什么不是"直接抽取";先量爆炸半径再动手 |
77
+ | v0.30.37 | 2026-09-29 | 给 v0.30.36 补上缺的那条行为守门 |
78
+ | v0.30.36 | 2026-09-29 | 补完 v0.30.35 的另一半:未知变化 ⇒ 保守重启该仓库下的全部库 —— 仍未加行为守门 |
79
+ | v0.30.35 | 2026-09-29 | `pull` 的 diff 失败不再被当成「没有变化 ⇒ 不重启任何库」(本版修「不再沉默」那一半) |
80
+ | v0.30.34 | 2026-09-29 | 把三个「手动浏览器守门」写进文档 |
81
+ | v0.30.33 | 2026-09-29 | ConfigStore 的写路径:核实后决定「不改」,并把理由写具体 |
82
+ | v0.30.32 | 2026-09-29 | 给「共享写策略」补上调用点守门 |
83
+ | v0.30.31 | 2026-09-29 | 把「共享写策略」的例外清单写进代码与铁律 |
84
+ | v0.30.30 | 2026-09-29 | $:/language 的条件写入改走共享写策略 |
85
+ | v0.30.29 | 2026-09-29 | wechat-publish.ts 拆出「契约层」 —— 先核实再动手 |
86
+ | v0.30.28 | 2026-09-29 | tw-frame.ts 拆出「公共 API 层」 —— 先证明了无环才动手;两处 tabLabel 直接读改成 getTabLabel() |
87
+ | v0.30.27 | 2026-09-29 | seeds.ts 拆出「注册表装配层」 —— 分界线是「策略 vs 编排」;依赖方向单向;搬迁手法升级为「编译器驱动」 |
88
+ | v0.30.26 | 2026-09-29 | 补上 v0.30.22 漏掉的用户文档 |
89
+ | v0.30.25 | 2026-09-29 | admin-routes.ts 拆出最后两条业务路由 —— 依赖面这次是「按代码生成」而不是手抄;两次自我纠错值得记下来 |
90
+ | v0.30.24 | 2026-09-29 | admin-routes.ts 再拆出「状态 / 插件·主题·语言」那一组 |
91
+ | v0.30.23 | 2026-09-29 | 核实后把一条审计假阳性(两条看似冗余的 id 正则)就地钉死 |
92
+ | v0.30.22 | 2026-09-29 | 修 tiddlywiki_attach 把文本文件存成 base64 |
93
+ | v0.30.21 | 2026-09-29 | 修掉剪藏桥「同一秒内连剪两次会撞名」 |
94
+ | v0.30.20 | 2026-09-29 | admin-routes.ts 再拆出「一次性预置(seed)」那一组 —— 为什么这一组值得单独出去 |
95
+ | v0.30.19 | 2026-09-29 | admin-routes.ts(746 行)拆出「知识库列表 / 位置」那一组 —— 「什么都没丢」的证据 |
96
+ | v0.30.18 | 2026-09-29 | src/host/session-summary.ts(633 行)拆出扫描层 —— 顺手纠正速查表里的一个错;搬迁纪律;"什么都没丢"的证据 |
97
+ | v0.30.17 | 2026-09-29 | 公众号三条路由补上「已登记但没在跑 → 503 点名」+ 铁律「行尾必须 LF」终于有了执行者 —— 过程中揪出一处静默的仓库不一致 |
98
+ | v0.30.16 | 2026-09-29 | 数值配置项的「同一组数字」收成一条通用守门 |
99
+ | v0.30.15 | 2026-09-29 | 就绪窗口的上下界收成「唯一来源」+ 一条反向验证过的守门 |
100
+ | v0.30.14 | 2026-09-29 | 客户端 i18n 全量落地 + `tool-views.ts` 拆成 7 个模块 + 设置页重启后顺手重载面板 |
101
+ | v0.30.13 | 2026-09-29 | 客户端 i18n 收尾(第 3 条裁定,283 键 / 20 个文件已转,只剩 3 个) —— 顺手修掉三处语言相关的真缺陷;守门同步 |
102
+ | v0.30.12 | 2026-09-29 | 两个真缺陷 + 三条新守门 —— tiddlywiki_append 的 heading 会把 Markdown 图片当成标题;变更锁只覆盖了一半 |
103
+ | v0.30.11 | 2026-09-29 | 客户端 i18n 继续推进(283 键 / 20 个文件已转)+ 守门解析器加固 |
104
+ | v0.30.10 | 2026-09-29 | routes.ts 拆分第 3 步(笔记组)+ 修掉 /upload 的多库 URL 缺陷 —— 顺手修的真缺陷 |
105
+ | v0.30.9 | 2026-09-29 | routes.ts 拆分(第 5 条裁定)—— 两步纯搬迁/工厂抽取,行为零变化 —— 守门铺路的第三处 |
106
+ | v0.30.8 | 2026-09-29 | 给 routes.ts 的拆分铺路:两处守门改成按模块族读 |
107
+ | v0.30.7 | 2026-09-29 | `tools-notes.ts` 683 → 14 行 barrel:七个工具工厂按职责搬进 `tools-notes-{write,structure,lifecycle}.ts` |
108
+ | v0.30.6 | 2026-09-29 | 客户端 i18n 层落地 —— 唯一入口;目录按区域分片;语言来源是同一个 uiLanguage |
109
+ | v0.30.5 | 2026-09-29 | `git_sync` 改为同步「所有可同步的库」:按仓库去重、逐仓库独立成败、回执逐仓库标注 |
110
+ | v0.30.4 | 2026-09-29 | 剪藏桥「不做认证、对任意页面开放」的姿态落地(只拒 `$:/` 系统命名空间)+ 守门加固 |
111
+ | v0.30.3 | 2026-09-29 | `clip-bridge.ts` 574 → 414:payload 层独立成 `clip-bridge-payload.ts` |
112
+ | v0.30.2 | 2026-09-29 | `routes-session.ts` 632 → 274:432 行的 `createSessionRoutes()` 按族拆成两条工厂 |
113
+ | v0.30.1 | 2026-09-29 | 纯搬迁:`styles.ts` 945 → 47 行(CSS 进 `styles-css.ts`)、剪藏桥出 SSRF 层、seeds 出排干层 |
114
+ | v0.30.0 | 2026-09-29 | 第二轮全面审计修复(`get` 漏密钥命名空间守卫 / 剪藏桥可写系统命名空间 / `bridge.wiki` 运行期恒被丢掉 / `git_sync` 排干是空守护) |
115
+ | v0.29.0 | 2026-09-29 | 只读审计修复(快速笔记 native 路径不解析目标库 / 已登记未运行的库静默回落默认库 / 两处裸 `server.restart()` / 提示词 −47%) |
116
+ | v0.28.14 | 2026-09-29 | 修「点击切换知识库感觉会卡那么一下」 —— 先量再改;两段来源分开处理;侧边栏不再重复打 /status |
117
+ | v0.28.13 | 2026-09-29 | 修「左侧多 wiki 入口有时候要点两次才能切到对应的库」 —— 顺带修好一个长期红着没人看的守门 |
118
+ | v0.28.12 | 2026-09-29 | 「默认库」不再是一个名字:哪里都直接用被设为默认的那个库的真实显示名 —— 会话选择器;快速笔记「写入」目标;设置页剪藏目标 |
119
+ | v0.28.11 | 2026-09-29 | 卡片「在 TW 打开」跳错库 + 新会话出现两个选择器;README 不再放版本记录 |
120
+ | v0.28.10 | 2026-09-28 | 修「图标弹层出现在设置弹窗后面、被遮挡」 —— 根因是层级用错了数;顺带补 styles.ts 模板串守门;这条守门的第一版是错的,值得单独记 |
121
+ | v0.28.9 | 2026-09-28 | 修「点击图标没有正常弹出」 —— 守门为什么没拦住(本版最重要的部分);新守门(双向);新守门当场抓出第二处漏写 |
122
+ | v0.28.8 | 2026-09-28 | 多库第五批反馈(12 条一次做完)+ 拆大文件 —— 图标选择器接上 DSH 官方图标系统;修「选了图标立刻变回默认」;多库下设置页按作用域分页 |
123
+ | v0.28.7 | 2026-09-28 | 会话知识库选择器改成与「快速笔记」同一行 + 单库专属配置项不再在多库下出现 —— 选择器"没和对话框对齐";「侧边栏 TW 入口显示名称」在多库下不该出现 |
124
+ | v0.28.6 | 2026-09-28 | 修两个多库界面的真机报障 —— 「插件管理 / 主题管理 / 语言包 / 初始化」看起来是混在一起管理的;左侧入口点一个却三个都高亮 |
125
+ | v0.28.5 | 2026-09-28 | 修「多库模式下侧边栏多出一个没有库名的 TiddlyWiki 入口」 |
126
+ | v0.28.4 | 2026-09-28 | 每库自定义图标 + 修「右键菜单切库后选中项不更新」 —— 右侧「知识库」菜单的选中项停在旧值;每个库的入口图标可自定义 |
127
+ | v0.28.3 | 2026-09-28 | 三个真机报障 + 插件说明补「多库 / 升级」两节 |
128
+ | v0.28.2 | 2026-09-28 | 设置页去掉重复的「知识库位置」 |
129
+ | v0.28.1 | 2026-09-28 | 修「拆库 skill 在 wiki 启动慢 / 起不来时永远装不上」+ 快速笔记的目标库可切换(R7) —— 真实事故;R7 快速笔记目标 |
130
+ | v0.28.0 | 2026-09-28 | 多知识库 |
131
+ | v0.27.0 | 2026-09-28 | 重新定位:从「AI 记忆工具」改写成「你和 Agent 共用的本地文档中心」——纯元数据 + 文档发布,运行时行为零变化 |
132
+ | v0.26.7 | 2026-09-26 | 修「DSH 桌面版里 TW 面板没有『添加条目』按钮」——嵌入式 TW 被渲染在自定义 scheme 上,于是它根本没有同步器 |
133
+ | v0.26.6 | 2026-09-26 | 修 tiddlywiki_append 的 heading 在 CRLF 笔记上完全失效、且静默回退到文末 |
134
+ | v0.26.5 | 2026-09-23 | 修 P0:fields 参数被按字符拆成几十个垃圾字段 |
135
+ | v0.26.4 | 2026-09-23 | 修「发送给 Agent」因配置被浏览器自动填充而 404 + 设置页文本框防自动填充 |
136
+ | v0.26.3 | 2026-09-23 | 修正 starter-docs 教程里一处技术性错误说法 |
137
+ | v0.26.2 | 2026-09-22 | catalog 标题不再用目录名推导(`codemirror-fullscreen-editing` 的真名不同) |
138
+ | v0.26.1 | 2026-09-22 | 修 P0:设置页插件/主题/语言的勾选框全部显示为「未勾选」,且误点「应用」会把启动清单整个清空 |
139
+ | v0.26.0 | 2026-09-22 | 修「设置页插件管理与 TW 实际状态对不上」——两套真相源不再互相说谎 |
140
+ | v0.25.1 | 2026-09-21 | 修「回复流里的 wiki 链接点了打不开」 |
141
+ | v0.25.0 | 2026-09-20 | 第五轮代码审计修复(只留 `/render` title 分支 `{{…}}` 转写一条,判定单机可接受) |
142
+ | v0.24.2 | 2026-09-20 | 修「$:/language 每次 dsh web 启动都被无条件重写 → 两台机器每次 pull 都冲突」 |
143
+ | v0.24.1 | 2026-09-20 | 修「重启/停止 TW 的四个路径里有三个没排干 syncer 队列」——一类静默丢写入的缺陷 |
144
+ | v0.24.0 | 2026-09-20 | 三处「Agent 读写 wiki 的规矩」按用户要求改进 + 一个 lint 误报修复 |
145
+ | v0.23.5 | 2026-09-18 | 修 15 处审计缺陷(最重:`delete` 先删条目后读回收站索引 ⇒ 未进索引的永久孤儿) |
146
+ | v0.23.4 | 2026-09-18 | 三处硬化,全部来自 v0.23.3 上线当天暴露的真实事故 —— 提交前拦冲突(数据安全);配置解析失败拒绝写(数据丢失);adapter 版本校验 |
147
+ | v0.23.3 | 2026-09-18 | TW 笔记工具栏新增「发布到公众号」按钮 —— 标题不进 argv;任务化而非同步请求;可选功能默认不打扰 |
148
+ | v0.23.2 | 2026-09-17 | 多图发布命令 publish-note-imgs + 发布策略定为「到草稿箱为止」 |
149
+ | v0.23.1 | 2026-09-17 | 新增 wechat-setup seed——「微信公众号发布指南」也随插件分发 |
150
+ | v0.23.0 | 2026-09-17 | 新增「发布到微信公众号」+ 发布元数据 |
151
+ | v0.22.10 | 2026-09-17 | 修「写入丢失 created/modified,新笔记在按修改时间排序的页面上沉到最后一名」 |
152
+ | v0.22.9 | 2026-09-16 | 修「TiddlyWiki 编辑者署名被静默清空」 |
153
+ | v0.22.8 | 2026-09-15 | 第六轮代码审计修复 —— 数据丢失(P0);设置页超时(P0);净化器崩服务 |
154
+ | v0.22.7 | 2026-09-14 | 设置页「预览注入文本」改为按表单当前值渲染 |
155
+ | v0.22.6 | 2026-09-14 | 修「快速笔记」两处用户实测报障 —— 原生编辑弹窗里删掉笔记后,弹窗变永久白板、再点「快速笔记」也回不来;card 模式底部操作条把按钮文字压成两行 |
156
+ | v0.22.5 | 2026-09-14 | 修「大知识库冷启动被误判为启动失败」 |
157
+ | v0.22.4 | 2026-09-13 | 重构:把 TW frame 的生命周期收敛成唯一一份实现 |
158
+ | v0.22.3 | 2026-09-12 | 客户端审计修复(`iframe` 空 `src` 被当成合法地址使用 / 主题「活动」单选等) |
159
+ | v0.22.2 | 2026-09-12 | 修「主题汇总页·模板」seed 内容被渲染成几千遍重复 |
160
+ | v0.22.1 | 2026-09-11 | 修首页「快速记笔记」创建出的条目日期时间错误 |
161
+ | v0.22.0 | 2026-09-11 | 设置页运行期切换知识库位置 + seed 文档「过期」检测 + 一个隐藏很久的数据丢失竞态 |
162
+ | v0.21.0 | 2026-09-11 | 注入提示词改为「默认精简 + 可配置 + 不再过期」 —— 保存后无需重启 dsh web |
163
+ | v0.20.1 | 2026-09-11 | 修复「覆盖已有条目时内容类型被静默重置」 |
164
+ | v0.20.0 | 2026-09-11 | 第四轮代码审计修复(`/render` 未拦插件命名空间 ⇒ 可读出配置与 token 等) |
165
+ | v0.19.5 | 2026-09-11 | 第三轮代码审计修复(`attach` 不读旧条目就 PUT ⇒ 撞名静默覆盖既有笔记 等) |
166
+ | v0.19.4 | 2026-09-11 | 标签列表全面「有界」——最后一个没有上限的列表接口 |
167
+ | v0.19.3 | 2026-09-11 | 清掉审计剩余中优先级项(`/admin/state` 与 `/admin/config` 回包把 token / PAT 打码 等) |
168
+ | v0.19.2 | 2026-09-11 | v0.19.1 的内容 + 让 flush 哨兵真正生效 |
169
+ | v0.19.1 | 2026-09-11 | 第二轮代码审计的修复版 + 让 CI 重新变绿 |
170
+ | v0.19.0 | 2026-09-11 | 按审计结论全量修复 + 新能力(每个路由校验 HTTP 方法:`GET /sync` 曾能 push 等) |
171
+ | v0.18.0 | 2026-09-10 | 全量审计后 40+ 项修复(重头:seed 只在真 404 时才认为条目缺失,瞬时故障曾会覆盖笔记) |
172
+ | v0.17.0 | 2026-09-10 | 移除:DSH Better Sidebar(dsh-better-sidebar)Tab 集成 |
173
+ | v0.16.28 | 2026-09-10 | 修:拖拽版剪藏书签在知乎等真实页面报 SyntaxError: Unexpected token ';' |
174
+ | v0.16.27 | 2026-09-10 | 剪藏书签支持「拖拽安装」 |
175
+ | v0.16.26 | 2026-09-10 | 修:剪藏书签在真实页面上崩溃 |
176
+ | v0.16.25 | 2026-09-10 | 剪藏桥支持图片剪藏(浮层选图) |
177
+ | v0.16.24 | 2026-09-09 | 新:本地剪藏桥 + 书签小工具 |
178
+ | v0.16.23 | 2026-09-09 | 新:DSH Better Sidebar(dsh-better-sidebar)集成 |
179
+ | v0.16.22 | 2026-09-09 | seed 体系三层化 + 文档中心起步包 |
180
+ | v0.16.21 | 2026-09-09 | 新:DSH 右侧边栏(rightbar)集成 |
181
+ | v0.16.20 | 2026-09-09 | 修 search / recent 在大 wiki 上超时 |
182
+ | v0.16.19 | 2026-09-07 | 修:会话「知识库」Tab 把汇总当源码显示 |
183
+ | v0.16.18 | 2026-09-07 | README 全面精简重写;seed 优化——首页 seed 跟随 wiki 现状、修「所有文章」回主页死链、doc-note 文案修正 |
184
+ | v0.16.17 | 2026-09-07 | 点击快速笔记可选直达 TW 原生编辑页(ui.quickNoteMode)+ 修「在 TW 中编辑」弹窗 ✕ 关不掉 |
185
+ | v0.16.16 | 2026-09-07 | 修会话知识库 Tab 误入编辑草稿显示源码——三层防误编辑(清残留草稿/吞草稿创建/禁 ✏️) |
186
+ | v0.16.15 | 2026-09-07 | put/batch_put 未指定类型自动默认 text/markdown;警告 fields.type 是内容类型保留字段 |
187
+ | v0.16.14 | 2026-09-07 | 修知识库 Tab「佚失条目」根因——浏览器端 TW 天生排除 $:/temp,改为 iframe 就绪后注入 + 原生 hash 导航 |
188
+ | v0.16.11 | 2026-09-06 | 新增会话顶部「知识库」Tab(本会话相关 wiki 汇总,volatile $:/temp) |
189
+ | v0.16.0 | 2026-09-05 | 回复流原生 TW 工具卡片 + 可点击 wiki 链接(新增 render-route seed) |
190
+ | v0.15.0 | 2026-09-05 | 初始化不强制 + 可反初始化(seed 拆核心/可选两层) |
191
+ | v0.14.0 | 2026-09-05 | 「发送给 Agent」按钮独立图标 + 附加说明 + 权限选择 |
192
+ | v0.13.0 | 2026-09-04 | menubar 顶栏主题自适应 |
193
+ | v0.12.0 | 2026-09-04 | 新默认主页 + 「所有文章」两列分页页 |
194
+ | v0.11.0 | 2026-09-04 | 「发送给 Agent」支持选择工作模式(Agent 预设) |
195
+ | v0.10.0 | 2026-09-04 | 统一 seed 注册表 + 设置页「初始化」 |
196
+ | v0.8.0 | 2026-09-04 | 嵌入式 TW 跟随 DSH 深浅主题 |
197
+ | v0.7.0 | 2026-09-03 | TW 一键把当前笔记发送到 dsh 会话 |
198
+ | v0.6.0 | 2026-09-03 | 同源代理(远程访问核心) |
199
+ | v0.5.0 | 2026-09-03 | Agent 工具扩充 + 快速笔记草稿自动保存 + 统一知识库 FAB |