dot-skill v2:Node 单栈 + 证据脊柱 + 渲染 + 渠道与同意 + 审计(重建版) - #185
Open
titanwings wants to merge 90 commits into
Open
titanwings wants to merge 90 commits into
titanwings wants to merge 90 commits into
Conversation
Command surface, on-disk layout, receipt shape, key discipline, evidence rules (screenshots stay local), test gates and the bilingual convention. Parallel branches code against this file instead of negotiating interfaces.
A 45-cue synthetic interview (backend engineer x interviewer) covering multiple speakers, a time span, tone shifts and one deliberately deflected topic. It is original to this repository, so the fixture carries no third-party licence risk; real public-domain or CC-BY material can be added by appending to LICENSE.md.
…targets) Separates effect acceptance from the engineering gates: A/B time split, judge sees only the private-mode HTML, checker scores hit/partial/miss/undecidable against the held-out tail, and a reverse control run without the evidence layer. Mechanical assertions are listed with the exact commands that enforce them.
A hand-written profile of the synthetic interviewee with claims citing anchors by
ordinal placeholder ({{ANCHOR:n}}); the harness resolves them against the ledger,
so the fixture stays valid while the numbering is owned by the parser.
Runs harvest → retrospect → view check → view render → visual-check over a corpus and asserts receipts, idempotence, byte-determinism, anchor resolution, single file/offline output and the visual-check gate. Missing commands fail loudly with the branch that owes them instead of being skipped.
One table mapping each ds/* branch to its deliverable, dependencies and the exact command that proves it, plus the merge order for the integration branch.
Eight hosts with the directory each one actually scans (taken from
bin/distilly.mjs and INSTALL.md only — no guessed paths), the confirmed
AgentSkills CLI ids, per-host bilingual caveats, and the three command helpers.
Hosts without a confirmed CLI target refuse to emit an `--agent` command under
{ requireVerified: true }.
ds/05 owns the host documentation, the INSTALL/README sections and the anti-drift assertions, so it can run in parallel with the CLI port.
ds/07 now covers the credentialed channels, the consent gate and the optional transcribe backend (verifiable entirely with injected fetch), while schema v4, migration and the release check move to ds/08 so neither task waits on the other.
Every legacy tool mapped to the module that replaces it, with its owning branch, so 'no Python left' becomes checkable rather than asserted, and so each deletion can be tied to a parity record.
…ce stub A minimal contract-compliant implementation (kept outside the repository) scores 11/11, so a failure on a real branch points at the implementation rather than at the harness.
Checked against skills@1.5.26 instead of inferring ids from display names: grok-build -> grok, hermes -> hermes-agent, pi is a real target (so it gains a cliId and a project path), and DeepSeek Harness has no upstream target at all, so it becomes clone-only rather than advertising an `--agent` value that the CLI rejects. Still 7 CLI-capable hosts and 1 clone-only host, but the correct ones.
/tmp 被环境清空,`/tmp/dst` 里 dot-skill v2 的 165 个本地提交全部丢失。 这一批是从 73 个 DSH 会话记录里重放 write/edit 重建出来的文件内容: - 重放 377 条 write/edit 事件;132 个仓库内路径,106 个有完整 write 内容 - 每应用一条 edit 就校验一次语法,坏 edit 回滚(第一版没有校验, `bin/distilly.mjs` 被拼坏,131 个测试里 65 个连锁失败) - 80 个文件覆盖进仓库;7 个 prompts 在基线 `52d4050` 的内容上应用 edit 已知还不完整(下一步处理): - 41 个文件的 edit 被跳过、5 个有回滚 —— 它们的最终内容来自会话里的 bash heredoc,write/edit 记录覆盖不到 - `scripts/audit-objective.mjs`、`scripts/blind-test.mjs` 等完全没有 write 记录 - 当前 `node --test tests/*.test.mjs`:137 个测试 66 pass / 71 fail, 主因是 `src/skill/writer.mjs` 少了 `installGeneratedHosts` 导出(同因) 这是一次**等价重建**,不是原来的 165 个提交;提交历史不会与丢失的那串相同。
第一版只重放了 write/edit(80 个文件),拿不到「由 bash heredoc 建立、之后只用 sed/heredoc 改动」的那些文件。这一版改成**四类事件同一条时间线**: write / edit / heredoc(cat > file <<'EOF') / read(完整读取的观测值) 关键在于把 read 当作时间线上的**状态重置**:文件常被未记录成 write/edit 的 bash 改动推进过,之后的完整读取就是那一刻的真实状态,能纠正重放漂移。同时给 read 与 heredoc 也加了语法校验 —— 审计子代理读到过更早的版本(例如还是 Python 正则 `(?P<name>…)` 的那一版),不加校验会让旧内容覆盖新内容。 配套两个抽取器的修正: - heredoc 的相对路径要按**这条命令的 cwd** 解析(`cd /tmp/dst && cat > src/x.mjs`), 并按会话过滤掉同一 store 里其它项目的记录 - read 结果末尾的 `(End of file - total N lines)` 不是文件内容,要截掉 结果:distilly 文件 80 → 184;`node --test` 137 → 220 个测试(99 pass / 121 fail)。 还手工修了两处重建损伤:`archive.mjs` 块注释里的 `*/` 提前结束注释(原文是 `*\/`), `feishu.mjs` 的 Python 命名捕获组 `(?P<n>` → `(?<n>`。 仍缺(下一步): - `src/knowledge/anchors.mjs` 少了 `buildSubAnchors` 导出(17 个测试失败) - `bin/distilly.mjs` 少了 `payloadEntries`、`scripts/visual-check.mjs` 少了 `anchorProblem` - 5 个测试报 `__TEMPLATE_EN__ is not defined`:viewer 碎片需要模板生成器先跑
两处让重建质量明显变好的修正: 1. **按 worktree 根区分事件**。`ds/NN-*` 的功能分支各有独立 worktree,同一个 相对路径在不同 worktree 里是不同阶段的版本;混成一条时间线会让早期分支的 读取盖掉正式版(`bin/distilly.mjs` 一度只剩 71 行的脚手架)。现在时间线以 集成分支 `/tmp/dst` 为准,功能分支只在它没有记录时兜底。 2. **候选版本择优**。时间线依赖 write/edit 事件的完整性,而很多文件是靠未记录的 bash 改动推进的,时间线会倒退。改成收集每个路径的**全部候选**(所有 worktree), 归一化后取「语法有效且最完整」的那一个 —— 源码在开发中只会变长。 「归一化」把两类重建损伤在校验前修掉,否则最好但受了伤的版本会因语法无效落选: - 块注释续行里的 `*\/` 少了反斜杠(会提前结束注释) - Python 风格的命名捕获组 `(?P<name>…)` 人工修复脚本 `manual-repairs.mjs` 保留:`anchors.mjs` 的 `buildSubAnchors` 从会话 2026-09-12T18:07 的 write 记录里逐字取回。 现状:184 个 distilly 文件全部语法通过;`node --test` 220 个测试,105 pass / 115 fail (此前 99/121)。剩余失败集中在少数仍是早期版本的模块,需要按测试重写。
三个修正,每个都对应一类具体的重建失误: 1. **截断过滤器误判**。判断 read 结果是否被截断时,我拿**全文**匹配 `truncated`,而 `src/parse/common.mjs` 正文里本身就有这个词(讲 JSON leaf 的 `truncated: boolean`)。于是一次完整的 1092 行读取被当成截断丢掉, 导致 `buildDocument` 缺失、16 个测试失败。改成只看 `<content>` 之前的头部。 这一条把测试数从 220 拉到 298。 2. **补回被重建覆盖的导出**。`rebuild-all.mjs` 会整份覆盖文件,所以临时手工加的 导出活不过下一次重建 —— 把它们写进 `manual-repairs.mjs`: - `common.mjs` 的 `stableJson` / `truncate`(`feishu.mjs` 从它 import) - `kit.mjs` 的 `DEFAULT_MAX_PAGES` / `DEFAULT_PAGE_SIZE` / `DEFAULT_MAX_MESSAGES` 3. **记下一次失败的启发式**。试过「按被 import 的符号数给候选排序」,它在 `archive.mjs` 上用 234 行的早期版本换掉了 1228 行的完整版本(早期版本导出了 测试要的两个名字,却丢了其它模块依赖的 `extractXArchive` / `extractTakeout`), 测试数 319 → 298。已关掉并在注释里写明原因,避免下次又想到同一个主意。 现状:319 个测试,175 pass / 144 fail(上轮 220 / 105)。
… / classifyMember+parseXMember) 四个都写进 manual-repairs.mjs 而不是临时手改 —— rebuild-all 整份覆盖文件, 临时改动活不过下一次重建。 - bin/distilly.mjs:payloadEntries 存在但没导出 - scripts/visual-check.mjs:anchorProblem(纯函数,为的是测试不必开浏览器) 注意要插在 shebang **之后**,prepend 到 #! 前面会让整个文件语法错误 - src/collect/feishu-mcp.mjs:parseMcpArgs(测试逐条断言它的 error 串) - src/parse/archive.mjs:classifyMember + parseXMember —— 恢复出来的 1228 行版本是 后来的重构(memberParser + extractXArchive),测试按旧名字 import;两个函数体 从会话记录里逐字取回,作为额外导出补上,不动已有结构 328 个测试,180 pass / 148 fail。
恢复出来的 `bin/distilly.mjs` 是 ds/01 早期的「只会 install」版本:没有注册表, `--help` 只列 install,`src/commands/**` 里注册的十几个命令一个也进不来。 会话里对它的完整读取只发生在**基线**阶段(18:06,v2 还没开始),之后全是 20–45 行 的局部读取,所以按契约重写: - `bin/distilly.mjs`(228 行):`--json` 全局回执、`--check-package` 预打包门禁 (含 PR-21 的 `files` 覆盖校验)、`--version`、双语帮助、`resolveCommand` 两段式 命令名、`CliError`/`ArgError` 的失败回执、以及解析符号链接的入口守卫 - 命令模块按**副作用 import** 的顺序排在注册表之前,绕开 `commands/*.mjs → index.mjs` 的循环求值 - `PLANNED` 清空:契约 §1 的命令面在本分支已全部实现 - `src/skill/writer.mjs`:四个模板占位符(`__TEMPLATE_EN__` 等)从仍在仓库里的 `tools/skill_writer.py` 取回并内联 —— 这正是 ds/01 当年那一步;补上 `installGeneratedHosts` - 顺手补:`view.mjs` 里没用的 `bilingual` 死 import、`credentialed.mjs` 的 `PENDING_CHANNELS`(空表=没有欠账) 还新增了两个诊断工具(`RECOVERED/` 下):`rebuild-writer.mjs`、 `find-missing-exports.mjs` —— 后者一次性列出「谁 import 了不存在的导出」, 比一个个跑 CLI 撞错误快得多(本次只剩 5 处)。 364 个测试,205 pass / 159 fail(上轮 328 / 180)。
`view.mjs` 按新接口 import `normalizeView`,而恢复出来的 `schema.mjs` 还没有它 ——
CLI 一加载就报错,整个命令面进不来。按契约实现:
normalizeView(raw) -> { view, notes[] }
「宽容归一化」:手写的 view.json 形状会漂(`items` 写成 `claims`、缺 confidence、
缺 title),`checkView` 把这些报成问题是对的,但调用方仍需要一个**能用**的对象
(回执里的 slug 就取自它)。每一步只补空缺或改错类型,所以**幂等**:归一化过的
再归一化,view 不变、notes 为空。
结果:`--help` 列出 16 个命令(50 行),`view check --help`、`doctor` 都能跑。
373 个测试,245 pass / 128 fail(上轮 364 / 205)。
`src/commands/index.mjs` 只有在 `bin/distilly.mjs` 按副作用 import 之后才有内容,
于是直接 `import { listCommands } from "../src/commands/index.mjs"` 拿到的是**空表**。
「注册表是唯一注册点」这条契约因此在任何非 bin 消费者眼里都是断的 —— 测试就是这么
用它的,所以直接暴露了。
把命令模块的 import 移进 `index.mjs` 末尾后是循环求值:命令模块 import `./index.mjs`
的 `register`,而它们会在 index.mjs 的函数体还没跑完时被求值。`const REGISTRY`(含
`const registry = () => …`)在这时处于 TDZ,`register()` 一调用就
"Cannot access 'registry' before initialization"。改用 `var` + 函数声明(都被提升),
`register()` 里再惰性建表,循环就安全了。
顺带把 `tests/dispatcher.test.mjs` 里三条**ds/01 阶段**的断言更新到最终态(harvest
已实现、`skill list --json` 成功、PLANNED 为空),意图不变:仍然断言「缺命令要给出
可执行补救」、「stdout 只有一个 JSON 对象」、「两段式命令名优先」。这不是为了让测试
变绿而放宽,而是原断言写的是那个阶段的事实,终点已经不同。
373 个测试,249 pass / 124 fail。
**污染**:`/tmp/distilly-src` 是同一个仓库的 **`distilly-plugin` 分支**(TypeScript 插件工程),不是 dot-skill v2 分支。我把它当成恢复源之一,于是 `scripts/check_release.mjs`、 `tests/release_manifest.test.mjs`、`docs/architecture.md`、`README.md` 等**通用路径**被插件 分支的版本覆盖 —— `check_release.mjs` 去读 `plugins/release-manifest.json`,一个本仓库 根本不存在的文件。六个抽取脚本的根清单里已去掉 `distilly-src`,重建后删掉 3 个残留文件。 判断依据很直接:这些文件里出现 `packages/cli`、`plugins/release-manifest`、`@dsh/` 这类标记,而 distilly 是零运行时依赖的单包仓库。 **双语 prompt 层**:15 个 family prompt(`prompts/celebrity/**` 11 + `prompts/relationship/**` 4) 还停在 ds/04 之前,prompt-lint 报 112 条。ds/04 是用 `cat > /tmp/gen/blocks.mjs <<'GENEOF'` 生成后一次性重写的 —— 既不在 write/edit 记录里, 也不在 read 结果里,只能从 bash 命令里救。脚本逐字取回,存为 `RECOVERED/rebuild-prompts.mjs`, 跑出 15 个文件的双语三段(中文 必须/禁止/回执 + 英文 MUST/MUST NOT/RECEIPT)。 剩下 7 条是 3 个文件里残留的 `tools/research/*.py` 引用,lint 要求同段标明 deprecated; 按 SKILL.md 迁移表的权威映射改写成 `distilly transcribe` / `distilly collect x` / `distilly doctor` / `distilly retrospect`。 结果:**`prompt-lint` 0 findings**(26 个文件全过),这是目标里「双语 prompt 五步改造与 prompt lint」一项的验收条件。367 个测试 245 pass / 122 fail —— 测试数从 373 降到 367 是 因为删掉了插件分支带进来的测试文件。
macOS 上 `/tmp` 是 `/private/tmp` 的符号链接。抽取脚本只匹配 `/tmp/<root>/…`, 于是**用 realpath 写文件的那批会话整个看不见** —— ds/05 的 `tests/agents.test.mjs`、`docs/v2/HOSTS.md`、`scripts/check-agent-ids.mjs` 就是这么漏掉的, 而不是「会话里没有记录」。六个抽取脚本的根正则加上 `(?:private/)?` 之后: - `/private/tmp/...` 下独有的 91 个路径进入候选 - `tests/agents.test.mjs`(10552 字节)、`docs/v2/HOSTS.md`(23562 字节)恢复 - 恢复路径 179 → 185,语法全通过;372 个测试 247 pass / 125 fail 这一条比它看起来重要:`/private/tmp` 与 `/tmp` 是同一个目录的两种写法,任何只认其中 一种的匹配都会**静默丢掉一半证据**,而且丢的都是「用绝对真实路径写文件」的那部分 —— 正好是更谨慎的那些会话。
恢复出来的 `anchors.mjs` 是 PR-09 之前的版本,段落渲染成 `k0004 text`:**没有方括号、
也没有归因前缀**。这两样都是脊柱的承重件 —— 派生层按 `[kNNNN]` 找锚点(没有方括号,
`retrospect` 恒为空),而句长/称谓/时间线全靠正文里的 `<时间> <说话人>:`。
## 渲染契约(patches/anchors-attribution.mjs)
- 渲染改成 `[k0012] <at> <speaker>:text`;`unit.text` 仍是**原文片段本身**,
前缀只是渲染期标记,字节区间回指不变(测试断言 `unit.text.startsWith("Lin")`)
- `buildSegmentLookup` 的 span 带上 `speaker` / `at`,并暴露 `segmentAt`;
新增 `attributionFor(unit, lookup)`:两段各自可缺(字幕有时间无说话人,笔记两样都没有),
但**绝不编造** —— 说话人未知就是留空
## 归因链路(patches/attribution-chain.mjs)
四处断点,每处都只是「没有转发」:
| 位置 | 断在哪 |
| --- | --- |
| `parse/chat.mjs` | slice 上只有拼好的 `label`,没有 `speaker` / `at` |
| `parse/chat.mjs` | `turnsToRecords` **手工**拼 record,不走 `recordsFromCharSpans`,又漏一次 |
| `parse/subtitle.mjs` | 同上 |
| `parse/common.mjs` | `recordsFromCharSpans` 丢字段;`assembleContent` 造 segment 时不带 |
同一份信息有两条路(`recordsFromCharSpans` 与 `turnsToRecords`),漏掉哪条都断 —— 这正是
第一遍只补了前者、正文里依旧没有前缀的原因。
## 身份映射与飞书路由
- `buildDocument` 在 **assembleContent 之前**改写 speaker(`recordDocument` 已经太晚,
那时 `ou_lin:` 早写进锚点那一行了);各解析器透传 `options.identity`
- `parse/feishu.mjs` 的 `unwrap` 只找一层数组,而开放平台分页响应是
`{ code, data: { items: [...] } }` —— 改成有界递归,`feishu-page.json` 才进得来
- `harvest` 的 `--identity`、兄弟 `users.json`(`readSlackUsers` 要的是**文本**,传对象会
变成 `[object Object]`)
372 个测试 250 pass / 122 fail;`prompt-lint` 0 findings;全树语法通过。
下一步:Slack `ts` → ISO 归一化、users.json 解析(正文现在还是 `1700000000.000100 U01:`)。
- `normaliseTimestamp` 只认纯整数串,而 Slack 的 `ts` 是 `"1700000000.000100"`
(带小数点的秒),于是正文里一直显示原始 `ts`。补上该形态,小数部分按 Slack 的
定义是微秒,只取整数秒 —— 下游要的是稳定 ISO 瞬间。
现在渲染成 `[k0001] 2023-11-14T22:13:20.000Z U01:…`
- `tests/text-attribution.test.mjs` 自己生成 `U01`/`U02` 的消息,却复制共享夹具
`slack-users.json`(那里是 `U01SYNTH`/`Alice Example`),于是名字永远解不出来。
改成这个合成导出**自带 users.json**,断言原样不动。
- 补丁改用 `patches/attribution-chain.mjs`:这段代码含反引号与正则转义,嵌进模板
字符串已经弄坏过三次(`\d` 被吃掉、`*/` 提前结束注释、`${indent}` 不再插值)。
`manual-repairs.mjs` 现在会先对所有 patch 文件跑 `node --check` 再动手改仓库。
372 个测试 251 pass / 121 fail;`prompt-lint` 0 findings;全树语法通过。
下一个缺口:字幕正文里 `Lin: ` 与归因前缀重复(测试要求「不重复加前缀」)。
三处都是「脊柱的不变量被破坏」,各自堵住一条:
1. **字幕 cue 的字节区间带了标识行**。原来从 SubRip 序号/WebVTT cue name 算起,
于是 `raw.subarray(byteStart, byteEnd)` 比 `unit.text` 多出序号与时间码两行 ——
「锚点文本 == 原始字节切片」当场不成立。改成从**第一行正文**到**最后一行正文的
内容末**(`line.end` 而不是 `terminatorEnd`,后者含换行)。序号与时间码仍进 `label`。
2. **溯源自证不成立**。派生层读 `knowledge/text/*.md` 后按 `entry.sha256 === file.sha256`
认亲,而这两个是**不同文件的摘要**:账本记的是原始导出(`raw/...`)的,读到的是
归一化正文(`text/...`)的 —— 永远不相等。于是每份正文都被判来路不明,正文照读、
锚点一条不引用:派生结果「有内容」但挂不上任何证据。改成用账本自己记的位置
`entry.locations.text`(形如 `text/chat.md`)认亲,摘要相等仍作兜底。
3. **`loadLedger` 只认 KnowledgeStore**(读 `store.ledgerPath`),而调用方常用
`loadLedger({ root })`。两种写法都收。
顺带:字幕正文里已有 `Lin: ` 时不再重复加说话人前缀(`Lin:Lin:` → `Lin:`),
比较时 `:` 与 `:` 都认。
端到端已通:`[k0001] 2023-11-14T22:13:20.000Z Alice:…`,`retrospect` 无警告产出
七份派生文件。372 个测试 251 pass / 121 fail;`prompt-lint` 0 findings;语法全通过。
下一个明显缺口:`retrospect.mjs` 只有 934 行(最终版 2400 行量级),voice / relations /
timeline / boundaries 四个维度还没产出 claims,`stats` 已有 6 条。
恢复出来的 `retrospect.mjs` 只有 `stats`:`DERIVED_KINDS` 里另外六个名字都在、 `MIN_UNITS` 里六个阈值都在,唯独没有对应的函数 —— 七个产物文件里六个恒为空, 验收页面只能填出七段中的两段,任何引用都到不了页面。**这是派生层长期为空的第三个 根因**(前两个是方括号锚点、溯源认亲)。 六个维度写在 `src/derive/dimensions.mjs`(自带小工具,只依赖传入的 corpus): | 维度 | 产出 | | --- | --- | | `voice` | 句长(含 `by_speaker`、`pooled`)、标点密度、表情、口头禅(重复三字组)、称呼用法、提问比例 | | `relations` | 回应频次与**不对称**(谁更常回应谁)、同一段对话内的称呼变化 | | `timeline` | 按时间戳切分的阶段(`basis: "time"`,带 `range_anchors`)+ 总跨度 | | `boundaries` | R1 话题回避 / R3 拒绝细说,`probe` 只在**同一次会话**(6 小时内)内认定 | | `shifts` | 滑窗长度突变,双阈值(相对 45% + 绝对 6 字符),标注 `pooled` / `mixes_speakers` | | `conflicts` | 同一话题上的立场对立,两个维度(`sentiment` / `certainty`),区分同人与跨人 | 守住 `stats` 定下的三条规矩:没有可引用锚点就不产出结论(`makeClaim` 返回 null)、 测不出来就在 notes 里说明、一切列表先排序(两次运行逐字节相同)。 三处实现上的判断,都是踩过才明白的: 1. **规范名也要算作称呼词**。第一版把 `林工` 本身排除在称呼词之外,于是 `林工 → 林哥` 的变化**没有起点**,永远发现不了 —— 变体表必须包含规范名。 2. **探询不一定要有问号**。夹具第二处回避的前一句是 `那offer的事...`,靠 `?` 判定 会漏掉它;补上疑问词与省略号结尾。 3. **低于全局下限时统一短路**。语料太薄时**每个**维度都无话可说,此时报各自的分维度 阈值("少于 10 句")不假却误导,读者要的是解释"为什么一条都没有"的那个数字。 顺带两处: - `scripts/blind-test.mjs` 装回入口守卫(被 import 时不再 `exit(2)`;不再被 `node --test` 当成失败测试) - `renderCommandHelp` 认三种 help 形状 —— 注册处写的是 `...help`,展开后 `command.help` 其实**不存在**,于是每个命令的 `--help` 都只打印一行 usage 回归:`tests/retrospect.test.mjs` **7/7**(夹具设计的每个特征都被找到); 372 个测试 261 pass;`prompt-lint` 0 findings;验收页面由 2 段 → **5 段 / 24 个锚点**。
一条链上四个独立缺陷,每个都让前一个的修复看不出效果: 1. **字幕时间码没被消费**(`retrospect.mjs`)。渲染出的 `<at>` 有两种:聊天导出的绝对 时间、字幕的相对时间码。`LEADING_TIMESTAMP` 只认前者,于是 `00:00:01.000 面试官:…` 里 `LEADING_SPEAKER` 一路吃到第一个冒号,**说话人被解析成 `00`**。后果是每个单元都 叫同一个名字:`relations` 认为「同一个人」,回应数恒为 0;`voice.by_speaker` 只有一个 假人。修法:先消费时间码(不是绝对时刻,`at` 仍为 null),并要求说话人首字符非数字。 2. **缺口段被丢掉而不是渲染成缺口**(`blind-test.mjs`)。页面顺序是契约的一部分, 派生不出来的段必须**在场并说明自己为什么空**;丢掉会让后面每段错位,读者也永远 不知道这里稀薄。新增 `gapSection()`:一段「本节证据不足:<原因>」,不编造锚点。 3. **`--allow-missing` 根本不存在**。缺口段没有锚点可引,时间线缺口也没有 `at`, 于是 `view check` 报两条错。这两条规则本身是对的(结论必须有据),所以不能删 —— 加显式开关:只有调用方声明「我知道这里有缺口」时才降级为警告,并把缺口数量 报进 summary,免得开关变消音器。`renderView` 会重跑同一个 check,开关也要跟着走。 4. **`reporter.error` 不存在**(`receipt.mjs`)。render 的失败路径用它打印诊断,于是 **渲染一失败就抛 `reporter.error is not a function`** —— 用户看到的是工具自己崩了, 不是渲染的真实原因。补上 `error()`(与 `warn` 一样固定走 stderr)。 另外:验收的锚点索引取 `entry.source`,而账本没有这个字段(渠道在 `kind` 里,见 `blind-test.mjs` 的同名索引),27 条 evidence 一起报 "source is required"。 验收:**11/12**(此前 8/9);渲染 52921 字节、两次字节相同、单文件无外链、含 CSP。 372 个测试 262 pass;`prompt-lint` 0 findings;全树语法通过。唯一剩下的是 visual-check 八项 —— 它需要 Playwright,本机没装(CI 里会装),脚本按设计**响亮失败** 而不是静默跳过。
`check_release.mjs` 报出三项「迁移只做了一半」,逐条补齐: 1. **`SCHEMA_VERSION` 还是 3**,而 `src/skill/migrate.mjs` 已经在写 v4 目录布局。 版本号不跟上,迁移脚本自己的幂等判断与 `min_schema_version` 都会说谎。 2. **`hosts.mjs` 里没有 `CARRIED_DIRECTORIES`**:生成的 Skill 装到宿主时只复制一个 `SKILL.md`,`knowledge/text`、`evidence/derived`、`views/*.html` 全留在源目录。 宿主因此离线读不到证据,页面能打开但每个锚点都指向空。现在按 `knowledge/raw|text`、`evidence`、`views`、`assets` 一起携带(只复制存在的目录), 并把实际带走的清单写进安装记录 —— 「宿主有没有证据」因此可核对,而不是靠假设。 3. **Python 残留 36 个**(`tools/**` 25 个、`tests/test_*.py` 10 个、`requirements.txt`)。 目标是 Node 单栈,仓库里不该再有 `.py`。删除前确认过依赖:`scripts/parity.mjs` 是从**指定的 git rev** 里 `git archive tools` 导出 Python 原件(pre-port 树), 不读工作树;`src/**` 里的 `tools/...` 全是注释与 `presets.mjs` 的历史元数据。 `assets/pinyin.json` 也重新生成了(`generate-pinyin.mjs`,Unihan 2404 字)。 发布检查 **7/7**(此前 3/7);372 个测试 265 pass;`prompt-lint` 0 findings; 验收 11/12(唯一剩下的 visual-check 需要 Playwright,正在装)。 注意:`RECOVERED/rebuild-writer.mjs` 依赖 `tools/skill_writer.py` 取 SKILL 模板, 删掉之后它无法再跑 —— 模板已经内联进 `writer.mjs` 并入库,该脚本就此成为一次性 迁移工具,不再参与重建流程。
两条都被「重建覆盖手工改动」这条规律咬到,这次写进补丁: 1. **`PLANNED` 又被填回来了**。契约 §1 的命令面在本分支已全部实现,`PLANNED` 应当是空表;上一次是手工清空的,下一次 `rebuild-all` 整份覆盖 `index.mjs` 时 被冲掉,审计于是报 `PLANNED: harvest, parse-chat, parse-email, …`(12 条)。 现在这一步在 `manual-repairs.mjs` 里,与其余 30 多条修复一起重放。 2. **推送状态审计写死了 `origin`**,而且**无条件记 `true`** —— 那是用户「什么都不要推」 期间写的。现在:远端名要实际解析(这个克隆里是 `upstream`,写死会让整条审计直接 崩掉而不是给出结论);差距为 0 才算通过;措辞改成当前事实「集成分支已推送作异地 备份,PR 尚未创建」。 372 个测试 268 pass(↑3);`prompt-lint` 0 findings;发布检查 **7/7**; 目标审计 12/15(↑1)。剩下三条审计项待查:retrospect 那条是脚本在 grep 测试源码找 字面短语、适配矩阵那条读到的宿主名是空的、截图那条两项检查都 false(而 `.gitignore` 里 `dst-evidence/` 是在的)—— 下一轮逐条分辨是脚本过时还是真缺口。
四条缺陷,全部让「效果层盲测」这条线落空:
1. **`visual-check.mjs` 没有入口守卫**(scripts):`main()` 在 import 时就执行,
而 `tests/visual-check-rule.test.mjs` 正是 import 它来用它的检查函数 ——
一次 import 就启动浏览器并打印用法,于是整个**文件**作为一条测试失败,
而不是某个断言失败。现在与 `bin/distilly.mjs`、`blind-test.mjs` 一样用
`isEntryPoint` 守卫。
2. **`split_by` 的词汇与全系统不一致**:回执写 `"time"`,而同一份回执里的
`cut.timecode`、字幕解析器的 `meta.timecodes` 都用 timecode。
3. **`prepare` 默认不产出页面**:只在 `--baseline` 时才渲染 `profile.html`,
于是拿着一张 `judge-prompt.md` 的评委**没有任何东西可读** —— 这一臂可以在
「什么都没产出」的情况下"通过"。现在默认渲染(`--skeleton-only` 保留旧的
「只写骨架、由模型自己写视图」行为)。
4. **声明为缺口的段落渲染不出来**(views/schema):机械基线对没有证据的段落写
`{gap: true, items:[{anchors: []}]}`,而 `view check` 要求每个条目至少一个
锚点、时间线条目必须有 `at` —— 于是「诚实地标注缺证据」反而让页面校验失败。
`allowMissing` 正是为这种情形存在的,现在对 `gap: true` 的段落逐段生效
(引用**不存在**的锚点仍然是错误)。
另外 `finalizeView` 现在回读渲染结果并报告 `anchors` 数:页面把视图数据以 JSON
内嵌、由 viewer 在运行时生成 `data-anchor-ref` 标记,所以文件里是
`"anchor": "k00NN"` 而不是 `[k00NN]` —— 先前的方括号统计对一页满是引用的页面
报 0。
验证:
- `node --test tests/blind-test.test.mjs` → 8/8(改动前 6/8)
- `node --test tests/visual-check-rule.test.mjs` → 3/3(改动前整文件失败)
- 手工核对:`prepare` 产出 `profile.html`(53011 B,`external_links: none`,
26 个不同锚点),缺口段落渲染为「本节证据不足:…」
- 全量 → 365 通过 / 10 失败(改动前 363 / 13);acceptance 12/12 未回退
`safeReaddir` 在 ESM 模块里调用 `require("node:fs")` —— `require` 不存在,
ReferenceError 被同一个 `try` 的 `catch` 吞掉并返回 `[]`。后果是
`findSkillDirs()` **永远返回空数组**:`distilly skill migrate` 一个技能也找不到,
却报告成功(0 个目录需要迁移),v3 → v4 的迁移整条命令实际上没工作。
同类的 `require` 残留此前已在两个测试文件里修过(`gitignore`、`template`);
这是第一处在**生产代码**里。
验证:
- `node --test tests/schema-migration.test.mjs` → 6/6(改动前 4/6)
- `node bin/distilly.mjs skill migrate --help` 人工核对
- 全量 → 367 通过 / 8 失败(改动前 365 / 10)
两条口径冲突,按转录里分支自己的结论定案: 1. **区间改为整块**(parse-subtitle 期望「区间包含时间码行、且以序号行开头」): 锚点覆盖 `序号 + 时间码 + 正文`,到正文最后一行为止 —— 所以第 N 条永远不会 吞掉第 N+1 条的序号。脊柱的不变式是「锚点文字是载荷的逐字节切片」,超集 仍然成立;而顺着引用去看 `test.srt` 的人,想看到的正是那条带时间码的字幕。 2. **时间码不进正文**:字幕的时间码是**块的框架**,不是归因。它已经在 `label` 和 `meta.timecodes` 里,正文再写一遍等于在读者已经知道时间的转写里逐行重复。 对白回合(有说话人 + 时刻)才是归因,仍然渲染成 `[k0001] <时刻> <说话人>:<正文>`。 同时按分支最终版修正 `text-attribution` 的「逐字节相等」断言:那条不变式对 **聊天回合**成立(区间就是文字本身),对**字幕**不成立(区间是包络)。 分支当时的原话是「my "verbatim invariant" test is wrong for subtitles」—— 现在断言「单元文字逐字落在区间内」,并补一条「时间码不得出现在引用文字里」。 这也解决了我前两轮报告里挂着的那个分歧。 验证: - `node --test tests/parse-subtitle.test.mjs` → 17/17(改动前 14/17) - `node --test tests/text-attribution.test.mjs` → 4/4(未回退) - 全量 → 370 通过 / 5 失败(改动前 367 / 8);acceptance 12/12 未回退
`src/collect/feishu-mcp.mjs` 只导出了部件,没有 `runCollectCli` —— 整份 `tests/feishu-mcp.test.mjs` 在 import 阶段就失败(SyntaxError),这个文件里 8 条测试一条都没跑过。补回的入口与其它渠道同一个形状:解析 → 读凭据(只记 配置文件名,永不记内容)→ 走一次白名单工具调用 → 输出回执或人读行;`io.transport` 让测试不必真的 `npx` 起进程。 顺带修掉三处被它掩盖的缺陷: 1. **聊天 id 被当成文档 URL**:入口把 `--url` 与 `--chat-id` 混成一个值交给 `toolForUrl`(那是解析文档 URL 的),于是 `--chat-id oc_demo` 直接抛 「cannot read a document token out of the URL」。现在按参数选择工具与实参。 2. **`loadCredential` 抛异常没人接**:没有凭据时它抛 `CollectFailure`,入口 直接调用会让异常穿出去;契约要求的是 `ok:false` + `exitCode:1` + 回执里 指名配置文件。现在捕获并构造回执。 3. **`parseFeishu` 丢掉凭据来源**:与 `parseChat`/`parseEmail` 同一类缺陷 —— MCP 客户端传的 `credentialed: true` 与 `credential_file` 被丢弃,一次带鉴权的 取数在账本里和本地读取无法区分。 4. **MCP 聊天的时间戳是裸毫秒**:`create_time`(1700000000000)被原样写进正文, 而正文是派生层唯一读的东西。现在用与其它解析器相同的 `normaliseTimestamp` 归一化为 ISO,解析不了才保持原样。 测试侧:`cli: a missing credential` 只隔离了 `DISTILLY_HOME`,而 `loadCredential` 还会读改名前的 `~/.colleague-skill/` —— 本机恰好有那个文件,于是「缺凭据」这条 用例反而成功了。现在 `HOME` 一并隔离。 验证: - `node --test tests/feishu-mcp.test.mjs` → 8/8(改动前:整文件 import 失败) - 全量 → 378 通过 / 4 失败(改动前 370 / 5,测试总数 382 因为该文件终于跑起来了) - acceptance 12/12 未回退
四个文件、四种「测试坏了但不是功能坏了」: 1. **`command-parse` 的兄弟 users.json**:断言说 fixture 目录里 `slack-users.json` 与 `users.json` 是同一个文件 —— 而真实 Slack 导出带的是 `users.json`,`parse-chat` 的兄弟规则也只认这个名字。补上那个文件(保留 `slack-users.json` 供显式 `--users` 的用例),常量指向它。 转录显示这条在原分支最后一刻也是红的。 2. **`identity` 的重复计数**:一个回合同时出现在 `segments` 和 `entries` 里, `applyIdentity` 就把它数了两次;`changed` 被读作「这次映射改写了多少个回合」 (原话),所以改为按**不同 handle**去重计数。 3. **`skill-writer` 的正则永远匹配不上**:`new RegExp(enHandoff)` 里的 `(see the Persona section)` 被当成捕获组,于是正则要求「…style see the Persona section…」,而文本里是有括号的 —— 任何实现都不可能通过。改为字面 `includes`。 4. **`cli-lifecycle` 读了一个目录**:路径多了一层 `skills/` (`…/skills/skills/victim/versions`)而且对目录调用 `readFileSync`。改为 `readdirSync(victimVersions).length === 11`,即它真正想断言的 「被拒绝的越权遍历没有动到另一个技能」。 验证: - `node --test tests/command-parse.test.mjs tests/identity.test.mjs tests/skill-writer.test.mjs tests/cli-lifecycle.test.mjs` → 59/59 - **全量 → 382 通过 / 0 失败**(改动前 378 / 4) - 全部门禁:acceptance 12/12、audit-objective 15/15(0 缺口)、check_release 7/7、 prompt-lint 0 findings、模板无漂移
上一个 hosts 提交(6dc053d)只 add 了 src/ 下的三个文件,`tests/agents.test.mjs`
的改动留在工作区没提交 —— 于是那次提交之后的**仓库状态**里,`agents.test.mjs`
仍然是旧的 `parseBinHosts` 版本(读 bin 里的 `const hosts = {…}` 表),而那张表
已经从 bin 移走了:任何人 clone 到这个提交跑 `npm test` 都会红,只有我这台
带着未提交改动的机器是绿的。
教训:本轮之后我用的是「工作区跑测试」,而不是「提交后的状态跑测试」。
验证:
- `git show HEAD:tests/agents.test.mjs | grep -c parseBinHosts`(改动前 1)
- 全量 382 通过 / 0 失败
…ource) 那条断言已经改走安装器 API,这三个辅助函数与它们的 import 都不再被引用。 验证:node --test tests/agents.test.mjs → 5/5。
按你的要求去掉 `view-mobile-375.png`:验收现在写 print / light / dark 三张。 **检查没去掉**:第 3 项仍然是「1280 / 768 / 375 三档无横向溢出」, 手机上会破版的布局照样红 —— 只是不再留那张图。 验证:acceptance 12/12;全量 382 通过 / 0 失败。
你问「图是合成语料渲染的,要真数据」——按仓库自己的规矩
(`public-corpus/README.md`:许可清晰、不是真人私聊、小、多说话人、有时间跨度),
收了一份真实材料:
tests/fixtures/public-corpus/us-house-floor-2009-07-29/
transcript.srt 2009-07-29 美国众议院院会实录,C-SPAN 字幕,公共领域(17 USC §105)
1346 条 cue / 47 分钟 / 真实具名议员
LICENSE.md 来源 URL、发布者、许可、获取日期、SHA-256、为什么用它
**真数据不是换个夹具那么简单——它一次性顶出四个缺陷**,都是合成语料碰不到的:
1. **说话人只在 cue 起始处才被识别**(subtitle):真实字幕是流式的,说话人换在
句子中间(`… MINUTES. MR. MICA: …`),而且只有约 5% 的 cue 以名字开头。
原先一条 cue 只读一个说话人,于是几乎什么都归不到人头上,
`relations`/`boundaries`/`shifts` 全空。现在按说话人切换把 cue 切成多段
(每段文字精确、共用该 cue 的块区间 —— 切点没有独立时间码,不编造)。
顺带修掉 `- MR. LEWIS`(对话破折号)和 `MINUTES. MR. MICA`(吞掉上句)两种脏名字。
2. **`at` 被当成数字用,实际是字符串**(derive):`ats.sort((a,b) => a-b)` 与
`unit.at - first` 对 ISO 串和 `HH:MM:SS,mmm` 都是 `NaN` —— **timeline 在任何
语料上都没工作过**,而验收把它当一个 gap 容忍了。新增 `toEpochMillis()` 归一化。
在聊天语料上 timeline 从 0 条变成 3 条。
3. **声明为缺口的段落里只有时间线带 `gap: true`**(blind-test):其它缺口段落
没有标记,而 `view check` 正是靠这个标记把「这一段没证据」降级为警告 ——
于是「如实报告自己缺什么」的页面根本渲染不出来。真语料有 3 处缺口,立刻暴露。
4. **正文被空白折叠过**(subtitle):`\s+ → " "` 让锚点文字不再是载荷的逐字节
切片(`THROUGH 2009.` 对不上原文件里的双空格)。合成夹具恰好没有双空格。
另外:`attributionFor` 改为「没有说话人就不渲染前缀」——字幕 cue 的时间码是
**块的框架**不是归因(不进正文),但它仍然留在记录上供派生读取。
验证(全部实测):
- `node --test` → **383 通过 / 0 失败**(新增一条真语料回归测试)
- 验收 **两份语料都 12/12**:合成 `synthetic-interview`、真实
`us-house-floor-2009-07-29`(2742 锚点 / 7 段 / 67 个引用 / 2 段缺口 / 渲染 64296 B)
- `visual-check` 在真语料页面上 8/8(print + light + dark 三张 PNG)
手动全流程走查真语料时看出来的两处显示缺陷: 1. **纯时间码被套上日期**:字幕 cue 的 `00:03:51,001` 只有时刻没有日期,但 `new Date(ms).toISOString()` 把它渲染成 `1970-01-01T00:03:51.001Z` —— 页面 凭空多出一个日期。现在解析层把原始时间码一并带上(`atLabel`), 只有**真的带日期**的语料才显示时刻;派生仍用毫秒做分桶与比较。 2. **`Z阶段 1` 粘在一起**:`.timeline__at` 的 `min-width: 5.5rem` 挡不住 24 字符的 ISO 串,溢出的部分直接贴上正文。补 `margin-right`。 验证: - 走查重跑:时间线渲染为 `00:03:51.001 阶段(按时间切分):15 条发言,平均 8.3333 字` - `node --test` → 383/383;模板 `--check` 无漂移(随 CSS 重新生成) - 验收 12/12 ×2 份语料(合成 + 真实)
字幕流的说话人换人常常发生在句子中间(`… IN ORDER. MR. LEWIS: I thank the gentleman.`)。名字的正则字符类是贪婪的:当名字前面超过约 24 个字符时, 匹配会从**句中、甚至词中**开始(`L BE IN ORDER. MR. LEWIS: `),而句界 判定看的是匹配起点前两个字符 —— 那里是 `IL` 而不是 `. `,于是这次换人被 直接丢弃。 短前缀(`MINUTES. MR. MICA:`)恰好让匹配从名字本身开始,所以通过;这正是 这个缺陷在第一轮真实字幕上没被发现的原因:只有长前缀这一种形状是坏的。 改法:先把名字归约到最后一句片段,再在**名字真正开始的位置**判句界, 切点也跟着挪到名字处 —— 上一句因此完整留在前一个 run 里,不再被吞掉。 效果(公开语料 us-house-floor-2009-07-29,C-SPAN 真实字幕 1350 条 cue): 修正前 78 条挂上说话人(5.8%) 13 位 修正后 82 条挂上说话人(6.1%) 13 位 天花板不在解析器:整份语料里 `名字: ` 形状总共只出现 84 次,现在取到 82 次。 测试:tests/parse-subtitle.test.mjs 新增长前缀句中换人回归(断言 2 条记录、 说话人顺序、head 保住上一句、两条文本都在原文里)。 全套 383 项通过。
`Name: text`(半角冒号 + 空格)是英文字幕写说话人的方式,中文会议转写写的是 `说话人 1:大家好` —— 全角冒号、冒号后常常没有空格。两种写法都匹配不上原来的 模式,于是一份**每一行都标了说话人**的中文语料解析出来是 0 位说话人:画像、 句长/标点/口头禅的按人分组、回应关系全部空转,看起来像"语料没标",其实是 "解析器不认"。 改法分两层: 1. 标签模式接受三种分隔符(半角冒号+空格 / 全角冒号 / 冒号后无空格)。 2. 后两种是**有歧义**的:全角冒号同样用来结束一个普通句子(`注意:…`)。 所以它们只在两种情况下被认领 —— 名字是转写角色(`说话人 1`、`SPEAKER_00` 这类,不会出现在正文里),或者该标签在文档里以行首形式**至少出现两次** (真人会不止说一次,`注意:` 不会)。名册在 splitCues 里按整篇文档建一次, cue 级判定与句中切分共用同一份,两处口径不会漂。 单次出现的真名因此不被认领 —— 这是刻意付出的代价:把锚点挂到错的人身上, 比挂到没有人身上更糟。 效果(26 条中文会议字幕,三位说话人): voice.sentence_length 按人分组:主持人 / 张三 / 李四 各带自己的锚点 relations.reply_counts 认出 主持人↔张三 的回应不对称度 conflicts / timeline 正常产出(修正前说话人维度全空) 测试:tests/parse-subtitle.test.mjs 新增两条 —— 全角冒号被读成说话人且标签留在 正文里;歧义标签的门槛(一次 `注意:` 不是说话人、一次 `SPEAKER_00` 是、 出现两次的真名是、只出现一次的暂不认领)。Node 20 与 22 上各 21/21 通过。
CI 从建立起**没有一次是绿的**(30 次运行全部 failure),原因有两个,都不是产品 代码的问题,而是门禁自己坏了: 一、`npm test` 在 Node 20 上根本跑不起来 "test": "node --test \"tests/*.test.mjs\"" 引号挡住了 shell 的 glob 展开,而 Node 20 的 `--test` 只接受字面路径(不支持 glob),于是 Node 20 那条腿报 `Could not find '…/tests/*.test.mjs'` 直接退出; Node 22 支持 glob,所以本机永远是绿的。矩阵里同时有 20 和 22,CI 必红。 更要命的是有一条测试**把这个坏字符串钉死了**: `assert.equal(manifest.scripts.test, 'node --test "tests/*.test.mjs"')`。 断言一条命令的**文本**,不等于断言它能跑 —— 这正是它没拦住的原因。 改成 `node --test tests/*.test.mjs`(不加引号):shell 先展开,Node 20 拿到真实 路径;Node 22 拿到同样的路径也认。(试过 `node --test tests/`:Node 20 认目录, Node 22 把位置参数当 glob、拿目录当模块,报 `Cannot find module '…/tests'`。) 新测试改为断言:命令不是裸 `node --test`、参数里没有引号、**每个参数都能对上 真实存在的文件**。 二、目标审计在 CI 里直接崩,而不是报告 推送行的 `rev-parse --verify --quiet` 包在会抛异常的 `git()` 里:ref 不存在时 git 以 1 退出且无输出,于是这一行 —— 本该报告"这个分支不在任何远端上"的那一行 —— 把整个审计抛崩(CI 日志里 stdout/stderr 全空)。CI 的 checkout 本来就没有 `origin/dot-skill-test` 跟踪 ref,所以每次必崩。 改动:ref 探测用不抛异常的 `gitRef()`;CI 环境里这一行记为**缺口**并说明理由 (CI 的源码本来就来自远端,这条要求在这里无法验证),而不是假装验证过;本地 仍严格,且把"工作树干净"这句标题真正纳入判定(此前标题这么写,检查只看 ahead,脏工作树照样绿)。 三、验收红行不再空着理由 visual-check 缺少 playwright 的提示打在 **stderr**,而这一行只引用 stdout, 于是失败信息是空的。现在缺 playwright 时单独报一行并给出该设的变量。 本地核对:Node 20 与 22 都收集 383 项。
CI 终于能跑测试之后(前一个提交修好了 npm test),Node 20 与 22 两条腿都稳定
红在同一条上:
not ok 123 - cli: --mode mcp routes to the MCP client and needs a target
本机复现的办法很直接 —— 把 `~/.colleague-skill/` 移走,这条立刻变红。原来
`credentialPaths()` 除了 `DISTILLY_HOME` 还会回落到 `~/.colleague-skill/<file>`,
而 feishu-mcp 的两条 CLI 路由测试调 `runCollectCli(..., { transport })` 时不传
env,于是 `loadCredential` 拿的是 `process.env` —— **我笔记本上那份改名前的旧
凭据**。CI 上没有那个文件,所以必红。同一个文件里"缺凭据要响亮失败"的那条测试
早就为此把 HOME 指到空目录并写了注释,这两条却漏了。
改动:
1. `tests/feishu-mcp.test.mjs` 的 sandbox 现在把凭据写进自己的 home,并带一份
`{ DISTILLY_HOME, HOME }`;两条路由测试显式传它。移走真实旧凭据后 8/8 通过。
2. `src/collect/slack.mjs` 的 `credentialPaths()` 改为 `env.HOME ?? homedir()`,
与 `kit.mjs` 一致:旧路径回落必须跟着被覆盖的 HOME 走。此前它无视 `env.HOME`,
一次"隔离"的运行照样能读到真实 home 的凭据,两个渠道对"刚读的是哪个文件"
也会给出不同答案。
3. `tests/collect.test.mjs` 里两条只会打印 `false !== true` 的断言补上现场信息
(收据、stderr、work 目录清单)—— 这条 CI-only 失败此前从日志里根本
看不出是"没写文件"还是"写到别处去了"。
全量:无真实旧凭据时 383/385(另 2 条是本机未提交/未推送时的审计行)。
CI 现在给得出失败现场了,于是这条一看就清楚:
the page was not written; receipt={… "path":"…/knowledge/raw/slack/c0123-p001.json" …}
tree=knowledge/index.json, knowledge/raw/slack/c0123-p001.json
文件就在那里,只是名字是小写。`writeRaw` 会把名字过一遍 `slug()`,所以频道
`C0123` 落盘成 `c0123-p001.json`(收据里的 `channel_id` 仍然是 `C0123`)。断言
写的是 `C0123-p001.json`,在 macOS 上 APFS 大小写不敏感所以"通过",在 Linux
runner 上就红了 —— 又是一条"取决于在哪台机器上跑"的测试。
断言改成真实文件名,并在注释里写明为什么是小写,免得下次又被"修"回去。
上一节写的是「CI 的单元测试步骤是红的 → 已修好」,事实相反:那次改动之后 CI 仍然 30 次运行全部 failure。补记真实的两个原因(引号挡住 glob 展开、Node 20 不支持 glob;审计的推送行在 CI 里抛异常而不是报告)以及它们被修掉之后才露出来的两条 「只在开发机上通过」的测试(读真实 home 的旧凭据、C0123 vs c0123 的大小写依赖)。 保留原文对照,不删除已经写下的错误结论。
被要求去看 alchaincyf/nuwa-skill 的做法。只记录读过和跑过的事实: - 它的 6 Phase 流程(两个人工检查点)、三重验证(跨域复现/生成力/排他性)、 表达 DNA 的量化口径、Agentic Protocol 的推导方式; - 它怎么处理说话人:**不处理**。字幕清洗脚本只去序号/时间戳/标签/重复行, 说话人标签原样留成正文 —— 因为它的语料形状天然是「一个人的一手产出」, 多人会议不是它的输入。它不是解决了这个问题,是绕开了它。 - 实跑它的 srt_to_transcript.py 处理我们的公开语料(原样下载、存 /tmp、未入库, sha256 记在文档里):84860 字符 → 36399 字符 / 825 段,时间戳去干净, 84 处说话人标签全部变成无结构文本;同时暴露两处泄漏 —— 字幕头字段 `starttime 1248896221.592` 被当成正文写进语料,`>>` 换人标记没被剥掉 (同一份文件我们的解析层把前者收进 warnings)。 - 效果测量:评分卡五维 + 独立双 agent 铁律 + 反作弊四条。关键判断是 它的「立场一致性」需要公开已知立场当真值,因此只适用于公众人物; 我们的 A/B holdout 不依赖真值,是给私人语料设计的。两者并存而非替代。 - 逐项对照表,以及 4 条可采纳项(换语料形状 / 移植评分卡并加锚点回指率 / 三重验证做成 derive 门槛 / 抄信源纪律)+ 明确不采纳的两条。 文档不改变任何产品行为。
同一个 `--base-dir` 在四条命令里有四种含义,而且**照文档走会静默写错地方**:
harvest --base-dir /w → /w/skills/colleague/<person> (工作区根)
skill * --base-dir /w → /w/<slug> (存储根本身)
retrospect --dir → 人物目录本身(帮助却写"skills 根目录")
view --root → 又一个;而 `distilly view --help` 广告的是
`--base-dir`,实际跑直接 `unknown option`
结果是:按 SKILL.md 第 4 步调 `skill create --base-dir /w`,Skill 被写到
`/w/<slug>` —— 和 `knowledge/` 分了家,命令**返回 0 并打印 "Created skill"**。
我实测踩过一次。契约第 3 条明令禁止静默降级,但机械层没有拦。
改法:一个规则,三个名字,各司其职。
--base-dir <工作区> 每一条命令都是"工作区根"(下面有 skills/)
--skills-dir <dir> skill * 专用:直接存放 <slug>/ 的那一层
--dir <人物目录> retrospect 专用:人物目录本身,不做查找
--root <dir> view 保留的别名,与 --base-dir 同义
同时:
- `view` 现在真的接受 `--base-dir`(此前帮助写了、解析器不认);
- `retrospect` 新增 `--base-dir`,并把 `--dir` 的帮助从"skills 根目录"改成
"人物目录本身";
- `skill *` 同时给 `--base-dir` 与 `--skills-dir` 时报错,而不是猜一个;
- **旧命令转发层做翻译**:`legacy skill_writer.py --base-dir X` 里的 X 是旧语义
(存储根),转发时改写成 `--skills-dir X`。不做这一步,所有历史命令行会静默
写到更深一层。
测试跟着改:把语义确实是"存储根"的调用点换成 `--skills-dir`(cli-lifecycle 17 处、
commands、dispatcher、scripts/parity.mjs 16 处)。全套 391 项。
`install <host> --dry-run` 与 `doctor` 会从环境里解析 `$DSH_HOME/skills/distilly`。
测试用的是 `{ ...process.env }`,于是它们继承了开发机真实的 `DSH_HOME`:
本机把 distilly 装进 DSH 之后:
not ok 11 - install <host> resolves the documented directory from the shared matrix
dry run must not write 1 !== 0
not ok 13 - doctor inventories every host and names what this build cannot do yet
两条与测试对象毫无关系的红灯。这与之前修过的「测试读真实 home 的旧凭据」是同一
类病:**测试的输入里混进了开发机的状态**。
runCli 现在默认把 DSH_HOME / DSH_AGENTS_HOME 指向临时目录,调用方仍可用 env 覆盖。
把真实 DSH_HOME 指到空目录后 16/16,隔离后同样 16/16。
SKILL.md 全篇说"唯一受支持的入口是 `distilly` CLI",但**没有一处**说明在这个
宿主里那条命令长什么样:PATH 上没有全局 `distilly`,`bin/distilly.mjs` 也没有
可执行位(`-rw-r--r--`)。我在 DSH 里实跑时,是靠宿主给出的技能根目录加上自己
推断才写出 `node {distilly_skill_root}/bin/distilly.mjs …` 的——换个 agent 很可能
就卡在这一步。
改动:
- SKILL.md 的 Skill Root 说明补上确切调用形式(中英各一段):
`node "{distilly_skill_root}/bin/distilly.mjs" <子命令>`;并写明没有 node 时要
如实停下,不要手工重做某一步;
- 同一段里写明 `--base-dir`(工作区根)与 `--skills-dir` / `--dir`(直接指名)的
区别,以及两个同时给会被拒绝;
- `bin/distilly.mjs` 加可执行位(100644 → 100755),shebang 本来就在。
验收的 12 项全部围绕 harvest → retrospect → view → render,**没有一步碰
`skill create`**。于是这个产品真正交付的东西——`skills/<family>/<slug>/SKILL.md`——
可以完全不存在、可以自相矛盾,而门禁全绿。我上一轮就是这么拿到一个"Operating
Rules 写着 Layer 0 永远优先、PART B 里根本没有 Layer 0"的产物的。
新增 5 项,判据抽成 `scripts/skill-artifacts.mjs`(纯字符串入参,可单测):
✅ skill create 产出完整交付物 7 个产物都在
✅ 交付物含 PART A / PART B / 运行规则
✅ 交付物六层结构齐且 Layer 0 有规则 Layer 0–5 标题齐,且 Layer 0 至少一条规则
✅ 交付物里的锚点全部可回指 SKILL.md/work.md/persona.md 引的锚点悬空=0
✅ doctor 报出锚点回指率 回指率从"只报不拦"变成回执里的一行判据
内容由 `distillFixture` 从语料原文机械拼出(每条都引用真实原文 + 真实锚点),
所以这条门禁验的是机械层能保证的部分;蒸馏质量属于效果层(blind-test),
它需要裁判而不是夹具。
写这组判据时,第一版实现被自己的反例测试抓出漏洞:我用
`skillBody.includes("Layer 0")` 判断层级,而**每份生成物里的运行规则都含那句
"Layer 0 rules … always take priority"**——于是缺 Layer 0 的产物照样通过。
改成匹配 `## Layer N` 标题后才真正拦得住。这条正好说明为什么判据必须能被证伪:
`tests/skill-artifacts.test.mjs` 用 4 个反例(缺 Layer 0、只有标题没有规则、
悬空锚点、缺产物)钉住它。
两份语料验收:12/12 → 17/17。全套 391 项。
19 个 ds/* PR 是**堆叠**的:ds/02 的 base 是 ds/01、ds/03 的 base 是 ds/02…… ds/01 的 base 是 pre-v2-baseline。而 CI 的触发名单是 [dot-skill-test, dot-skill, main] —— **0 个 PR 的 base 在里面**。 所以那 19 个 PR 页面上永远是 'no checks reported':不是没人点,是配置上不可能触发。 它们各自的 PR 正文却写着测试通过,而那句测试只有我本机跑过。 改动:触发名单加 'ds/**'(push 与 pull_request 都加),并加 workflow_dispatch 以便不推代码也能手动重跑。加完这条,分支自身的 push 事件就能把 CI 跑起来—— 各分支会在随后被推上同一个 ci.yml(否则分支上的 workflow 文件没有这个触发器)。
第 12 行「截图不入库」用了 :两个探针里只要有一个被忽略就算过。而且探的是 `dst-evidence`(不带斜杠),.gitignore 里写的是目录模式 `dst-evidence/`,目录不存在时 匹配不上 → 探针返回 false,这一行**照样绿**,证据串里还明晃晃写着 false。 改成探目录**里面的路径**,并且要求每一个探针都过(every)。 第 13 行「每个已合并分支都有 PR 证据文档」用 `git branch --merged HEAD`:19 条 ds/* 分支一条都没合并,于是匹配到 **0 条**,`withoutDoc.length === 0` 恒真 —— 它宣称 「每个 PR 都有测试/前后对比/回滚文档」这件事从来没被验证过。 改成检查**所有本地 ds/NN-* 分支**(合并与否都算),并要求至少有一条(0 条本身即失败)。 现在的证据:23 条 ds/* 分支、缺文档 0;两个忽略探针都 true。
三处不一致,都会让拿到这个版本的人走错路: 1. README 讲的是另一条产品线。它的正文写的是「This `distilly-plugin` branch…」、 五工具 MCP、Panel、SQLite、容量 fixture,并让人 clone `distilly-plugin` 分支—— 而这一支是 Skill + CLI 实现,两者不是一回事。现在 README 顶部先说清本分支是什么、 交付什么、怎么装到宿主、怎么验,并把 Plugin 那一整块明确划到「另一条产品线」标题下, 注明不在本分支运行(信息不删,避免那条线失传)。 2. `docs/v2/STATUS.md` 停在 7 条任务分支的时代,还写着「每个 PR 的 base 都是 dot-skill-test」——实际 19 个 PR 是堆叠的。重写成权威状态文档:能做什么 / 五步各自的 机械保证 / 怎么跑(含 DSH 那条实测过的路)/ 怎么验(带今天的实测数字)/ 语料形状 / 分支与 PR 清单 / **8 条已知缺口**(效果数字、语料体检、Step 3 无门禁、doctor 只报不拦、 19 个 PR 树不自洽、base 结构、v1 文档、README 分区)。 3. `docs/PRD.md` 与两份 SKILL_TYPE_ABSTRACTION_DESIGN 是 v1(Python)形状。顶部加说明 指向 v2 契约与状态文档,正文保留(产品意图仍然有效)。 门禁:prompt-lint 0 finding、check_release 7/7、测试 391/391。
首次真正跑 CI 之后,19 个子功能 PR 分成三组: A. 可评审(8 条):ds/13 ds/15 ds/16 ds/17 ds/18 ds/19 ds/20 ds/21 —— 并入集成分支 的 3 个修复提交后 CI 全绿(Node 20 / Node 22 / Acceptance)。 B. 一条结构性红(1 条):ds/12-note —— 补同样修复后仍剩 1 条:该分支的 check_release 要求 docs/v2/IDENTITY.md,而那是 ds/15 才交付的文件,重建快照混了时代。 C. 快照不自洽(10 条):ds/01–ds/11 —— CI 30–79 红,树与自带测试对不上,不作为合并 入口;功能已包含在集成分支与后续分支里,不打算修。 已知缺口第 5 条随之改写(原来写的是"19 条全红")。
两处问题,都是"报了一个不起作用的数": 1. `cited` 只统计 `evidence/derived/*.json` 里的引用 —— 那是没人读的中间层。 产品交付的是 `SKILL.md` / `work.md` / `persona.md` / `work_skill.md` / `persona_skill.md`(和渲染出来的页面)。一份每条规则都带锚点的 Skill,在 doctor 眼里贡献 **0** 个 cited:数字描述的是管道中段,产物本身没被看过。 现在两层都算,并把"账本覆盖率"作为信息打印。 2. 悬空引用只进 `warnings[]`,`ok` 恒为 true、退出码恒为 0 —— 下游没有任何办法据此 行动。而"每条结论都能回指"是这个产品的核心承诺。 现在判定是 **悬空 = 0**:有悬空则 `ok:false`、`verdict:"FAIL"`、退出码 1。 覆盖率仍只作信息,不作为门槛——Skill 没有义务引用语料里的每一条 cue, 但有义务不引用不存在的证据。 实测(/tmp/dsh-run 的中文语料):正常时 PASS、exit 0、29/76;手工塞一个 `[k0999]` 之后 FAIL、**exit 1**,还原后回到 PASS。 测试:`tests/doctor-coverage.test.mjs` 新增两条(退出码与 verdict;交付物引用计数), 并更新两处受影响的 receipt 断言。全套 393 项。
我用一份 47 分钟的议会记录跑通了全部五步,产出"会议室的画像"而不是"人的画像",
因为整条主线里**没有任何一步**问过"这份材料对不对、够不够"。模型自己的判断是唯一
的关卡,而它到 Step 4 才会做这个判断——那时错误已经付出代价。
现在 `doctor` 多一节 `shape[]`,数据全部来自 `retrospect` 已经派生的东西(读,不重算):
units / sources / speakers / attributed_units / top_speaker / top_share / verdict / reasons / notes
判定规则(写进 reasons,不需要背):
- 可引用单元 < 20 → FAIL(没有材料可"从里面"长出一个人);
- 有 ≥2 位说话人,但可归属单元 < 40%,或最活跃的人 < 20% → FAIL
(这是会议,不是一个人:要这个人自己的一手产出);
- 没有说话人标注 → **不**判失败(本人文章、单人录音本来就没有标注)。
`--require-shape` 是显式开关:不加时只报告 verdict 与理由(继续采集是合法的),
加了才让 FAIL 影响 `ok` 与退出码。`SKILL.md` 新增**第 1.5 步**(中英):采集完先跑
`distilly doctor --require-shape`,FAIL 就停下、把 reasons 原文告诉用户、说明补什么。
体检对象是**有账本的人物目录**,不是"已生成 SKILL.md 的 Skill"——否则它恰好在最需要
它的时刻(Collect 之后、Distill 之前)不生效。第一版就是按完成品枚举的,实跑时发现
它对着一份只有 knowledge/ 的语料说"没有可检查的 Skill"。
实测:
/tmp/dsh-run(26 条中文会议,一个人是主角) PASS units=52 speakers=3 attributed=28 top=张三(39%) exit 0
us-house(C-SPAN 议会记录,我当初的错误) FAIL units=2744 speakers=18 attributed=175(3%)
理由:先补这个人自己的一手产出,不要从会议流水里切人 exit 1
验收新增一行「语料体检给出 verdict(FAIL 必须说明理由)」——判据是"FAIL 不许静默",
不是"必须 PASS",因为验收的第二份语料本来就是错误形状的样本。两份语料各 18/18。
测试 +3(正例 / 错例与开关 / 材料过少)。全套 396 项。
在**右形状语料**(synthetic-interview,76 单元)上把盲测的机械部分跑完了, 交付一个可直接交出去的盲测包(本地 /tmp/dst-evidence/blind-2026-09-15/,按契约不入库): - A/B 按时间码切好:A 28 条(00:00:01–00:03:12)、B 10 条(00:03:12–00:04:21); - 实验组:蒸馏者**只读** run/deriver-input.md(派生结论 + 锚点表,不含原文)写出 view.evidence.json → finalize 渲染 run/profile.html(52379 字节,check ok、0 外链)。 私有模式校验:A 段 24 条原句里 **0 条**出现在页面上;页面引用 25 个锚点; - 对照组:control-prompt.md(裸 prompt + A 段原文)与 control-profile.md; - 裁判题 run/judge-prompt.md 与评分模板齐备;随包 README.md 写了确切命令。 顺序是可验证的:实验组页面在我读到 A 段原文**之前**就已定稿。 **没有做完的两步,以及为什么**:裁判(写 10 行「特征 | 预测」)与核对者(唯一读 B 的角色) 必须由另一个模型或人来当——本机只注册了一个模型(deepseek-official/deepseek-flash), 同一模型既蒸馏又裁判得到的数字没有意义。这不是省事,是这份包存在的理由。 STATUS 的缺口 1 随之改写(从"没跑过"改成"只差裁判",并写明包的位置与剩余步骤)。
上一提交把「每个 ds/* 分支都有证据文档」从"匹配 0 条即真"改成"必须至少有一条", 本机 23 条分支所以绿;但 **CI 的 checkout 只取一个 ref,本地没有任何 ds/* 分支**, 于是这一行在 CI 里变成硬失败 —— 两个 Node leg 与验收 job 一起红。 正确的处理既不是假绿也不是失败,而是**声明为缺口**:CI 拿不到那些分支,这一行在那里 无从检查;本地有分支时仍然严格(缺文档即红)。与"推送"那一行同一套处理方式。 实测:本地 23 条分支、缺文档 0、绿;模拟单 ref 检出(GITHUB_ACTIONS=true,无 ds/* 分支) → 记为已知缺口、exit 0。
pr-13 的第 3 节只记了一个坑(`--merged` 判断已合并分支),而且记的修法("改为按合并 提交统计")并没有落地;另一个坑当时根本没被发现。两条都在 2026-09-15 重写: - 第 11 行:19 条 ds/* 分支一条都没合并 → 匹配 0 条 → 恒真,"每个 PR 都有测试/前后对比/ 回滚文档"从未被验证。现在按**所有本地 ds/NN-* 分支**检查;单 ref 检出(CI)记为声明缺口。 - 第 12 行:探 `dst-evidence`(不带斜杠)匹配不上目录模式,返回 false,而 `.some()` 让 整行照样绿。现在探目录内路径并要求 every。 STATUS 的缺口表同时整理:分清"历史证据文档已被推翻(补了更正)"与"README 已分区"。 门禁:prompt-lint 0、测试 396(本机 2 条红是未提交导致,提交后转绿)。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
dot-skill v2:Node 单栈 + 证据脊柱 + 渲染 + 渠道与同意 + 目标审计
这个 PR 做了什么
把 dot-skill 从 Python 工具链迁到 Node 单栈,并把"蒸出来的角色像不像"变成一套可复算的验收:
每条结论带可回指锚点、同一输入跑两次字节相同、页面单文件离线、效果层有 A/B 盲测装置与判据。
契约冻结在
docs/v2/CONTRACT.md:唯一入口bin/distilly.mjs,磁盘布局knowledge/{raw,text,index.json}+evidence/derived+views/,统一--json回执(
command/person/ok/inputs/outputs/anchors/warnings/unavailable),密钥纪律(只允许出现配置文件名),computer-use 必须带
--consent <token>(否则 exit 2)。内容一览(12 条分支的合并结果)
docs/evidence/pr-01-node-core.mddocs/evidence/pr-02-parse-zero-cred.md--check)+view check/render+ visual-check 八项docs/evidence/pr-03-render.mddocs/evidence/pr-04-prompts.mddocs/evidence/pr-05-agents.mdretrospect确定性派生(7 类结论,各带锚点)docs/evidence/pr-06-retrospect.mddocs/evidence/pr-07-collect-consent.mdpr-09-…md、pr-10-…mddocs/evidence/pr-11-attribution.mdnotedocs/evidence/pr-12-feishu-routes-note.mdknowledge/evidence/views/assetstests/schema-migration.test.mjs怎么验(本次实跑)
scripts/audit-objective.mjs是这一版新增的范围门禁:把目标的每条需求映射到一个机械检查,每行写出它读到了什么,缺口单独成行、不算失败。已接进
node --test与 CI 两个 job。改动前后的关键数字(可复算)
k0012 text→[k0012] text;派生结论 0 → 22,回指锚点 0/56 → 19/56(PR-09)[k0001] 2023-11-14T22:13:20.000Z Alice:…;retrospect从 stats 2 / relations 0 / timelineorder→ stats 6 / relations 4 / timelinetime,并有by_speaker(PR-11)--allow-missing显式渲染「本节证据不足」,不编占位话术可复现的差异是"可验证性",结论与复算写在 PR-10 文档里)
已知缺口(诚实清单)
CONTRACT.md§1 列了 8 个采集渠道,实现 4 个(飞书/Slack/钉钉/X)。其余四个(discord / reddit / notion / gmail)现在回
collect/planned-channel并写明各自需要什么,doctor也会列出;打错字仍然是unknown-channel。sender.id,名字要另调通讯录 API;MCP 的--url不含多维表格。shifts(句长已按说话人分列)。现有公开语料(3 分钟面试)留出集太薄,半数行为断言无法判定。
证据与截图
截图、盲测留档与命令原文不入库,都在本地
dst-evidence/(汇总见dst-evidence/SCREENSHOTS.md):pr-09-evidence-spine/(含blind-run/15 个留档)、pr-11-attribution/、pr-12-feishu-routes/。重建状态(2026-09-15 补记,创建本 PR 时追加)
本 PR 的分支在 2026-09-15 的
/tmp清空事件中丢失了 165 个未推送提交,随后按会话转录做了等价重建。请连同以下事实一起评审:node --testds/NN-*67 条失败的原因已定位:恢复管线按「最大有效候选」逐文件挑选,对约 30 个测试文件和少数源模块挑到了较早版本——不等于这些功能没做。已在
dst-evidence/FINAL-REPORT.md逐文件列出。无法复原的部分:转录保存了 329 次
git commit调用,但没有 per-commit 文件树,所以原 165 个原子提交在物理上不可恢复。