Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
name: CI

on:
# The per-feature PRs are a stack whose bases are other `ds/*` branches; with the
# trigger list limited to the integration branches, none of them could ever run CI.
push:
branches: [dot-skill-test, dot-skill, main]
branches: [dot-skill-test, dot-skill, main, 'ds/**']
pull_request:
branches: [dot-skill-test, dot-skill, main]
branches: [dot-skill-test, dot-skill, main, 'ds/**']
workflow_dispatch:

jobs:
test:
Expand Down
54 changes: 54 additions & 0 deletions docs/evidence/pr-15-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# PR-15 · 跨渠道身份映射(`identity.json`)

- 分支:`ds/15-identity`(本地,合并进 `dot-skill-test`)
- 交付:`src/knowledge/identity.mjs`、`src/knowledge/ledger.mjs`(记录时套用 + 账本留痕)、
`src/commands/harvest.mjs`(`--identity`)、`docs/v2/IDENTITY.md`、`tests/identity.test.mjs`
- 依赖:无。与 PR-11(归一化正文带说话人)配合:那条让派生层知道"谁在说",这条让它知道
"不同句柄是同一个人"。

## 1. 为什么

多来源盲测的实验组蒸馏器只能**按渠道分别描述**同一个人:Slack 里是 `林工`,飞书里是 `ou_lin`,
语料没有任何一句说这两者是同一人。派生层因此报出 6 个参与者、把风格统计拆开,
`relationship` 也只能写成"显示名渠道 vs `ou_` 渠道"。裁判据此把若干条断言判为"跨渠道不可验证"。

## 2. 改动前后(同一份多来源语料)

```bash
distilly harvest tests/fixtures/public-corpus/synthetic-multisource \
--person lin-gong --identity ./identity.json
distilly retrospect --person lin-gong
```

| 指标 | 无映射 | 有映射(林工←ou_lin、小明←ou_chen) |
| --- | --- | --- |
| `knowledge/text/*.md` | `[k0001] … ou_lin:评审前我把风险清单发群里…` | `[k0001] … 林工:评审前我把风险清单发群里…` |
| `stats.participants` | `["林工","ou_lin","小明","老周","ou_chen","ou_zhou"]` | `["林工","小明","老周","ou_zhou"]` |
| `voice.sentence_length.by_speaker` | 6 个"说话人" | 4 个(真人的统计不再被拆开) |
| 账本条目 | 无身份信息 | `identity: {file:"identity.json", handles:["ou_chen","ou_lin"], turns:16}` |

## 3. 纪律

- **句柄唯一**:同一句柄被两人声明 → exit 2 且**零写入**;非法 JSON 同样响亮失败。
- **记录时生效**:规范化在锚定之前,一个 turn 仍是"一个锚点、一个前缀、一行统计";
锚点文本仍是源字节的逐字切片(不变式未破)。
- **不做推断**:只合并显式声明的句柄;"措辞像同一个人"不构成合并理由。
- 无映射时行为与以前完全一致。

## 4. 怎么验

```bash
node --test tests/identity.test.mjs # 5 条
node --test tests/*.test.mjs # 354 pass / 0 fail
node scripts/audit-objective.mjs --skip-acceptance # 16/16
```

`tests/identity.test.mjs` 覆盖:句柄在正文与账本里被归一(且原始句柄不残留)、
派生层参与者与 `by_speaker` 合并、无映射时不改任何东西、句柄冲突/非法 JSON 零写入、
纯函数行为(不改输入、match 列表、无文件时的空映射)。

## 5. 已知缺口

- 映射按句柄字符串精确匹配(大小写敏感);`U02` 这类平台 id 需要写进映射或用导出里的显示名。
- 通讯录 API 自动取名未实现(飞书开放平台页只有 `sender.id`)。
- 映射文件本身没有锚点(它不进 `knowledge/`);它的存在通过账本条目的 `identity` 字段留痕。
43 changes: 43 additions & 0 deletions docs/v2/IDENTITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 跨渠道身份映射(`identity.json`)

同一个人在每个渠道签名不同:一个导出里叫 `林工`,另一个里是 `ou_lin`,第三个里是 `U02`。
派生层只读 `knowledge/text/*.md`,所以句柄不合并时它会报出多个参与者,并把这个人的统计
拆到几个人身上——多来源盲测因此只能"按渠道分别描述"(见 `docs/evidence/pr-14-multisource.md`)。

## 文件与命令

Skill 根目录(`skills/<family>/<slug>/identity.json`)放一份映射:

```json
{
"people": [
{ "name": "林工", "handles": ["ou_lin", "U02"], "note": "Slack 显示名 = 飞书 open_id" }
]
}
```

安装与使用:

```bash
distilly harvest <dir|file...> --person lin-gong --identity ./identity.json
```

`--identity` 会把映射装到 Skill 根目录;之后的每次采集/解析都自动读取它。

## 纪律

- **句柄唯一**:一个句柄被两个人声明是**错误**(exit 2),不掷骰子;非法 JSON 同样响亮失败,
且不写任何文件。
- **记录时生效**:规范化发生在锚定之前,所以一个 turn 仍是**一个锚点、一个前缀、一行统计**;
锚点文本仍是源字节的逐字切片。
- **回执可查**:被合并的条目在账本里带
`identity: { file, handles: [...], turns: N }`,读者能看出 `ou_lin` 与 `林工` 是同一个人。
- **不做推断**:映射只写你明确声明的关系。语料里"看起来像同一个人"(同样的措辞、同样的立场)
不会自动合并——多来源语料里那三份文件正是这种情形。

## 已知边界

- 映射按**句柄字符串**精确匹配(去首尾空白,大小写敏感)。
- 没有映射时行为与以前完全一致(不回退、不猜测)。
- 通讯录 API 自动取名的路径未实现:飞书开放平台页只带 `sender.id`,名字要么进映射,
要么老实保留 id。
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"CITATION.cff"
],
"scripts": {
"test": "node --test \"tests/*.test.mjs\"",
"test": "node --test tests/*.test.mjs",
"prepack": "node bin/distilly.mjs --check-package"
},
"keywords": [
Expand Down
9 changes: 8 additions & 1 deletion scripts/acceptance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,17 @@ try {
// 4. visual-check(八项)
const vcScript = path.join(root, 'scripts/visual-check.mjs');
const vc = spawnSync('node', [vcScript, htmlPath, '--out', evidenceDir], { cwd: workdir, encoding: 'utf8' });
// The hint that playwright is missing goes to **stderr**, so a row that quoted
// only stdout went red with an empty reason — a gate nobody can act on.
const vcDetail =
(vc.stdout ?? '').trim().split('\n').slice(-3).join(' / ') ||
(vc.stderr ?? '').trim().split('\n').slice(-2).join(' / ');
if (/Cannot find module|ENOENT/.test(vc.stderr ?? '')) {
record('visual-check 可用', false, 'scripts/visual-check.mjs 尚不存在(ds/03-render 的产出)');
} else if (/DISTILLY_PLAYWRIGHT_ROOT/.test(vc.stderr ?? '')) {
record('visual-check 可用', false, '未提供 playwright:设 DISTILLY_PLAYWRIGHT_ROOT=<含 node_modules 的目录>');
} else {
record('visual-check 八项通过', vc.status === 0, (vc.stdout ?? '').trim().split('\n').slice(-3).join(' / '));
record('visual-check 八项通过', vc.status === 0, vcDetail);
}
} catch (error) {
record('验收流程未中断', false, String(error.message).split('\n')[0]);
Expand Down
53 changes: 42 additions & 11 deletions scripts/audit-objective.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ const record = (demand, ok, evidence, { gap = false } = {}) => {
};

const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
/**
* `git rev-parse --verify --quiet <ref>` → the ref's sha, or `null` when the ref
* does not exist.
*
* `git()` cannot be used for an existence probe: it throws on a non-zero exit, and
* a missing ref exits 1 with empty output. That turned the push row — the row whose
* whole job is to report "this branch is on no remote" — into a crash of the entire
* audit inside CI, where the checkout has no `origin/dot-skill-test` tracking ref.
*/
const gitRef = (ref) => {
try {
return execFileSync("git", ["rev-parse", "--verify", "--quiet", ref], { cwd: root, encoding: "utf8" }).trim();
} catch {
return null;
}
};

/** 1. Node single stack: no Python left anywhere in the tree. */
{
Expand Down Expand Up @@ -219,10 +235,16 @@ const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8"
if (skipAcceptance) {
record("端到端验收(本审计已跳过)", true, "--skip-acceptance was passed", { gap: true });
} else {
// `DISTILLY_PLAYWRIGHT_ROOT` is **passed through, never invented**. It used to
// default to `/tmp/audit-mcp`, which made this audit's result depend on a
// directory in a volatile temp path: on any other machine — or on this one after
// a reboot — the end-to-end row went red for a reason nothing stated. A caller
// that has playwright sets the variable (see docs/evidence/pr-03-render.md); a
// caller that does not gets a named, actionable failure from acceptance itself.
const output = execFileSync(process.execPath, [join(root, "scripts", "acceptance.mjs")], {
cwd: root,
encoding: "utf8",
env: { ...process.env, DISTILLY_PLAYWRIGHT_ROOT: process.env.DISTILLY_PLAYWRIGHT_ROOT ?? "/tmp/audit-mcp" },
env: { ...process.env },
});
const summary = output.trim().split("\n").pop() ?? "";
const match = /(\d+)\/(\d+)\s*通过/.exec(summary);
Expand All @@ -236,22 +258,31 @@ if (skipAcceptance) {
// `upstream`. Resolve whichever remote actually has the branch, and say so when
// none does, instead of throwing on a hardcoded name.
const remotes = git("remote").split("\n").map((line) => line.trim()).filter(Boolean);
const tracking = remotes
.map((remote) => `${remote}/dot-skill-test`)
.find((ref) => git("rev-parse", "--verify", "--quiet", ref) !== "");
const tracking = remotes.map((remote) => `${remote}/dot-skill-test`).find((ref) => gitRef(ref) !== null);
const ahead = tracking === undefined ? null : Number(git("rev-list", "--count", `${tracking}..HEAD`));
const dirty = git("status", "--porcelain");
const dirty = git("status", "--porcelain").split("\n").filter(Boolean);

// "Pushed" is the thing the user asked for (an off-site copy). A PR is a
// separate, still-unrequested step, so it is reported rather than required.
const pushed = ahead === 0;
// A dirty tree is counted as **the same risk** as an unpushed commit — the title
// has always claimed it, and uncommitted work is the more volatile of the two —
// so the check now enforces what the title says instead of merely printing it.
const pushed = ahead === 0 && dirty.length === 0;

// In CI the demand cannot apply: the checkout *came from* the remote, and there
// is no tracking ref for the branch to measure against. Recording that as a gap
// keeps it visible; pretending to have verified it would be the silent pass this
// audit exists to prevent.
const inCi = process.env.GITHUB_ACTIONS === "true";
record(
"推送:集成分支已推送到远端(异地备份),工作树干净",
pushed,
tracking === undefined
? "no remote carries dot-skill-test; every commit exists only on this machine"
: `${tracking}: ${ahead} commit(s) ahead; working tree ${dirty === "" ? "clean" : "dirty"}; PR bodies staged in dst-evidence/PR-BODIES/`,
{ gap: !pushed },
inCi ? true : pushed,
inCi
? `CI checkout: ${remotes.length} remote(s) configured, no local record of the branch, so "off-site" is this run's own source — not verified here`
: tracking === undefined
? "no remote carries dot-skill-test; every commit exists only on this machine"
: `${tracking}: ${ahead} commit(s) ahead; working tree ${dirty.length === 0 ? "clean" : `${dirty.length} path(s) dirty`}; PR bodies staged in dst-evidence/PR-BODIES/`,
{ gap: inCi || !pushed },
);
}

Expand Down
8 changes: 7 additions & 1 deletion src/collect/slack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,15 @@ export function distillyHome(env = process.env) {
}

export function credentialPaths(env = process.env) {
// `env.HOME ?? homedir()`, matching `kit.mjs`: the pre-rename
// `~/.colleague-skill/<file>` fallback has to move when a caller points `HOME`
// somewhere else. Reading the real home here ignored `env.HOME` — so an isolated
// run (a test, a sandboxed collect) could still pick up the credential sitting in
// the developer's own home, and the two channels disagreed about which file they
// had just read.
return {
primary: join(distillyHome(env), CONFIG_FILE),
legacy: join(homedir(), LEGACY_CONFIG_FILE),
legacy: join(env?.HOME ?? homedir(), LEGACY_CONFIG_FILE),
};
}

Expand Down
35 changes: 33 additions & 2 deletions tests/collect.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,26 @@ function sandbox(name = "case") {
};
}

/**
* Every file written under `dir`, relative and sorted — for failure messages.
*
* A failing "the page was not written" assertion used to say only `false !== true`,
* which is unactionable from a CI log: it cannot distinguish "nothing was written"
* from "something was written somewhere else".
*/
function written(dir) {
const out = [];
const walk = (current, prefix) => {
for (const entry of readdirSync(current, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) walk(join(current, entry.name), rel);
else out.push(rel);
}
};
if (existsSync(dir)) walk(dir, "");
return out.length === 0 ? "(nothing)" : out.sort().join(", ");
}

function json(body, { status = 200, headers = {} } = {}) {
return new Response(JSON.stringify(body), { status, headers });
}
Expand Down Expand Up @@ -548,10 +568,21 @@ test("CLI success path: a fake key never reaches stdout, stderr or the receipt",
assert.ok(!run.stdout.includes(SECRET.slack), "stdout leaked the credential");
assert.ok(!run.stderr.includes(SECRET.slack), "stderr leaked the credential");
const receipt = receiptOf(run.stdout);
assert.equal(receipt.ok, true);
// The messages name what was seen: a bare `false !== true` on either of these made
// a CI-only failure impossible to diagnose from the log alone.
assert.equal(receipt.ok, true, `receipt was not ok: ${JSON.stringify(receipt)}`);
assert.equal(receipt.credential_file, "slack_config.json");
assert.ok(!JSON.stringify(receipt).includes(SECRET.slack));
assert.equal(box.exists("knowledge/raw/slack/C0123-p001.json"), true);
assert.equal(
box.exists("knowledge/raw/slack/c0123-p001.json"),
true,
// The channel id is `C0123` and the **file name is slugged to `c0123`**
// (`writeRaw` runs the name through `slug()`), while the receipt keeps
// `channel_id: "C0123"`. This assertion used to read `C0123-p001.json` and
// passed on macOS only because APFS compares names case-insensitively; on the
// Linux runner it failed while the file sat right there in the listing.
`the page was not written; receipt=${JSON.stringify(receipt)} stderr=${run.stderr} tree=${written(box.work)}`,
);
});

test("CLI failure path: a rejected key leaks nothing and names where to reconfigure", () => {
Expand Down
23 changes: 21 additions & 2 deletions tests/feishu-mcp.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,22 @@ function sandbox() {
const root = mkdtempSync(join(tmpdir(), "dst-mcp-"));
const home = join(root, "distilly");
mkdirSync(home, { recursive: true });
return { root, home, work: join(root, "work"), config: { app_id: "cli_x", app_secret: SECRET } };
const config = { app_id: "cli_x", app_secret: SECRET };
// An **isolated home with the credential written into it**, plus the env that
// points at it. Without this the CLI-routing tests below read the developer's
// real home: `credentialPaths` falls back to `~/.colleague-skill/<file>`, this
// machine still has that pre-rename directory, and the run found a credential
// there. Locally green, and red in CI, where the file does not exist — the exact
// "a test that depends on whose laptop it runs on" failure this file already
// warned about in its missing-credential test.
writeFileSync(join(home, "feishu_config.json"), JSON.stringify(config, null, 2));
return {
root,
home,
work: join(root, "work"),
config,
env: { DISTILLY_HOME: home, HOME: home },
};
}

/** An MCP transport that answers from a script and records every call. */
Expand Down Expand Up @@ -200,13 +215,17 @@ test("cli: --mode mcp routes to the MCP client and needs a target", async () =>

const box = sandbox();
const transport = fakeTransport({ result: [{ type: "text", text: JSON.stringify({ items: textMessages(3) }) }] });
const result = await runCollectCli(["--mode", "mcp", "--chat-id", "oc_demo", "--person", "demo", "--base-dir", box.work, "--json"], { transport });
const result = await runCollectCli(["--mode", "mcp", "--chat-id", "oc_demo", "--person", "demo", "--base-dir", box.work, "--json"], {
transport,
env: box.env,
});
assert.equal(result.ok, true, JSON.stringify(result.receipt));
assert.equal(transport.calls.length, 1);
assert.deepEqual(transport.calls[0].args, { chat_id: "oc_demo", page_size: 50 });
const lines = [];
const human = await runCollectCli(["--mode", "mcp", "--chat-id", "oc_demo", "--person", "demo", "--base-dir", box.work], {
transport,
env: box.env,
stdout: (line) => lines.push(line),
stderr: () => {},
});
Expand Down
Loading
Loading