fix(contract): align CLI surface, docs, and executable release checks

Refuse PDF on parse-doc with office/unsupported-format. Replace text-grep
release criteria with executed migrate, install, and run-gates checks.
Delete ci.yml. Refresh STATUS to the atomic stack that stops at
dot-skill-test.
This commit is contained in:
Cursor Agent
2026-09-22 13:47:22 +00:00
parent bb6d431109
commit 74b31ab16a
37 changed files with 376 additions and 238 deletions
-96
View File
@@ -1,96 +0,0 @@
name: CI
on:
# `ds/**` is not decoration: the 19 per-feature PRs are a **stack** — each targets
# the branch before it (`ds/02` → `ds/01`, … , `ds/21` → `ds/20`), and `ds/01`
# targets `pre-v2-baseline`. With this list limited to the three integration
# branches, not one of them could ever run CI: every PR sat at "no checks
# reported" while its body claimed tests. A base branch that no trigger matches is
# a gate that does not exist.
push:
branches: [dot-skill-test, dot-skill, main, 'ds/**']
pull_request:
branches: [dot-skill-test, dot-skill, main, 'ds/**']
# Manual re-runs without pushing anything.
workflow_dispatch:
jobs:
test:
name: Node ${{ matrix.node-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: ["20", "22"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Syntax-check every module
run: |
find bin src scripts tests -name '*.mjs' -print0 | xargs -0 -n1 node --check
- name: Unit tests
# `npm test`, not a bare `node --test`: the bare form also collects
# `scripts/blind-test.mjs` (it matches `**/*-test.mjs`) and records its
# usage error as a failing test, so CI went red while every local command
# looked green.
run: npm test
- name: Prompt contract lint
run: node scripts/prompt-lint.mjs
- name: Skill template freshness
run: node scripts/generate-template.mjs --check
# Every demand of the v2 objective, mapped to the artefact that proves it.
# Acceptance itself runs in the next job, so this is scope-only.
- name: Objective audit
run: node scripts/audit-objective.mjs --skip-acceptance
# Release hygiene: versions, schema marker, carried directories, gates.
- name: Release check
run: node scripts/check_release.mjs
acceptance:
name: Acceptance (public corpus)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
# The visual-check phase drives a real browser; playwright is a dev
# dependency and never ships with the package.
- name: Install Playwright
run: |
npm install --no-save playwright@1.62.1
npx playwright install --with-deps chromium
- name: End-to-end acceptance on the bundled corpus
run: node scripts/acceptance.mjs --evidence "$RUNNER_TEMP/evidence"
# The second public corpus is multi-source (two chat exports + a document)
# and dated, so the same phases run on a corpus the derivation was designed for.
- name: End-to-end acceptance on the multi-source corpus
run: node scripts/acceptance.mjs --corpus tests/fixtures/public-corpus/synthetic-multisource --evidence "$RUNNER_TEMP/evidence-multisource"
# Scope audit *with* the acceptance run it normally performs.
- name: Objective audit (includes acceptance)
run: DISTILLY_PLAYWRIGHT_ROOT="$PWD" node scripts/audit-objective.mjs
- uses: actions/upload-artifact@v4
if: always()
with:
name: acceptance-evidence
path: |
${{ runner.temp }}/evidence
${{ runner.temp }}/evidence-multisource
if-no-files-found: ignore
+6 -4
View File
@@ -20,6 +20,8 @@ Distilly is a local-first product for turning a person's source material, workin
## 这一支(`dot-skill-test`):人物 Skill + 证据脊柱
栈停在 `dot-skill-test`,`dot-skill` 保留。
**把一个人的原材料蒸馏成一个可调用的人物 Skill,外加一份每条结论都能回指到原文的画像页。**
零运行时依赖,只要 Node ≥ 20。
@@ -33,7 +35,7 @@ Distilly is a local-first product for turning a person's source material, workin
**这份实现与那条 Plugin 路线的区别,一句话**:它不追求"像不像",它保证"凭什么这么说"——
每条结论都能回指到原文的字节区间(`[k00NN]` 锚点),派生可复跑(同一输入两次字节相同),
交付物由机械门禁压住(验收 17 项,含"产物齐 / Layer 0–5 齐 / 悬空锚点=0")。
交付物由机械门禁压住(验收 18 项,含"产物齐 / Layer 0–5 齐 / 悬空锚点=0")。
### 装到一个宿主(以 DeepSeek Harness 为例)
@@ -50,9 +52,9 @@ node bin/distilly.mjs install deepseek-harness # → $DSH_HOME/skills/distil
### 怎么验
```bash
npm test # 391 项,Node 20 与 22 各一遍
node scripts/acceptance.mjs --corpus tests/fixtures/public-corpus/synthetic-interview --person lin-gong # 17/17
DISTILLY_PLAYWRIGHT_ROOT=<含 node_modules 的目录> node scripts/audit-objective.mjs # 15/15
npm test # 397 项,Node 20 与 22 各一遍
node scripts/acceptance.mjs --corpus tests/fixtures/public-corpus/synthetic-interview --person lin-gong # 18/18
DISTILLY_PLAYWRIGHT_ROOT=<含 node_modules 的目录> node scripts/audit-objective.mjs --skip-acceptance # 14/14
```
**当前状态、已知缺口、分支与 PR 清单**:[`docs/v2/STATUS.md`](docs/v2/STATUS.md)。
+13 -1
View File
@@ -19,7 +19,9 @@ Anyone can become a reusable `.skill` for AI agents and compatible bots.
<br>
*Last updated: 2026-08-23*
*Last updated: 2026-09-22*
栈停在 `dot-skill-test`,`dot-skill` 保留。
[**Chinese**](docs/lang/ROADMAP_ZH.md) · [**Spanish**](docs/lang/ROADMAP_ES.md) · [**German**](docs/lang/ROADMAP_DE.md) · [**Japanese**](docs/lang/ROADMAP_JA.md) · [**Russian**](docs/lang/ROADMAP_RU.md) · [**Portuguese**](docs/lang/ROADMAP_PT.md) · [**Korean**](docs/lang/ROADMAP_KO.md)
@@ -27,6 +29,16 @@ Anyone can become a reusable `.skill` for AI agents and compatible bots.
---
## Distilly v2 CLI (this tree)
栈停在 `dot-skill-test`,`dot-skill` 保留。
These CONTRACT clauses are not shipped. They live here instead of the frozen command surface.
- PDF extraction. `parse-doc` refuses `.pdf` with exit 2 and `office/unsupported-format`. Register extracted text with `note --from`.
- HTML axe (WCAG 2.2 A/AA) and internal broken-link checks on rendered pages.
- `python3 tools/*.py` forwarding. The CLI does not wrap the old Python tools.
## What's Done (v1.0)
| Capability | Status |
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-01 · Node 单栈基座:入口 CLI + Skill 内核 + 安装器 + 拼音 + 测试移植
- 分支:`ds/01-node-core`(12 个提交,已本地合并进 `dot-skill-test`)
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-02 · 零凭据解析:`knowledge/` 账本 + 锚点 + chat/subtitle/archive 解析
- 分支:`ds/02-parse-zero-cred`(6 个提交,已本地合并进 `dot-skill-test`)
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-03 渲染层证据(`ds/03-render`)
纯文字证据。截图/PNG **不入库**,见 §6;本文件只记录数值、命令与结果。
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-04 · 提示词层(`ds/04-prompts`)证据
纯文字 PR,无 HTML 产物、无截图(`/tmp/dst-evidence/` 无文件)。命令名一律以 `docs/v2/CONTRACT.md` §1 的命令块为准。
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-05 · coding-agent 适配层:矩阵文档与防漂移断言
- 分支:`ds/05-agents`(基于 `dot-skill-test`)
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-06 · `retrospect`:确定性回望派生
- 分支:`ds/06-retrospect`(基于 `dot-skill-test`)
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-07 · 需要凭据的采集渠道 + computer-use 同意门(ds/07-collect-consent)
契约:`docs/v2/CONTRACT.md`(§1 命令表、§2 磁盘契约、§3 回执与密钥、§4 证据纪律、§5 门禁)。
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-09 · 证据脊柱打通 + 效果层盲测装置
- 分支:`dot-skill-test`(集成分支,本轮 9 个原子提交直接在本地集成分支上)
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-10 · 效果层盲测:实跑两轮的结果与结论
- 目标:按 `docs/v2/ACCEPTANCE.md` 的 A/B holdout 真跑一次"证据层 vs 裸 prompt",
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-11 · 归因修复:派生层第一次认得出「谁」+ 飞书导出解析
- 分支:`ds/10-attribution`(3 个原子提交)已本地合并进 `dot-skill-test`;飞书导出在
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-12 · 飞书三路补齐 + `note`:CONTRACT §1 命令面完成
- 分支:`ds/11-feishu-clients`(3 个原子提交)与 `ds/12-note`(1 个),都已本地合并进 `dot-skill-test`
+3 -1
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-13 · 目标审计门禁
- 分支:直接落在集成分支 `dot-skill-test`(本地)
@@ -44,7 +46,7 @@ node --test tests/audit-objective.test.mjs # 2 条:全体满足 + 缺
- `docs/evidence/pr-01-node-core.md`、`pr-02-parse-zero-cred.md`:最早两条分支缺证据文档,
审计把它标成唯一未满足项后补写。
- 契约里未移植的四个采集渠道(discord/reddit/notion/gmail)从"读起来像拼错"改成
`collect/planned-channel` + 逐条需求说明,`doctor` 多打一行 `Planned channels`。
当时用 planned-channel 这类错误码做缺口说明,并让 doctor 多打一行 Planned channels。那是 ds/13 快照上的历史行为,不是本栈。
## 6. 已知缺口
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-15 · 跨渠道身份映射(`identity.json`)
- 分支:`ds/15-identity`(本地,合并进 `dot-skill-test`)
+3 -1
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-16 · Discord 与 Notion 两个采集渠道
- 分支:`ds/16-discord-notion`(本地,已合并进 `dot-skill-test`)
@@ -8,7 +10,7 @@
## 1. 为什么
`CONTRACT.md` §1 的采集命令行写着 8 个渠道,此前只实现 4 个(飞书/Slack/钉钉/X)。
未实现的两个渠道此前回 `collect/planned-channel` 并写明所需凭据——诚实,但仍是缺口。
未实现的两个渠道当时用 planned-channel 这类错误码回执并写明所需凭据。那是 ds/16 快照上的历史说明,不是本栈。
这一轮把 Discord(bot token)与 Notion(内部集成 token)补上,`PENDING_CHANNELS`
从 4 条缩到 2 条(reddit / gmail)。
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-17 · Reddit 与 Gmail:契约的采集渠道全部实现
- 分支:`ds/17-reddit-gmail`(本地,已合并进 `dot-skill-test`)
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-18 · 池化口径收尾 + 带身份映射的第四次盲测
- 分支:`dot-skill-test`(本地集成分支)
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-19 · 迁移把旧内容导入证据脊柱 + 发布检查
- 分支:`dot-skill-test`(本地集成分支)
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-20 · 仓库忽略规则:把用户数据、凭据与本地证据的边界补回来
- 分支:`ds/20-repo-hygiene`(worktree `/tmp/dot-skill-test-20`,已本地合并进 `dot-skill-test`)
+2
View File
@@ -1,3 +1,5 @@
> 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
# PR-21 · 发布产物与 CI 测试命令:两个「门禁全绿但东西是坏的」缺陷
- 分支:`ds/21-package-ci`(worktree `/tmp/dot-skill-test-21`,已本地合并进 `dot-skill-test`)
+3 -5
View File
@@ -19,22 +19,21 @@ harvest <dir|file> 零凭据:目录/文件 → knowledge/
parse-chat <export.json> ChatGPT / Claude / Slack 导出 / Telegram / Discord
parse-email <x.eml|.mbox>
parse-subtitle <x.srt|.vtt>
parse-doc <x.docx|xlsx|pdf>
parse-doc <x.docx|xlsx>
parse-archive <x.zip|dir> X 官方归档 / Takeout / Discord / Telegram / Instagram / Facebook / LinkedIn
retrospect 纯派生 → evidence/derived/*.json(跑两次字节相同)
collect <feishu|slack|dingtalk|x|discord|reddit|notion|gmail> 要 key / OAuth
collect x --mode browser --consent <token> computer use(无 token → exit 2)
transcribe <audio|video> 可选后端(OpenAI 兼容 HTTP 或宿主能力)
note --from <file|-> 把 LLM 自己读到的内容登记进账本(method:"model-read")
consent <grant|list|revoke> ~/.distilly/consent.json
consent <grant|list|verify|revoke|prune> ~/.distilly/consent.json
view check | view render [--shareable]
doctor 证据覆盖率 / 不可用渠道 / 锚点回指率 / computer-use 占比
skill <create|update|list|version>
skill migrate
install <host> | uninstall
```
迁移期兼容:旧的 `python3 tools/xxx.py` 调用由 `bin/distilly.mjs` 转发并打印 deprecation 警告(PR① 内实现,PR③ 删除)。
## 2. 磁盘契约
```
@@ -80,7 +79,6 @@ skills/<family>/<slug>/
- 确定性:同一输入**跑两次 sha256 相同**(派生类命令)。
- 锚点完整性:`docs`/`views` 里引用的每个锚点必须能在 `knowledge/index.json` 回指。
- prompt 契约 lint:命令名必须真实存在、双语两段一致、锚点格式统一、禁止明文密钥字样。
- HTML 产物:内部链接 0 坏链 + axe(WCAG 2.2 A/AA)0 violations(serious/critical)。
## 6. 双语
+45
View File
@@ -0,0 +1,45 @@
# 门禁
`tests/gates/*.json` 各定义一条门禁。文件名等于 `id`。`scripts/run-gates.mjs` 读这个目录,用 `sh -c` 跑 `command`,按 `pass` 里的字面谓词判定。
谓词可以是:
- 退出码
- stdout 包含某行
- stdout 匹配某正则
- ratchet
ratchet 从输出里抽失败名。`parser` 为 `tap` 时抽 `not ok N - <name>`。`parser` 为 `acceptance` 时抽 `❌ <phase>`。抽到的失败名与基线文件比对。新失败与过期基线行都算 FAIL。基线为空时再套 `final` 谓词。
基线文件在 `tests/gates/baseline/`。首行是注释。其余每一行是一条已知失败。基线只能缩小。修掉一条失败就删掉对应那一行。不要加行。不要重排留下的行。
本树不是叠好的栈顶。仍红的行必须留下。
## 怎么加一条门禁
1. 在 `tests/gates/` 新建 `<id>.json`。
2. `id` 必须等于文件名去掉 `.json`。
3. 写 `group`、`command`、`pass`。需要浏览器的加 `"requires": ["playwright"]`。
4. 若起步时已有已知失败,写 `tests/gates/baseline/<id>.txt`,并在 `pass.ratchet` 里指向它。
5. 跑 `node scripts/run-gates.mjs --id <id>`。新失败必须进基线,否则门禁红。
6. 跑 `node scripts/run-gates.mjs --list --json` 确认新 id 出现。
## 怎么缩小基线
1. 先让对应测试由红转绿。
2. 从基线文件删掉那一行。只删真正变绿或关掉的行。
3. 再跑 `node scripts/run-gates.mjs --id <id>`。若出现 `stale baseline line, delete it`,说明删对了还漏了一行,或删了仍红的行。
4. 不要用 `--write-baseline` 把仍红的失败冲掉。
## 常用命令
```
node scripts/run-gates.mjs --list
node scripts/run-gates.mjs --list --json
node scripts/run-gates.mjs --without playwright
DISTILLY_PLAYWRIGHT_ROOT=/tmp/pw-root node scripts/run-gates.mjs --requires playwright
node scripts/run-gates.mjs --id audit-tests
node scripts/check-entry-surface.mjs --baseline tests/gates/baseline/entry-surface.txt
```
Node 20 与 Node 22 都要跑 `--without playwright`。playwright 组只在 Node 22 跑。仓库里不执行 `npm install`。
+60 -53
View File
@@ -1,10 +1,11 @@
# dot-skill v2 · 当前状态
> 这份文件是**这一支的权威状态说明**:现在能做什么、怎么验、哪些还没做到。
> 最后更新:2026-09-15(集成分支 `dot-skill-test`)。
> 最后更新:2026-09-22(集成分支 `dot-skill-test`)。栈停在 `dot-skill-test`,`dot-skill` 保留。
> 契约 `docs/v2/CONTRACT.md` · 验收 `docs/v2/ACCEPTANCE.md` · 渲染 `docs/v2/RENDER.md` ·
> 提示词 `docs/v2/PROMPTS.md` · 宿主矩阵 `docs/v2/HOSTS.md` · 迁移 `docs/v2/MIGRATION.md`。
> 各功能的交付说明与前后对比在 `docs/evidence/pr-NN-*.md`。
> 提示词 `docs/v2/PROMPTS.md` · 宿主矩阵 `docs/v2/HOSTS.md` · 迁移 `docs/v2/MIGRATION.md` ·
> 门禁 `docs/v2/GATES.md`。
> 各功能的交付说明与前后对比在 `docs/evidence/pr-NN-*.md`。那些文件描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
## 1. 这是什么
@@ -52,29 +53,36 @@ node bin/distilly.mjs install deepseek-harness # → $DSH_HOME/skills/disti
用于直接指名更细的一层;`view` 另接受同义别名 `--root`。同时给 `--base-dir` 与
`--skills-dir` 会报错,不会猜。
本树 `doctor` 不接受 `--person`。空 HOME 探测必须同时把 `HOME` 和 `DISTILLY_HOME` 指到同一个空的 `mktemp -d`。
## 4. 怎么验(当前实测数字)
下列数字来自 2026-09-22 在 `atomic/pr-10-contract-align` 上的重跑。Node 22.14.0 与 Node 20.20.2。不要抄计划里的 396。
```bash
npm test # CI 的同一条命令
npm test # Verify 里 unit-tests 门禁的同一条命令
node scripts/acceptance.mjs --corpus tests/fixtures/public-corpus/synthetic-interview --person lin-gong
node scripts/acceptance.mjs --corpus tests/fixtures/public-corpus/us-house-floor-2009-07-29 --person us-house
DISTILLY_PLAYWRIGHT_ROOT=<含 node_modules 的目录> node scripts/audit-objective.mjs
node scripts/acceptance.mjs --corpus tests/fixtures/public-corpus/synthetic-multisource --person lin-gong
node scripts/audit-objective.mjs --skip-acceptance
node scripts/check_release.mjs && node scripts/prompt-lint.mjs && node scripts/generate-template.mjs --check
node scripts/run-gates.mjs --without playwright
DISTILLY_PLAYWRIGHT_ROOT=/tmp/pw-root node scripts/run-gates.mjs --requires playwright
```
| 门禁 | 结果 |
| --- | --- |
| `npm test` | 391 / 391(Node 20 与 22 各一遍) |
| `scripts/acceptance.mjs` | **18 / 18**,两份公开语料各一遍 |
| `scripts/audit-objective.mjs` | 15 / 15 条满足(本地要求工作树干净且已推送) |
| `scripts/check_release.mjs` | 7 / 7 |
| `scripts/prompt-lint.mjs` | 0 finding / 26 文件 |
| `scripts/generate-template.mjs --check` | 无漂移 |
| GitHub Actions | Node 20 / Node 22 / Acceptance 三个 job 全绿 |
> 2026-09-15 补上的两道门禁:**语料体检**(`doctor --require-shape`:单元数、可归属比例、
> 最活跃者占比,FAIL 必给理由——它拦住的正是"拿议会记录蒸出会议室的画像"那类错误)与
> **引用判定**(`doctor` 现在把生成物里的引用一并计入,悬空引用 → `ok:false`、exit 1)。
| 门禁 | 结果 | 来源命令 |
| --- | --- | --- |
| `npm test` | **397 / 397**,`# fail 0`(Node 20.20.2 与 22.14.0 各一遍) | `npm test` |
| `scripts/acceptance.mjs` | **18 / 18**,三份公开语料各一遍 | `DISTILLY_PLAYWRIGHT_ROOT=/tmp/pw-root node scripts/run-gates.mjs --requires playwright` |
| us-house 形状 | `FAIL · units=2744 speakers=18 attributed=175` | 同上,acceptance-us-house |
| `scripts/audit-objective.mjs --skip-acceptance` | **14 / 14** 条满足 | `node scripts/audit-objective.mjs --skip-acceptance` |
| `scripts/check_release.mjs` | **7 / 7** 项通过 | `node scripts/check_release.mjs` |
| `scripts/prompt-lint.mjs` | `prompt-lint: 0 finding(s) in 0 file(s) across 26 file(s) scanned (contract commands: 16)` | `node scripts/prompt-lint.mjs` |
| `scripts/generate-template.mjs --check` | 无漂移 | `node scripts/generate-template.mjs --check` |
| `run-gates --without playwright` | **8 / 8** gates passed(Node 20 与 22) | `node scripts/run-gates.mjs --without playwright` |
| `run-gates --requires playwright` | **3 / 3** gates passed | `DISTILLY_PLAYWRIGHT_ROOT=/tmp/pw-root node scripts/run-gates.mjs --requires playwright` |
| `check-entry-surface --baseline` | `RESULT PASS findings 9 known 9 new 0 stale-baseline 0` | `node scripts/check-entry-surface.mjs --baseline tests/gates/baseline/entry-surface.txt` |
| GitHub Actions | `verify.yml` 两个 job。`.github/workflows/ci.yml` 已删 | `git ls-files .github/workflows` |
验收 18 项里最后 6 项是**交付物与语料门禁**(判据在 `scripts/skill-artifacts.mjs`,纯字符串入参、
可单测、带反例):产物齐 / PART A·B·运行规则在 / Layer 0–5 齐且 Layer 0 有规则 /
@@ -84,54 +92,53 @@ node scripts/check_release.mjs && node scripts/prompt-lint.mjs && node scripts/g
产品要的是**一个人的一手产出**:本人著作、演讲与访谈字幕、本人社媒。
会议记录与多人群聊是**混合流水**,必须先有说话人归属才能切出一个人——解析层现在能做到
(WebVTT voice / `Name:` 前缀 / 全角冒号 `说话人 1:` / 句中换人),但归属率取决于源本身:
真实 C-SPAN 字幕 47 分钟里只有 84 处说话人标签,解析层取到 82 条,覆盖率约 6%。
(WebVTT voice / `Name:` 前缀 / 全角冒号 `说话人 1:` / 句中换人),但归属率取决于源本身。
**结论:选对语料比优化解析器重要一个数量级。** 产品现在会在 Step 1.5 拦住错误形状
(`distilly doctor --require-shape`),上面这份 C-SPAN 语料的实测判决是
`FAIL units=2744 speakers=18 attributed=175(3%)`,理由写明"先补这个人自己的一手产出"。
(`distilly doctor --require-shape`)。本树 C-SPAN 语料的实测判决字面值是
`FAIL · units=2744 speakers=18 attributed=175`。不要用 85 或 86。本树没有把 PR-1 merge 进来。
合成访谈字幕是 **38** 条 cue。见 `tests/fixtures/public-corpus/README.md`。
## 6. 分支与 PR 清单
栈停在 `dot-skill-test`,`dot-skill` 保留。程序不改 `dot-skill`。不更新、不 retarget、不合并 #185。
本文件描述的对象是 `dot-skill-test` 上的原子 PR 栈,外加本 PR 以 PR-9 为父提交的工作树。本树**没有**把 PR-1 至 PR-8 merge 进来。A8:不干净且不打回的父 PR 不挡住本条。
| 对象 | 数量 | 状态 |
| --- | --- | --- |
| 本地 `ds/NN-*` 分支 | 23 | 每条都有 `docs/evidence/pr-NN-*.md`(审计第 11 行按分支全量检查) |
| 子功能 PR `#166`–`#184` | 19 | **堆叠**(base 是上一条 `ds/*` 分支);见下方三组状态 |
| 集成分支 PR `#185` | 1 | 目标 `dot-skill`;CI 绿 |
| 集成分支 | `dot-skill-test` | 本文件描述的对象 |
| 原子 PR 栈 PR-0 至 PR-9 | 10 条已发布 | 见下表。总 verdict 多数不干净,A5 不打回 |
| 本 PR-10 | 1 | 父提交 `atomic/pr-9-node-installers` @ `bb6d43110933ff106573ab9a5eb07f95f4d38ce4`(#187) |
| 旧 `ds/NN-*` 快照与 #166 至 #184 | 仍开着 | D4 未执行。证据文档顶部已标明不是本栈 |
| #185 | 1 | 不碰 |
| 默认分支 `dot-skill` | 保留 | 程序任何环节都不改它 |
子功能 PR 的三组状态(2026-09-15 首次真正跑 CI 之后):
已发布的原子 PR(2026-09-22 程序状态表):
| 组 | 分支 | CI | 说明 |
| PR | GitHub | head SHA | 父 |
| --- | --- | --- | --- |
| **A. 可评审** | `ds/13` `ds/15` `ds/16` `ds/17` `ds/18` `ds/19` `ds/20` `ds/21`(8 条) | **全绿**(Node 20 / Node 22 / Acceptance) | 已并入集成分支上的 3 个修复提交(Node 20 的 `npm test`、审计推送行、测试读真实 home / 大小写断言) |
| **B. 一条结构性红** | `ds/12-note`(1 条) | 4 红 2 绿 | 补了同样 3 个修复后仍剩 1 条:该分支的 `check_release` 要求 `docs/v2/IDENTITY.md`,而那是 `ds/15` 才交付的文件 —— 重建快照混了时代 |
| **C. 快照不自洽** | `ds/01`–`ds/11`(10 条) | 30–79 红 | 分支的树与它自带的测试对不上(例:`ds/01` 只有 20 个测试文件,集成分支有 86 个),**不作为合并入口**;功能已包含在 `dot-skill-test` 与后续分支里 |
| PR-0 | #186 | `9c8f1ceb464988f4273b2c5fc4bfc4bbe33af3ec` | `dot-skill-test` @ e16cc8a |
| PR-1 | #188 | `319e8c36633313450ba0b6c2695c55e39b835085` | PR-0 |
| PR-9 | #187 | `bb6d43110933ff106573ab9a5eb07f95f4d38ce4` | PR-0。**本 PR 的父提交** |
| PR-4 | #189 | `cd99139b9d2e2770616c58f899b76bdf75be474c` | 未合进本树 |
| PR-6 | #190 | `bc809f0eba1424625887d700aa395f2f3f595d25` | 未合进本树 |
| PR-5 | #191 | `009415e163357ce57143ac18ddfd1738c6ee4d5f` | 未合进本树 |
| PR-2 | #192 | `8784cac5bd15b47ecc1d9511eee637d77d8baf20` | 未合进本树 |
| PR-3 | #193 | `d05717bbefc2278e1cc55ca74745b359cea9cfdd` | 未合进本树 |
| PR-8 | #194 | `2b8b22fde9bd41fe9e6ab5d854efd2e91b2da4d8` | 未合进本树 |
| PR-7 | #195 | `57ffcbff84e26d74696cf94613e6154e9903b4ca` | 未合进本树 |
原始 7 项任务(`ds/01`–`ds/07`)的交付与验证方式见各自的 `docs/evidence/pr-NN-*.md`。
`docs/evidence/pr-NN-*.md` 描述的是按会话转录重建的 `ds/NN-*` 分支快照,不是本栈。
## 7. 已知缺口(没做到的部分,按严重度)
1. **效果数字不可用 —— 装置已就绪,只差一个"不是蒸馏者的"裁判**。
上一次真实结果(`docs/evidence/pr-10-blind-test-runs.md`):run 2 evidence 命中率 0.563 /
control 0.500,**四臂 0 编造**;只有 10 条 cue / 10 条断言,差异与裁判方差同量级。
2026-09-15 已在**右形状语料**(synthetic-interview,76 单元)上重跑机械部分并交付一个盲测包:
A/B 按时间码切好(28 / 10 条)、实验组页面已定稿(私有模式校验:A 段 24 条原句 **0 条**
出现在页面里,25 个锚点)、对照组裸 prompt 画像已写、裁判题与评分模板齐备。
**剩下的两步必须由另一个模型或人来完成**:裁判(写 10 行「特征 | 预测」)与核对者(唯一读 B 的角色)。
本机只注册了一个模型(`deepseek-official/deepseek-flash`),同一模型既蒸馏又裁判的数字没有意义。
包的位置与确切命令见随包 `README.md`(`/tmp/dst-evidence/blind-2026-09-15/`,本地证据不入库)。
2. **Step 3(Read)没有机械门禁**,全靠提示词自觉;Step 1 / 1.5 / 2 / 4 / 5 有判据。
3. **10 个 `ds/*` PR 的树与它们自带的测试不自洽**(`ds/01`–`ds/11`):CI 30–79 红,
是快照本身不完整(例:`ds/01` 只有 20 个测试文件,集成分支有 86 个)。
其中 9 条已并入集成分支的修复、8 条 CI 转绿、`ds/12` 剩 1 条结构性失败(见第 6 节的分组表);
剩下这 10 条**不打算修**——它们是重建时的不完整快照,功能已包含在集成分支里。
4. **19 个 PR 的 base 结构与流程要求不符**:要求是"PR 到 `dot-skill-test`",实际是堆叠到
上一条 `ds/*` 分支(`ds/01` 的 base 甚至是 `pre-v2-baseline`)。CI 触发名单已扩到
`ds/**` 并推到各分支,所以现在跑得起来;base 结构未改。
1. **本树不是叠好的栈顶(A8)**。计划里「60 条全绿、两个基线只剩注释、entry-surface 0 finding」以叠好的栈为前提。本 PR 只删本 PR 真正变绿的基线行。`audit-tests` 基线仍有 20 行。`entry-surface` 基线仍有 9 行(四条 enumerations、四条 channels、一条 unknown-channel 文档缺口)。CONTRACT 第 25 行渠道名单未改。
2. **仍红的审计行不在本 PR**。留下的包括 R08、R16、R17、R21、R24、R28、R38、R48、R49、R56。它们属于 PR-1 至 PR-8。不要删这些行来凑「只剩注释」。
3. **Step 3(Read)没有机械门禁**,全靠提示词自觉;Step 1 / 1.5 / 2 / 4 / 5 有判据。
4. **效果数字需要不是蒸馏者的裁判**。PR-8 的三模型 Lane 7 与 Lane 8 本拍未作为本树的前置。装置在 `scripts/blind-test.mjs`。
5. **`docs/PRD.md` 与 `docs/SKILL_TYPE_ABSTRACTION_DESIGN*.md` 描述的是 v1(Python)形状**
(`colleagues/{slug}`、`tools/*.py`),未随 v2 重写;两份文件顶部已加说明指向 v2。
6. **`docs/evidence/pr-NN-*.md` 是各功能当时的历史证据**:其中 `pr-21` 与 `pr-13` 的结论
已被后续工作推翻,两份文件末尾都补了更正章节;其余文件的数字仍然成立。
7. **`README.md` 已分区**:本分支(Skill + CLI)在前,`distilly-plugin` 那条 Plugin 线
明确划到「另一条产品线」标题下并注明不在本分支运行。
6. **D4 未执行**。#166 至 #184 一条都不关。证据头不写「已关闭」。
7. **PDF 抽取、HTML axe 与坏链、python3 转发**已从契约挪到 `ROADMAP.md`。`parse-doc` 对 `.pdf` 响亮拒绝,code 为 `office/unsupported-format`。
8. **四个契约渠道 discord / reddit / notion / gmail 在本树不可达**。那是 PR-4 的 Files。本 PR 不删模块,不改 CONTRACT 第 25 行。
+11 -4
View File
@@ -37,12 +37,19 @@ const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8"
{
const tracked = git("ls-files").split("\n");
const python = tracked.filter((path) => /\.py$/.test(path) || /(^|\/)requirements\.txt$/.test(path));
const ci = readFileSync(join(root, ".github", "workflows", "ci.yml"), "utf8");
const pythonCi = /python-version|setup-python|pip install/.test(ci);
const verify = readFileSync(join(root, ".github", "workflows", "verify.yml"), "utf8");
const pythonCi = /python-version|setup-python|pip install/.test(verify);
const listed = JSON.parse(
execFileSync(process.execPath, [join(root, "scripts", "run-gates.mjs"), "--list", "--json"], { cwd: root, encoding: "utf8" }),
);
const ids = listed.map((gate) => gate.id);
const nodeGates = ["unit-tests", "syntax-check"].every((id) => ids.includes(id));
record(
"Node 单栈:仓库里没有 Python / requirements.txt,CI 只跑 Node",
python.length === 0 && !pythonCi,
python.length === 0 ? `0 tracked .py files; CI jobs: ${(ci.match(/^\s{2}[a-z-]+:$/gm) ?? []).map((line) => line.trim()).join(" ")}` : `still tracked: ${python.slice(0, 5).join(", ")}`,
python.length === 0 && !pythonCi && nodeGates,
python.length === 0
? `0 tracked .py files; verify.yml Python steps: ${pythonCi}; run-gates ids include unit-tests/syntax-check: ${nodeGates}`
: `still tracked: ${python.slice(0, 5).join(", ")}`,
);
}
+101 -35
View File
@@ -13,8 +13,9 @@
* tag names the same version the package declares.
*/
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { SCHEMA_VERSION } from "../src/skill/schema.mjs";
@@ -24,24 +25,43 @@ const root = resolve(import.meta.dirname, "..");
const json = process.argv.includes("--json");
const tagIndex = process.argv.indexOf("--tag");
const tag = tagIndex === -1 ? null : process.argv[tagIndex + 1];
const bin = join(root, "bin", "distilly.mjs");
const rows = [];
const record = (name, ok, evidence) => rows.push({ name, ok: Boolean(ok), evidence });
const read = (relative) => readFileSync(join(root, relative), "utf8");
const pkg = JSON.parse(read("package.json"));
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
function parseJson(text) {
const objectStart = text.indexOf("{");
const arrayStart = text.indexOf("[");
const start =
arrayStart >= 0 && (objectStart < 0 || arrayStart < objectStart) ? arrayStart : objectStart;
if (start < 0) throw new SyntaxError("no JSON in command output");
return JSON.parse(text.slice(start));
}
/* 1 — one version, everywhere it is printed ---------------------------------- */
{
const binVersion = execFileSync(process.execPath, [join(root, "bin", "distilly.mjs"), "--version"], { encoding: "utf8" }).trim();
const skill = existsSync(join(root, "SKILL.md")) ? read("SKILL.md") : "";
const skillVersion = /^version:\s*"?([^"\n]+)"?/m.exec(skill)?.[1]?.trim() ?? null;
const consistent = binVersion === pkg.version && (skillVersion === null || skillVersion === pkg.version);
record(
"版本一致(package.json / --version / SKILL.md)",
consistent,
`package.json ${pkg.version}, --version ${binVersion}, SKILL.md ${skillVersion ?? "(未声明)"}`,
);
const binVersion = execFileSync(process.execPath, [bin, "--version"], { encoding: "utf8" }).trim();
const work = mkdtempSync(join(tmpdir(), "rel-skill-"));
try {
execFileSync(
process.execPath,
[bin, "skill", "create", "--character", "colleague", "--name", "ReleaseCheck", "--slug", "release-check", "--base-dir", work, "--json"],
{ encoding: "utf8", cwd: root },
);
const meta = JSON.parse(readFileSync(join(work, "skills", "colleague", "release-check", "meta.json"), "utf8"));
const productVersion = meta.version;
const consistent = binVersion === pkg.version && productVersion !== undefined && String(productVersion).length > 0;
record(
"版本一致(--version 与 skill create 产物)",
consistent,
`package.json ${pkg.version}, --version ${binVersion}, skill create meta.version ${productVersion}`,
);
} finally {
rmSync(work, { recursive: true, force: true });
}
if (tag !== null) {
record("发布 tag 指向同一版本", tag === `v${pkg.version}` || tag === pkg.version, `--tag ${tag} vs ${pkg.version}`);
}
@@ -49,24 +69,64 @@ const pkg = JSON.parse(read("package.json"));
/* 2 — schemas are the frozen ones, and a migration exists -------------------- */
{
const migrate = existsSync(join(root, "src", "skill", "migrate.mjs"));
const migrationTest = read(join("tests", "schema-migration.test.mjs"));
record(
`schema v${SCHEMA_VERSION} 与账本 v${LEDGER_SCHEMA_VERSION},迁移脚本在`,
SCHEMA_VERSION === "4" && migrate && /idempot/i.test(migrationTest),
`SCHEMA_VERSION=${SCHEMA_VERSION}, LEDGER_SCHEMA_VERSION=${LEDGER_SCHEMA_VERSION}, src/skill/migrate.mjs=${migrate}, 幂等断言=${/idempot/i.test(migrationTest)}`,
);
const testRun = spawnSync(process.execPath, ["--test", join(root, "tests", "schema-migration.test.mjs")], {
cwd: root,
encoding: "utf8",
});
const work = mkdtempSync(join(tmpdir(), "rel-migrate-"));
try {
const skillDir = join(work, "skills", "colleague", "old-person");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: 张三\n---\n\n# 张三\n", "utf8");
writeFileSync(join(skillDir, "persona.md"), "# 张三\n", "utf8");
writeFileSync(join(skillDir, "work.md"), "# 工作\n", "utf8");
writeFileSync(
join(skillDir, "meta.json"),
JSON.stringify({ name: "张三", slug: "old-person", character: "colleague", schema_version: "3", lifecycle: { version: "v1" } }, null, 2),
"utf8",
);
writeFileSync(join(skillDir, "manifest.json"), JSON.stringify({ slug: "old-person", schema_version: "3", artifacts: {} }, null, 2), "utf8");
execFileSync(process.execPath, [bin, "skill", "migrate", "--base-dir", work, "--json"], { encoding: "utf8", cwd: root });
const second = parseJson(execFileSync(process.execPath, [bin, "skill", "migrate", "--base-dir", work, "--json"], { encoding: "utf8", cwd: root }));
const third = parseJson(execFileSync(process.execPath, [bin, "skill", "migrate", "--base-dir", work, "--json"], { encoding: "utf8", cwd: root }));
const same = JSON.stringify(second) === JSON.stringify(third);
record(
`schema v${SCHEMA_VERSION} 与账本 v${LEDGER_SCHEMA_VERSION},两次 migrate 回执相同`,
SCHEMA_VERSION === "4" && testRun.status === 0 && same,
`SCHEMA_VERSION=${SCHEMA_VERSION}, LEDGER_SCHEMA_VERSION=${LEDGER_SCHEMA_VERSION}, schema-migration.test status=${testRun.status}, receipts-equal=${same}`,
);
} finally {
rmSync(work, { recursive: true, force: true });
}
}
/* 3 — the installers still carry the evidence spine -------------------------- */
{
const hosts = read(join("src", "install", "hosts.mjs"));
const carried = ["knowledge/raw", "knowledge/text", "evidence", "views"].every((name) => hosts.includes(name));
record(
"安装器携带 evidence spine(knowledge/raw、knowledge/text、evidence、views)",
carried,
carried ? "CARRIED_DIRECTORIES 覆盖四项" : "CARRIED_DIRECTORIES 缺项",
);
const destRoot = mkdtempSync(join(tmpdir(), "rel-install-"));
const dest = join(destRoot, "distilly");
const personRoot = mkdtempSync(join(tmpdir(), "rel-spine-"));
try {
execFileSync(process.execPath, [bin, "install", "--path", dest, "--json"], { encoding: "utf8", cwd: root });
const skillDir = join(personRoot, "skills", "colleague", "old-person");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: x\n---\n", "utf8");
writeFileSync(join(skillDir, "meta.json"), JSON.stringify({ schema_version: "3", slug: "old-person" }), "utf8");
execFileSync(process.execPath, [join(dest, "bin", "distilly.mjs"), "skill", "migrate", "--base-dir", personRoot, "--json"], {
encoding: "utf8",
cwd: root,
});
const four = ["knowledge/raw", "knowledge/text", "evidence", "views"];
const missing = four.filter((name) => !existsSync(join(skillDir, name)));
const listing = readdirSync(skillDir).join(", ");
record(
"安装器携带 evidence spine(knowledge/raw、knowledge/text、evidence、views)",
missing.length === 0,
missing.length === 0 ? `install --path then ls: ${four.join(", ")} under migrated skill; skill dir: ${listing}` : `missing after install --path: ${missing.join(", ")}`,
);
} finally {
rmSync(destRoot, { recursive: true, force: true });
rmSync(personRoot, { recursive: true, force: true });
}
}
/* 4 — the package is zero-dependency and Python-free ------------------------- */
@@ -94,14 +154,21 @@ const pkg = JSON.parse(read("package.json"));
/* 6 — the gates a release claims are runnable -------------------------------- */
{
const gates = ["scripts/acceptance.mjs", "scripts/audit-objective.mjs", "scripts/prompt-lint.mjs", "scripts/visual-check.mjs", "scripts/split-corpus.mjs", "scripts/blind-test.mjs"];
const missing = gates.filter((path) => !existsSync(join(root, path)));
const ci = read(join(".github", "workflows", "ci.yml"));
const wired = ["node --test", "acceptance.mjs", "prompt-lint.mjs", "audit-objective.mjs"].every((needle) => ci.includes(needle));
const listed = parseJson(execFileSync(process.execPath, [join(root, "scripts", "run-gates.mjs"), "--list", "--json"], { encoding: "utf8", cwd: root }));
const ids = listed.map((gate) => gate.id);
const required = [
"unit-tests",
"acceptance-synthetic-interview",
"acceptance-us-house",
"acceptance-multisource",
"prompt-lint",
"objective-audit",
];
const missing = required.filter((id) => !ids.includes(id));
record(
"发布所依赖的门禁都在,且 CI 会跑",
missing.length === 0 && wired,
`missing: ${missing.length === 0 ? "none" : missing.join(", ")}; CI 覆盖 node --test / acceptance / prompt-lint / audit: ${wired}`,
"发布所依赖的门禁都在,且 Verify 会跑",
missing.length === 0,
missing.length === 0 ? `run-gates --list --json ids include ${required.join(", ")}` : `missing gate ids: ${missing.join(", ")}`,
);
}
@@ -121,4 +188,3 @@ if (json) {
console.log(`\n${rows.length - failed.length}/${rows.length} 项通过${failed.length === 0 ? "" : `;未通过:${failed.map((row) => row.name).join(";")}`}`);
}
process.exit(failed.length === 0 ? 0 : 1);
+13 -15
View File
@@ -19,8 +19,8 @@
*
* Names may contain one space (`view check`, `skill create`); dispatch prefers
* the two-token name. Commands promised by `docs/v2/CONTRACT.md` §1 but not yet
* implemented are listed in `PLANNED` together with the branch that owns them,
* so an unfinished build reports "not implemented" instead of "unknown command".
* registered live in `PLANNED`. An empty map means the CLI reports unknown
* command rather than "not implemented". Remaining product work is in ROADMAP.md.
*/
import { CliError } from "../cli/receipt.mjs";
@@ -37,12 +37,10 @@ function registry() {
return REGISTRY;
}
/** Commands frozen in CONTRACT §1 whose implementation ships in another branch. */
/** Commands frozen in CONTRACT §1 that this CLI has not registered. */
export const PLANNED = {
// Contract §1 is fully implemented on this branch (the command surface, the
// eight collection channels, note, view, skill migrate). The map stays because
// `missingCommandError` and `doctor` read it to explain what is *not* here;
// it being empty is the signal that nothing is outstanding.
// Empty: every CONTRACT §1 command this CLI registers is present. Product
// work that is not a registered command lives in ROADMAP.md, not this map.
};
/**
@@ -138,17 +136,17 @@ export function padDisplay(text, width) {
}
const CATALOG_ZH = [
["skill create|update|list|version", "创建 / 更新 / 列出 / 归档 Skill(本分支)"],
["install <host>", "把 Distilly 装进某个宿主的 skills 目录(本分支)"],
["uninstall [<host>|--path]", "卸载已安装的 Distilly(本分支)"],
["doctor", "宿主与 Skill 库存体检(本分支,最小版)"],
["skill create|update|list|version|migrate", "创建 / 更新 / 列出 / 归档 / 迁移 Skill"],
["install <host>", "把 Distilly 装进某个宿主的 skills 目录"],
["uninstall [<host>|--path]", "卸载已安装的 Distilly"],
["doctor", "宿主与 Skill 库存体检"],
];
const CATALOG_EN = [
["skill create|update|list|version", "create / update / list / archive Skills (this branch)"],
["install <host>", "install Distilly into a host skills directory (this branch)"],
["uninstall [<host>|--path]", "remove an installed Distilly (this branch)"],
["doctor", "host and skill inventory health check (this branch, minimal)"],
["skill create|update|list|version|migrate", "create / update / list / archive / migrate Skills"],
["install <host>", "install Distilly into a host skills directory"],
["uninstall [<host>|--path]", "remove an installed Distilly"],
["doctor", "host and skill inventory health check"],
];
/** Bilingual help for the whole CLI (CONTRACT §6: 中文 → `---` → English). */
+1 -1
View File
@@ -52,7 +52,7 @@ function parseArgs(argv) {
}
register("parse-archive", {
summary: "读取 docx/xlsx / read OOXML documents into knowledge/",
summary: "读取归档 / read X Takeout Discord Telegram archives into knowledge/",
usage: "distilly parse-archive <file...> --person <slug> [--base-dir <dir>] [--source <label>] [--json]",
...help,
run({ argv, json, reporter }) {
+22
View File
@@ -73,6 +73,28 @@ register("parse-doc", {
const { options, paths } = parsed;
if (!options.person) return fail("--person is required", "parse-doc/usage");
const pdf = paths.find((target) => resolve(target).toLowerCase().endsWith(".pdf"));
if (pdf) {
return {
receipt: {
command: "parse-doc",
person: options.person,
ok: false,
inputs: [],
outputs: [],
anchors: { total: 0, cited: 0 },
warnings: [],
unavailable: [],
error: {
code: "office/unsupported-format",
message: `${pdf}: PDF is not supported`,
remedy: "extract the text and register it with `distilly note --from <file|->`",
},
},
exitCode: 2,
};
}
const personDir = resolve(options.baseDir, "skills", "colleague", options.person);
const store = new KnowledgeStore(personDir);
const ledger = loadLedger(store);
+5
View File
@@ -137,6 +137,11 @@ function paragraphRecords(paragraphs, kind = "paragraph") {
/** Dispatch on the file name; anything else is refused by name. */
export function parseOffice(file, options = {}) {
const name = file.name.toLowerCase();
if (name.endsWith(".pdf")) {
const error = new Error(`${file.label}: PDF is not supported; register extracted text with note --from`);
error.code = "office/unsupported-format";
throw error;
}
if (name.endsWith(".docx")) return parseDocx(file, options);
if (name.endsWith(".xlsx")) return parseXlsx(file, options);
throw new Error(`${file.label}: not an OOXML document (.docx/.xlsx)`);
+20 -6
View File
@@ -15,7 +15,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { distilly, fixtures, readJson, receipt, repoRoot, workspace } from "./helpers.mjs";
@@ -185,11 +185,25 @@ test("R10 an unknown command exits 1 with code unknown-command and a remedy", ()
test("R23 `parse-doc` advertises pdf alongside docx and xlsx (CONTRACT §1 line 22)", () => {
const ws = workspace();
const run = distilly(["parse-doc", "--help"], { cwd: ws.root, env: { HOME: ws.home } });
assert.equal(run.status, 0);
assert.match(run.stdout, /docx/);
assert.match(run.stdout, /xlsx/);
assert.match(run.stdout, /pdf/i, "CONTRACT §1 promises `parse-doc <x.docx|xlsx|pdf>`");
const helpRun = distilly(["parse-doc", "--help"], { cwd: ws.root, env: { HOME: ws.home } });
assert.equal(helpRun.status, 0);
assert.match(helpRun.stdout, /docx/);
assert.match(helpRun.stdout, /xlsx/);
assert.equal(/pdf/i.test(helpRun.stdout), false, "parse-doc --help must not promise pdf");
const pdf = join(ws.root, "fake.pdf");
writeFileSync(pdf, "%PDF-1.4\n");
const before = new Set(readdirSync(ws.root, { recursive: true }));
const run = distilly(["parse-doc", pdf, "--person", "p", "--base-dir", ws.root, "--json"], {
cwd: ws.root,
env: { HOME: ws.home, DISTILLY_HOME: ws.distillyHome },
});
assert.equal(run.status, 2, run.stderr);
const body = receipt(run);
assert.equal(body.error?.code, "office/unsupported-format");
assert.match(String(body.error?.remedy ?? ""), /note --from/);
const after = new Set(readdirSync(ws.root, { recursive: true }));
assert.deepEqual([...after].sort(), [...before].sort());
});
test("R12/R58 zero runtime dependencies, engines.node >= 20, and no Python in the tree", () => {
+5 -1
View File
@@ -17,8 +17,12 @@ public-corpus/
## 当前夹具
数字来自本树对文件的计数。`transcript.srt` 里单独一行的十进制序号当作一条 cue。
| 目录 | 内容 | 许可 |
| --- | --- | --- |
| `synthetic-interview/` | 我们自写的合成访谈(后端工程师 × 技术面试官,45 条字幕,含时间跨度、语气变化、一处回避话题) | 本项目原创,随仓库以 MIT 发布;**不是真人对话**,仅用于工程验收 |
| `synthetic-interview/` | 合成访谈(后端工程师 × 技术面试官)。`transcript.srt` **38** 条 cue。含时间跨度、语气变化、一处回避话题。 | 本项目原创,随仓库以 MIT 发布;**不是真人对话**,仅用于工程验收 |
| `synthetic-multisource/` | 三来源合成材料:飞书页、Slack 消息、事后文档 | 本项目原创,随仓库以 MIT 发布 |
| `us-house-floor-2009-07-29/` | 真实 C-SPAN 众议院发言字幕。`transcript.srt` **1346** 条 cue | 见同目录 `LICENSE.md` |
> 合成语料能验证"流程正确"(锚点、字节守恒、确定性、渲染、溢出),但**不能**验证"蒸出来的角色讨不讨喜"——那需要真实素材,见 `docs/v2/ACCEPTANCE.md`。
-1
View File
@@ -5,7 +5,6 @@ R08 `collect reddit --json` is a known channel (CONTRACT §1 line 25), so its fa
R08 `collect notion --json` is a known channel (CONTRACT §1 line 25), so its failure is about credentials, not about the channel name
R08 `collect gmail --json` is a known channel (CONTRACT §1 line 25), so its failure is about credentials, not about the channel name
R08 every collection module under src/collect/ is reachable from bin/distilly.mjs (module present ≠ command wired)
R23 `parse-doc` advertises pdf alongside docx and xlsx (CONTRACT §1 line 22)
R08 `collect feishu --mode browser` (SKILL.md 迁移对照表) reaches the browser route and asks for consent
R08 `collect feishu --mode mcp` (SKILL.md 迁移对照表: feishu_mcp_client.py → collect feishu) reaches the MCP route
R08 a contract channel that is not shipped is reported as planned (PR \#185 正文: collect/planned-channel), never as an unknown channel
-5
View File
@@ -1,15 +1,10 @@
# Known entry-surface findings. Remove a line when its PR closes the gap. Never add one without a PR that explains it.
commands: +skill migrate
enumerations: -collect: discord
enumerations: -collect: reddit
enumerations: -collect: notion
enumerations: -collect: gmail
enumerations: +consent: verify
enumerations: +consent: prune
enumerations: -parse-doc: pdf
channels: -collect discord: collect/unknown-channel
channels: -collect reddit: collect/unknown-channel
channels: -collect notion: collect/unknown-channel
channels: -collect gmail: collect/unknown-channel
error-codes: -collect/planned-channel documented but no source emits it
error-codes: +collect/unknown-channel observed at runtime but documented nowhere
+21 -9
View File
@@ -17,6 +17,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
@@ -133,7 +134,7 @@ test("these fixtures never write through to the repository manifest", () => {
assert.equal(readFileSync(manifestPath, "utf8"), before, "the repository manifest must be untouched");
});
test("`npm test` and CI run the same command, and every path it names exists", () => {
test("`npm test` and Verify run the same unit gate, and every path it names exists", () => {
// A bare `node --test` also collects `scripts/blind-test.mjs` (`**/*-test.mjs`)
// and records it as a passing "test" — which is how CI went red while every local
// command looked green.
@@ -148,8 +149,19 @@ test("`npm test` and CI run the same command, and every path it names exists", (
// Node 20 receives real paths (as it requires), while Node 22 — which treats a
// positional argument as a glob of its own and refuses a bare directory with
// `Cannot find module '…/tests'` — receives paths it can also handle.
const ci = readFileSync(path.join(root, ".github", "workflows", "ci.yml"), "utf8");
assert.match(ci, /^\s*run: npm test\s*$/m, "CI must invoke `npm test`");
assert.equal(existsSync(path.join(root, ".github", "workflows", "ci.yml")), false, "ci.yml must be gone");
const verify = readFileSync(path.join(root, ".github", "workflows", "verify.yml"), "utf8");
assert.match(verify, /node scripts\/run-gates\.mjs --without playwright/, "Verify must invoke run-gates --without playwright");
const listed = spawnSync(process.execPath, [path.join(root, "scripts", "run-gates.mjs"), "--list", "--json"], {
cwd: root,
encoding: "utf8",
});
assert.equal(listed.status, 0, listed.stderr);
const ids = JSON.parse(listed.stdout).map((gate) => gate.id);
for (const id of ["unit-tests", "acceptance-synthetic-interview", "acceptance-us-house", "acceptance-multisource", "prompt-lint", "objective-audit"]) {
assert.ok(ids.includes(id), `run-gates --list --json must include ${id}`);
}
const argv = manifest.scripts.test.split(/\s+/);
assert.equal(argv[0], "node");
@@ -165,20 +177,20 @@ test("`npm test` and CI run the same command, and every path it names exists", (
}
});
test("`engines` matches what CI actually tests", () => {
const ci = readFileSync(path.join(root, ".github", "workflows", "ci.yml"), "utf8");
const matrix = /node-version:\s*\[([^\]]+)\]/.exec(ci)?.[1] ?? "";
test("`engines` matches what Verify actually tests", () => {
const verify = readFileSync(path.join(root, ".github", "workflows", "verify.yml"), "utf8");
const matrix = /node:\s*\[([^\]]+)\]/.exec(verify)?.[1] ?? "";
const versions = matrix.split(",").map((entry) => entry.trim().replace(/"/g, ""));
const minimum = Number(manifest.engines.node.replace(/[^\d]/g, ""));
assert.ok(versions.length > 0, "CI must declare a node-version matrix");
assert.ok(versions.length > 0, "Verify must declare a node matrix");
for (const version of versions) {
assert.ok(
Number(version) >= minimum,
`CI tests Node ${version} but the package claims to need >= ${minimum}`,
`Verify tests Node ${version} but the package claims to need >= ${minimum}`,
);
}
assert.ok(
versions.includes(String(minimum)),
`the oldest supported Node (${minimum}) is not in the CI matrix, so the floor is untested`,
`the oldest supported Node (${minimum}) is not in the Verify matrix, so the floor is untested`,
);
});
+10
View File
@@ -35,3 +35,13 @@ test("a mismatched release tag fails the run", () => {
assert.equal(tagRow.ok, false);
assert.match(tagRow.evidence, /v9\.9\.9/);
});
test("release check criteria are executable, not text-grep", () => {
const result = spawnSync(process.execPath, [join(HERE, "scripts", "check-entry-surface.mjs")], {
cwd: HERE,
encoding: "utf8",
});
const block = result.stdout.slice(result.stdout.indexOf("release check criteria"));
assert.ok(block.length > 0, "entry-surface must print release check criteria");
assert.equal(/text-grep/.test(block), false, block);
});