From 2c5c3be8f9f2395731e762b0b90379008176c2da Mon Sep 17 00:00:00 2001 From: andershsueh Date: Sat, 15 Aug 2026 01:25:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?!8=20feat(skills):=20karpathy-wiki-ingest?= =?UTF-8?q?=20=E6=88=90=E4=B8=BA=20bundled=20skill=20(IK8MWT=20#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 builtin skill:把 raw/ 编译进 wiki/ 的确定性执行器。 - SKILL.md:7 步 Ingest Checklist(scan → design → scaffold → build → lint → log → 回报), 逐份/批量两节奏,失败处理表(源类型判不了→问用户、raw 有错→注记不改源、冲突→ > 矛盾 注记), 三条禁止(不改 raw、不堆叠旧结论、不造孤岛页) - ingest.ts:scanRaw(只读,扫 raw + 对照 wiki/log.md,pending/ingested/orphans 三类) + appendLog(type 白名单校验、append-only、缺 log.md 自动补最小骨架),带 CLI 入口 - postbuild 已自动拷贝 SKILL.md 与 ingest.ts 到 dist/skills/bundled/karpathy-wiki-ingest - test-case/test-issue-020.ts:9 个用例 / 51 个断言,含 SKILL.md 契约 + scanRaw 三分类 + appendLog 幂等 + dist 打包 - test-case/test-list.md 同步登记 + 回归基线 168 → 219 断言 全量回归 219/219 绿。 Co-Authored-By: Claude --- .../bundled/karpathy-wiki-ingest/SKILL.md | 151 +++++++ .../bundled/karpathy-wiki-ingest/ingest.ts | 304 +++++++++++++ test-case/test-issue-020.ts | 399 ++++++++++++++++++ test-case/test-list.md | 6 +- 4 files changed, 858 insertions(+), 2 deletions(-) create mode 100644 src/skills/bundled/karpathy-wiki-ingest/SKILL.md create mode 100644 src/skills/bundled/karpathy-wiki-ingest/ingest.ts create mode 100644 test-case/test-issue-020.ts diff --git a/src/skills/bundled/karpathy-wiki-ingest/SKILL.md b/src/skills/bundled/karpathy-wiki-ingest/SKILL.md new file mode 100644 index 0000000..ec5a649 --- /dev/null +++ b/src/skills/bundled/karpathy-wiki-ingest/SKILL.md @@ -0,0 +1,151 @@ +--- +name: karpathy-wiki-ingest +description: 把 raw/ 里的新源编译进 wiki/(IK8MWT #20)。自带 scanRaw 扫源 + appendLog 登 log + 7 步 Ingest checklist;逐份(推荐)或批量两种节奏;失败有明确逃生舱。**raw/ 一个字都不改**。当用户说「消化一下这份资料」「把 raw 录进 wiki」「编译 raw」「wiki ingest」时触发。兄弟技能 karpathy-wiki-new / karpathy-wiki-lint 配套使用。 +version: 1.0.0 +--- + +# karpathy-wiki-ingest + +## 角色 + +你是 Karpathy Wiki 知识库的 **Ingest 执行器**。把 `raw/` 里新落进来的源文件编译进 `wiki/` 的主题页与 `wiki/log.md`,**只读 raw,绝不写 raw**。配套技能:`karpathy-wiki-new`(建库)→ 本技能(进料)→ `karpathy-wiki-lint`(体检)。 + +## 触发条件 + +- 用户输入 `/karpathy-wiki-ingest` +- 用户说「消化一下这份资料」「把 raw 录进 wiki」「编译 raw」「wiki ingest」「新源入仓」 +- 用户在对话里粘了一大段素材(等价于 ingest,先落 `raw/` 再走流程) + +## 工作流(严格按顺序) + +### Step 0:确认当前是 Karpathy Wiki 库 + +跑一下 `scanRaw(root)`(`scanRaw` 是本 skill 自带的确定性执行器,见下)—— +它会告诉你: + +- `raw/` 是否存在(不存在 → 停下来问用户) +- 当前 `wiki/log.md` 里 `## [YYYY-MM-DD] ingest |` 条目里都登记过哪些源 +- 哪些源还没被摄取(新源候选)、哪些已经被摄取过(再次跑会跳过) + +`scanRaw` 不会动文件,可以反复跑。 + +### Step 1:确定 Ingest 节奏(待摄取源 > 1 时) + +```text +逐份(推荐) 每份源落盘前先给 3-5 句 key takeaways + 计划触及的页面清单,等用户点头再写 +批量 按 7 步 checklist 一份一份跑完,最后一次性汇报改了哪些页 +``` + +Karpathy 原文首选做法是人在环路里: *"Personally I prefer to ingest sources one at a time and stay involved."* — 默认走**逐份**,只有用户明确说「批量跑」才切批量。问完把这个偏好写进 `CLAUDE.md` 的「⚙️ 操作中要注意」(参考 wiki/工作流-ingest-query-lint.md §一.开工前),之后照此执行、不再每次重问。 + +## 7 步 Ingest Checklist(每份源按顺序跑一遍) + +每步都有产物/判断点;跳过的每一步最后都会变成断链、孤岛页或一句查无出处的结论。 + +- [ ] **1. scan** —— 跑 `scanRaw(root)` 拿摄取状态;确认这份源在「未摄取」清单里 +- [ ] **2. design** —— 决定影响范围(同 wiki/工作流-ingest-query-lint.md §一.Step 3 的决策树): + - 新概念/新实体/新专题 → 新建主题页 + - 已有主题的补充、修订、反例 → 更新对应主题页 + - 只是佐证已有结论,没有新信息 → 在 `INDEX.md` 来源清单登记 + - 引出了没人能答的问题 → 新建 `待澄清-<主题>.md` +- [ ] **3. scaffold** —— 若是新建页:必备 `## 摘要` + 日期戳 `> 最后更新:YYYY-MM-DD` + 至少 3 条相关互链;若是更新页:直接改写正文与 `## 摘要`,被推翻的旧说法压成 `## 修订记录` 一行或交给 git +- [ ] **4. build** —— 写或改主题页;**引用要落到具体位置**(源文件 + 章节 / 页码 / 行号区间);同时更新 `INDEX.md`(新主题进主题清单、新源进来源清单、关键事实速查同步) +- [ ] **5. lint**(自检) —— 5 项与 karpathy-wiki-lint 同口径:① 摘要 ② 日期戳 ③ 出链 ≥ `MIN_LINKS`(默认 3,不足只提示) ④ `[[目标]]` 真实存在(跨域 `raw/`/`outputs/`/`../CLAUDE.md` 除外) ⑤ 新页至少被 `INDEX.md` 或某主题页链到 +- [ ] **6. log** —— 跑 `appendLog(root, 'ingest', '<源简述>')` 追加 `## [YYYY-MM-DD] ingest | <源文件简述>`;再跑 `appendLog(root, 'wiki', '<变动摘要>')` 追加 wiki 变更条目;正文至少 3 行(源路径 + 类型 + 关键事实 + 触及的页面名) +- [ ] **7. 回报** —— 一份源完结后向用户简短汇报:摄取了几份、触及/新建了哪几页、还有什么遗留问题 + +`appendLog` 是幂等的:同一份源、同一天、同一标题重复登记时,**会原样追加**(append-only),不修改历史条目。`scanRaw` 也不动文件、可以反复跑。 + +### Step 3:失败处理(表) + +| 情况 | 处理 | +|------|------| +| 源类型判断不了(文章/论文/对话/…) | 停下问用户,不猜测 | +| 找不到合适的主题归属 | 新建一页,不要硬塞进不相关主题 | +| 源本身有错(错别字 / 自相矛盾) | **不改 raw**,在对应主题页加注 `> 待澄清:YYYY-MM-DD raw/X 某处有 Y 问题`(四个注记标记的唯一定义见 `CLAUDE.md` 注记标记表) | +| 一份源触及的页面太多、一次做不完 | 先建页面骨架(摘要 + 戳 + 互链)并登 `todo` 条目,下次续 | +| 源与已有 wiki 结论冲突 | 新源证据更强就直接改写主题页;一时判不出谁对,两边都加 `> 矛盾:` 注记,别自己拍板选一个 | +| `raw/` 不存在 | 停下来问用户:这个目录是不是 Karpathy Wiki 库?如果还没建,先跑 `/karpathy-wiki-new` | +| `wiki/log.md` 不存在 | 不是 Karpathy Wiki 库(没有 schema),停下来引导用户先跑 `/karpathy-wiki-new` | +| `scanRaw` 报 IO 错 | 权限/路径问题,停下来告诉用户具体错误,不要瞎建目录 | + +### Step 4:收尾输出 + +```markdown +## ✅ 本轮 Ingest 完成 + +📥 摄取源:N 份(<简列>) +🆕 新建主题页:M 页(<简列>) +✏️ 更新主题页:K 页(<简列>) +🧭 节奏:<逐份 / 批量>(下次按此默认) +⚠️ 遗留:<矛盾 / 待澄清 / 待办 简述>(无则省略) +🔁 下一步:出正式产物前先跑 /karpathy-wiki-lint 体检 +``` + +## 确定性执行器(本 skill 自带) + +为了让 LLM 与测试都能直接调用,本 skill 把扫源 + 登 log 抽成纯函数,跑在 `ingest.ts` 里。 + +### `scanRaw(root: string) -> ScanResult` + +```ts +import { scanRaw } from './ingest.js'; +const r = await scanRaw('/path/to/wiki'); +// r.rawExists, r.wikiExists, r.logExists +// r.allSources = raw/ 下全部源(相对路径) +// r.ingested = log.md 里登记过的源 +// r.pending = 未登记的源(本次待摄取) +// r.orphans = log 里登记但 raw 已不存在的源(用户已删) +``` + +- **只读**,不动任何文件;幂等(跑 N 次结果一致) +- `pending` 与 `orphans` 是策划视角最关键的两个字段 +- 文件名匹配按**文件名相等**(basename),不解析路径 + +### `appendLog(root: string, type: LogType, summary: string) -> void` + +```ts +import { appendLog } from './ingest.js'; +await appendLog('/path/to/wiki', 'ingest', 'raw/Pan-2010-迁移学习综述.md'); +await appendLog('/path/to/wiki', 'wiki', '新建 [[迁移学习]] 并补 [[对比-...]]'); +``` + +- 按 `## [YYYY-MM-DD] | ` 格式追加 +- `type ∈ {'ingest', 'wiki', 'lint', 'query', 'deliverable', 'milestone', 'todo'}` —— 与 `CLAUDE.md` log 维护纪律一致 +- 日期用当天真实日期(`date +%F`) +- **append-only**:同一标题重复追加不会被覆盖,每次都会新增一条 +- 缺 `wiki/log.md` → 自动用 wiki-log.md 模板里的最小骨架创建(只是为了让 skill 在空库里也能工作;非 Karpathy Wiki 库请先跑 `/karpathy-wiki-new`) +- 缺 `raw/` 或 `wiki/` → 抛错并提示用户先建库 + +### CLI 形态(可选) + +```bash +node ingest.ts scan # 输出 JSON:allSources/ingested/pending/orphans +node ingest.ts append +``` + +CLI 输出 JSON,便于自动化;LLM/测试首选 import 调用。 + +## 调用方式 + +```ts +// 在 LLM 工具/脚本里 +import { scanRaw, appendLog } from '../skills/bundled/karpathy-wiki-ingest/ingest.js'; + +const r = await scanRaw(process.cwd()); +for (const src of r.pending) { + // ... 走 7 步 checklist ... + await appendLog(process.cwd(), 'ingest', src); +} +``` + +也通过 slash command 触发(`/karpathy-wiki-ingest`)—— BundledSkillLoader 会把 SKILL.md 正文注入 prompt,LLM 按上面的工作流执行。 + +## 不要做的事 + +- ❌ **不要改动 `raw/` 下一个字**(哪怕发现错别字)—— 在 wiki 主题页用 `> 待澄清:` 注记,源本身一字不改 +- ❌ **不要堆叠旧结论而不改写正文**—— 主题页该重写就重写,让这一页反映当前最优综述;被推翻的旧说法压成 `## 修订记录` 一行,不值得的交给 git +- ❌ **不要制造孤岛页**—— 新建的主题页至少要被 `INDEX.md` 或某个主题页链到 +- ❌ **不要回填页自造命名前缀**—— 命名约束(回填页取名)以 `CLAUDE.md` 为唯一定义 +- ❌ **不要把兄弟技能缺失写进 `wiki/log.md`**—— 那是环境状态,不是知识库内容 +- ❌ **不要替用户改 `scanRaw` / `appendLog` 的语义**—— 这是确定性执行器,改了测试就炸 diff --git a/src/skills/bundled/karpathy-wiki-ingest/ingest.ts b/src/skills/bundled/karpathy-wiki-ingest/ingest.ts new file mode 100644 index 0000000..d57ccff --- /dev/null +++ b/src/skills/bundled/karpathy-wiki-ingest/ingest.ts @@ -0,0 +1,304 @@ +/** + * src/skills/bundled/karpathy-wiki-ingest/ingest.ts + * + * karpathy-wiki-ingest 的确定性执行器(IK8MWT #20)。 + * + * 用法: + * 作为库: import { scanRaw, appendLog } from './ingest.js' + * 作为 CLI: node ingest.ts scan + * node ingest.ts append + * + * 行为契约(与 SKILL.md 一致): + * - scanRaw:只读;扫 raw/ 下全部源 + 对照 wiki/log.md 中 `## [YYYY-MM-DD] ingest |` 条目 + * 给出 pending(未摄取)/ ingested(已摄取)/ orphans(已登记但源不在)三类清单 + * - appendLog:按 `## [YYYY-MM-DD] | ` 格式追加;type ∈ 约定枚举; + * append-only,重复登记不会覆盖;缺 wiki/log.md 自动用最小骨架补一个 + * - 缺 raw/ 或 wiki/ → 抛错,提示用户先跑 karpathy-wiki-new + * - 幂等:scanRaw 跑 N 次结果一致;appendLog 同标题重复调每次新增一行 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { getErrorMessage } from '../../../utils/error.js'; + +/** + * 与 wiki/工作流-ingest-query-lint.md §一.Ingest / CLAUDE.md log 维护纪律一致的 + * log 类型集(7 类)。appendLog 严格按此白名单校验。 + */ +export const LOG_TYPES = [ + 'ingest', + 'query', + 'wiki', + 'lint', + 'deliverable', + 'milestone', + 'todo', +] as const; + +export type LogType = (typeof LOG_TYPES)[number]; + +/** 单条 ingest 条目里登记的源文件名(从 log 行解析得到) */ +export interface ScanResult { + /** raw/ 目录是否存在 */ + rawExists: boolean; + /** wiki/ 目录是否存在 */ + wikiExists: boolean; + /** wiki/log.md 是否存在(不存在时 scan 仍可跑,只是 ingested/orphans 都为空) */ + logExists: boolean; + + /** raw/ 下全部源(相对 root 的路径,字典序) */ + allSources: string[]; + /** 已被 log.md 登记过的源(按文件名字典序) */ + ingested: string[]; + /** raw/ 有、log 没登记 → 本轮待摄取 */ + pending: string[]; + /** log 登记过、raw 已不存在的源(用户删了原始素材) */ + orphans: string[]; +} + +/** scanRaw 行级正则:`## [YYYY-MM-DD] ingest | `(只关心 ingest 这一类) */ +const INGEST_LINE_RE = /^##\s+\[(\d{4}-\d{2}-\d{2})\]\s+ingest\s*\|\s*(.+?)\s*$/; + +/** + * 从 ingest summary 里抓文件名 token 的正则。ext 覆盖常见 markdown / 文本 / 数据 / 图片 / 网页。 + * 模块级常量,避免每次 parseIngestedSources 调用时重新编译。 + */ +const FILENAME_TOKEN_RE = + /[\w./\-一-龥()【】\[\]]+\.(?:md|txt|pdf|docx|xlsx|csv|json|png|jpg|jpeg|svg|html)/g; + +/** + * 缺 wiki/log.md 时,用这段最小骨架补一个。**不替代** karpathy-wiki-new 的 + * 完整模板;这里只给一个能跑 appendLog 的最小可用文件。 + */ +const MINIMAL_LOG_SKELETON = `# 日志(log.md) + +> 自动生成:karpathy-wiki-ingest 在 log.md 不存在时补的最小骨架。 +> 完整 schema 请跑 \`/karpathy-wiki-new\`。 + +> 最后更新:\`date +%F\` + + + +`; + +/** + * 扫 raw/ 下的全部源,对照 wiki/log.md 中已登记的 ingest 条目,返回摄取状态。 + * 不修改任何文件。 + */ +export async function scanRaw(root: string): Promise { + const rawDir = path.join(root, 'raw'); + const wikiDir = path.join(root, 'wiki'); + const logPath = path.join(wikiDir, 'log.md'); + + // 三个 stat 无依赖,并行触发 + const [rawExists, wikiExists, logExists] = await Promise.all([ + exists(rawDir), + exists(wikiDir), + exists(logPath), + ]); + + // raw/ 不存在 → 抛错,提示先建库(SKILL.md §三.失败处理) + if (!rawExists) { + throw new Error( + `scanRaw: ${root} 不是 Karpathy Wiki 库(缺少 raw/);请先跑 /karpathy-wiki-new`, + ); + } + if (!wikiExists) { + throw new Error( + `scanRaw: ${root} 缺少 wiki/ 目录;请先跑 /karpathy-wiki-new`, + ); + } + + // 1) 扫 raw/ 下全部源(README.md 视为索引/说明,不参与摄取清单) + const allSources = (await listRawSources(rawDir)).sort(); + + // 2) 解析 log.md 里登记过的源文件名(log 不存在则为空) + const ingested = logExists ? await parseIngestedSources(logPath) : []; + + // 3) 文件名相等作为「已登记」判据(SKILL.md:不解析路径,只看 basename) + // basename 只算一次,后续两个集合都复用 + const basenames = allSources.map((s) => path.basename(s)); + const basenameSet = new Set(basenames); + const ingestedSet = new Set(ingested); + const pending = basenames.filter((b) => !ingestedSet.has(b)).sort(); + // ingested 已是 sorted 数组,filter 保序,无需再排 + const orphans = ingested.filter((name) => !basenameSet.has(name)); + + return { + rawExists, + wikiExists, + logExists, + allSources, + ingested, + pending, + orphans, + }; +} + +/** 单文件/目录存在性探测(s.stat 失败即视为不存在) */ +async function exists(p: string): Promise { + return fs.stat(p).then(() => true, () => false); +} + +/** + * 列出 rawDir 下全部源(递归,跳过 README.md)。返回相对 rawDir 的路径。 + */ +async function listRawSources(rawDir: string): Promise { + const out: string[] = []; + const stack: string[] = [rawDir]; + while (stack.length > 0) { + const dir = stack.pop()!; + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const ent of entries) { + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + stack.push(full); + continue; + } + if (ent.name === 'README.md') continue; + // 隐藏文件(.开头)跳过 + if (ent.name.startsWith('.')) continue; + out.push(path.relative(rawDir, full)); + } + } + return out; +} + +/** + * 从 logPath 中解析所有 `## [YYYY-MM-DD] ingest | ` 行, + * 提取 summary 里看起来像文件名的 token(以 `.` 结尾的常见扩展名)。 + * + * 约定:用户登记 ingest 时把源文件名作为 summary 的核心信息(见 wiki-log.md + * 示例「## [YYYY-MM-DD] ingest | raw/<文件名>」)。本解析器容许以下写法: + * - `raw/Pan-2010-迁移学习综述.md` + * - `Pan-2010-迁移学习综述.md`(裸文件名,常见于简写) + * - 多文件登记 `raw/a.md + raw/b.md` + */ +async function parseIngestedSources(logPath: string): Promise { + let text: string; + try { + text = await fs.readFile(logPath, 'utf-8'); + } catch { + return []; + } + + const seen = new Set(); + for (const line of text.split('\n')) { + const m = line.match(INGEST_LINE_RE); + if (!m) continue; + const summary = m[2] ?? ''; + const tokens = summary.match(FILENAME_TOKEN_RE); + if (!tokens) continue; + for (const t of tokens) { + // 取 basename + const base = path.basename(t); + seen.add(base); + } + } + return Array.from(seen).sort(); +} + +/** + * 往 wiki/log.md 追加一条 `## [YYYY-MM-DD] | `。 + * + * - append-only:同一 (date, type, summary) 重复登记不会被覆盖,每次新增一行 + * - 缺 wiki/log.md → 自动用最小骨架补一个(便于在空库里也能工作) + * - type 不在 LOG_TYPES 白名单 → 抛错(沿用 CLAUDE.md log 维护纪律) + * - 缺 wiki/ → 抛错,提示先跑 karpathy-wiki-new + */ +export async function appendLog( + root: string, + type: LogType, + summary: string, + options: { date?: string; body?: string } = {}, +): Promise { + if (!LOG_TYPES.includes(type)) { + throw new Error( + `appendLog: 非法 type "${type}",必须在 ${JSON.stringify(LOG_TYPES)} 中`, + ); + } + if (typeof summary !== 'string' || summary.length === 0) { + throw new Error(`appendLog: summary 不能为空`); + } + + const wikiDir = path.join(root, 'wiki'); + const logPath = path.join(wikiDir, 'log.md'); + + // 两个 stat 无依赖,并行触发 + const [wikiExists, logExists] = await Promise.all([exists(wikiDir), exists(logPath)]); + if (!wikiExists) { + throw new Error( + `appendLog: ${root} 缺少 wiki/ 目录;请先跑 /karpathy-wiki-new`, + ); + } + if (!logExists) { + // wiki/ 已存在(上面抛错守门),无需再 mkdir + await fs.writeFile(logPath, MINIMAL_LOG_SKELETON, 'utf-8'); + } + + const date = options.date ?? todayIso(); + const body = options.body ?? defaultBody(type, summary); + const line = `\n## [${date}] ${type} | ${summary}\n\n${body}\n`; + + await fs.appendFile(logPath, line, 'utf-8'); +} + +/** + * 缺省正文:3 行模板,够 lint 友好 + grep 友好。 + */ +function defaultBody(type: LogType, summary: string): string { + return `- 类型:${type}\n- 内容:${summary}\n- 涉及:`; +} + +/** `date +%F` 的等价物(取本地日期) */ +export function todayIso(): string { + const d = new Date(); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +// ---------- CLI 入口 ---------- + +const isMain = (() => { + const arg1 = process.argv[1]; + if (!arg1) return false; + const self = fileURLToPath(import.meta.url); + return path.resolve(arg1) === self || path.resolve(arg1) === self.replace(/\.ts$/, '.js'); +})(); + +if (isMain) { + const [, , rootArg, cmd, typeArg, ...rest] = process.argv; + const root = rootArg ? path.resolve(rootArg) : process.cwd(); + + if (cmd === 'scan') { + scanRaw(root) + .then((r) => console.log(JSON.stringify(r, null, 2))) + .catch((err: unknown) => { + console.error(`❌ scan 失败: ${getErrorMessage(err)}`); + process.exit(1); + }); + } else if (cmd === 'append') { + if (!typeArg || rest.length === 0) { + console.error(`❌ 用法:append `); + process.exit(2); + } + const summary = rest.join(' '); + appendLog(root, typeArg as LogType, summary) + .then(() => console.log(`✓ log 条目已追加`)) + .catch((err: unknown) => { + console.error(`❌ append 失败: ${getErrorMessage(err)}`); + process.exit(1); + }); + } else { + console.error(`❌ 用法: scan | append `); + process.exit(2); + } +} diff --git a/test-case/test-issue-020.ts b/test-case/test-issue-020.ts new file mode 100644 index 0000000..9c96926 --- /dev/null +++ b/test-case/test-issue-020.ts @@ -0,0 +1,399 @@ +/** + * test-case/test-issue-020.ts + * + * 对应 issue IK8MWT #20 karpathy-wiki-ingest · raw/ 编译为 wiki/ 的 builtin skill + * + * 运行: bun run test-case/test-issue-020.ts + * + * 测试方法(issue 原文 + SKILL.md 行为契约): + * ① BundledSkillLoader 能把该 skill 注册为 /karpathy-wiki-ingest 且 description 非空 + * ② SKILL.md frontmatter 契约:英文 name=karpathy-wiki-ingest、description 含核心中文关键词 + * ③ 必备中文章节:「触发条件」「7 步 Ingest Checklist」「失败处理」+ 三条禁止事项 + * ④ scanRaw:tmp 里造 2-3 个假源 + wiki/log.md(其中一条已登记),断言 pending/ingested/orphans 分类正确 + * ⑤ appendLog:按 `## [YYYY-MM-DD] ingest | ` 追加;type 白名单校验;正文至少 3 行 + * ⑥ 幂等:scanRaw 跑 N 次结果一致;appendLog 同标题重复追加每次新增一行(append-only) + * ⑦ 打包:dist 含 SKILL.md + 编译后的 ingest.js + */ + +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { BundledSkillLoader } from '../src/services/BundledSkillLoader.js'; +import { skillManager } from '../src/core/skillManager.js'; +import { + scanRaw, + appendLog, + LOG_TYPES, + todayIso, + type LogType, +} from '../src/skills/bundled/karpathy-wiki-ingest/ingest.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); + +// ---------- 极简测试 harness ---------- + +let passed = 0; +let failed = 0; +const failures: string[] = []; + +function assert(cond: unknown, msg: string): void { + if (cond) { + passed++; + console.log(` ✓ ${msg}`); + } else { + failed++; + failures.push(msg); + console.log(` ✗ ${msg}`); + } +} + +function section(name: string): void { + console.log(`\n── ${name} ──`); +} + +async function makeTmpDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'alice-test-020-')); +} + +/** 直接复用 ingest.ts 导出的 todayIso(同一份口径) */ +const todayLocal = todayIso; + +async function exists(p: string): Promise { + return fs.stat(p).then(() => true, () => false); +} + +// ---------- 工具:造 fixture ---------- + +async function makeFixture(opts: { + rawFiles?: string[]; + logContent?: string; + createRawDir?: boolean; + createWikiDir?: boolean; +} = {}): Promise { + const dir = await makeTmpDir(); + if (opts.createRawDir !== false) { + await fs.mkdir(path.join(dir, 'raw'), { recursive: true }); + // README.md 不计入扫描 + await fs.writeFile( + path.join(dir, 'raw', 'README.md'), + '# raw (fixture)\n', + 'utf-8', + ); + for (const name of opts.rawFiles ?? []) { + await fs.writeFile(path.join(dir, 'raw', name), `# ${name}\nfixture`, 'utf-8'); + } + } + if (opts.createWikiDir !== false) { + await fs.mkdir(path.join(dir, 'wiki'), { recursive: true }); + if (opts.logContent !== undefined) { + await fs.writeFile(path.join(dir, 'wiki', 'log.md'), opts.logContent, 'utf-8'); + } + } + return dir; +} + +// ---------- 用例 ①: BundledSkillLoader 注册 ---------- + +async function testLoaderRegistration(): Promise { + section('① BundledSkillLoader 注册 /karpathy-wiki-ingest'); + + const loader = new BundledSkillLoader(null); + const commands = await loader.loadCommands(new AbortController().signal); + + const cmd = commands.find((c) => c.name === 'karpathy-wiki-ingest'); + assert(cmd !== undefined, '注册为 /karpathy-wiki-ingest 命令'); + assert( + typeof cmd?.description === 'string' && cmd.description.length > 0, + `description 非空 (实际 ${cmd?.description?.length ?? 0} 字符)`, + ); + // description 里要含 raw/wiki 这两个核心关键词(中英都行) + assert( + cmd?.description?.toLowerCase().includes('raw') === true && + cmd?.description?.toLowerCase().includes('wiki') === true, + 'description 含 raw / wiki 关键词', + ); + + // skillManager 直读:body 含完整 SKILL.md + const skills = await skillManager.listBundledSkills(); + const skill = skills.find((s) => s.name === 'karpathy-wiki-ingest'); + assert(skill !== undefined, 'skillManager.listBundledSkills 能发现该 skill'); + assert( + skill !== undefined && skill.body.includes('name: karpathy-wiki-ingest'), + 'SKILL.md body 含 frontmatter', + ); +} + +// ---------- 用例 ②: SKILL.md 契约(英文 frontmatter + 中文章节) ---------- + +async function testSkillMdContract(): Promise { + section('② SKILL.md 契约:frontmatter + 必备中文章节 + 三条禁止'); + + const skills = await skillManager.listBundledSkills(); + const skill = skills.find((s) => s.name === 'karpathy-wiki-ingest'); + assert(skill !== undefined, '能读出 SKILL.md'); + if (!skill) return; + + const body = skill.body; + + // frontmatter: name + description + const fmMatch = body.match(/^---\n([\s\S]*?)\n---/); + assert(fmMatch !== null, '有 YAML frontmatter'); + const fm = fmMatch ? fmMatch[1] : ''; + assert(/^name:\s*karpathy-wiki-ingest\s*$/m.test(fm), 'frontmatter.name = karpathy-wiki-ingest'); + assert(/^description:\s*\S/m.test(fm), 'frontmatter.description 非空'); + + // 必备中文小节(对应 SKILL.md 行为契约) + const requiredSections = [ + '## 触发条件', + 'scanRaw', + 'appendLog', + '## 7 步 Ingest Checklist', + '失败处理', + '## 不要做的事', + ]; + for (const sec of requiredSections) { + assert(body.includes(sec), `正文含「${sec}」`); + } + + // 显式禁止:不改 raw / 不堆叠旧结论 / 不造孤岛页 + assert(body.includes('raw/') && /不改\s*raw|一字都不改|不要改动\s*raw/.test(body), + '明确禁止改动 raw/'); + assert(/不要堆叠旧结论|堆叠旧结论而不改写/.test(body), + '明确禁止堆叠旧结论而不改写'); + assert(/不要制造孤岛页|不制造孤岛页/.test(body), + '明确禁止制造孤岛页'); + + // 触发词(中文短语) + const triggers = ['消化一下', 'raw 录进 wiki', '编译 raw', 'wiki ingest']; + const allTriggers = triggers.every((t) => body.includes(t)); + assert(allTriggers, `SKILL.md 写明触发词(${triggers.join(' / ')})`); +} + +// ---------- 用例 ③: scanRaw 基础(pending / ingested 分类正确) ---------- + +async function testScanRawBasic(): Promise { + section('③ scanRaw:pending / ingested / orphans 分类正确'); + + const today = todayLocal(); + const dir = await makeFixture({ + rawFiles: ['a.md', 'b.md', 'c.md'], + logContent: + `# log\n\n` + + `## [${today}] ingest | a.md\n\n第一份已消化。\n\n` + + `## [${today}] wiki | 新建 [[A]]\n\nA 已建立。\n\n`, + }); + + const r = await scanRaw(dir); + assert(r.rawExists, 'rawExists=true'); + assert(r.wikiExists, 'wikiExists=true'); + assert(r.logExists, 'logExists=true'); + assert(r.allSources.length === 3, `allSources 长度=3 (实际 ${r.allSources.length})`); + assert(r.ingested.includes('a.md'), 'ingested 含 a.md(已登记)'); + assert(!r.ingested.includes('b.md'), 'ingested 不含 b.md'); + assert(!r.ingested.includes('c.md'), 'ingested 不含 c.md'); + assert(r.pending.length === 2, `pending 长度=2 (实际 ${r.pending.length})`); + assert(r.pending.includes('b.md') && r.pending.includes('c.md'), + `pending 含 b.md + c.md (实际 ${JSON.stringify(r.pending)})`); + assert(r.orphans.length === 0, 'orphans 为空(无用户删源)'); + + // raw/a.md 一个字没改(只读断言) + const aContent = await fs.readFile(path.join(dir, 'raw', 'a.md'), 'utf-8'); + assert(aContent === '# a.md\nfixture', 'raw/a.md 内容未被改动(scan 只读)'); +} + +// ---------- 用例 ④: scanRaw 孤儿(log 登记了但源不存在) ---------- + +async function testScanRawOrphans(): Promise { + section('④ scanRaw:orphans 检测(log 登记过但 raw 已删除)'); + + const today = todayLocal(); + const dir = await makeFixture({ + rawFiles: ['only-this.md'], + logContent: + `# log\n\n` + + `## [${today}] ingest | deleted-source.md\n\n曾经消化过的源已经被删了。\n\n` + + `## [${today}] ingest | only-this.md\n\n这份还在。\n\n`, + }); + + const r = await scanRaw(dir); + assert(r.orphans.includes('deleted-source.md'), + `orphans 含 deleted-source.md (实际 ${JSON.stringify(r.orphans)})`); + assert(r.pending.length === 0, + 'pending 为空(only-this.md 已登记)'); +} + +// ---------- 用例 ⑤: scanRaw 幂等 ---------- + +async function testScanRawIdempotent(): Promise { + section('⑤ scanRaw:幂等(跑 N 次结果一致)'); + + const today = todayLocal(); + const dir = await makeFixture({ + rawFiles: ['x.md', 'y.md'], + logContent: `## [${today}] ingest | x.md\n\n`, + }); + + const r1 = await scanRaw(dir); + const r2 = await scanRaw(dir); + const r3 = await scanRaw(dir); + + assert( + JSON.stringify(r1) === JSON.stringify(r2) && + JSON.stringify(r2) === JSON.stringify(r3), + 'scanRaw 跑三次结果完全一致', + ); + + // 幂等运行不破坏文件 + const xContent = await fs.readFile(path.join(dir, 'raw', 'x.md'), 'utf-8'); + assert(xContent === '# x.md\nfixture', 'scanRaw 跑三次后 raw/x.md 仍是原内容'); + const logContent = await fs.readFile(path.join(dir, 'wiki', 'log.md'), 'utf-8'); + assert(logContent === `## [${today}] ingest | x.md\n\n`, 'log.md 也未被 scanRaw 改动'); +} + +// ---------- 用例 ⑥: scanRaw 缺 raw/ 抛错(不静默成功) ---------- + +async function testScanRawMissingRaw(): Promise { + section('⑥ scanRaw:缺 raw/ 时抛错(不瞎建目录)'); + + const dir = await makeTmpDir(); + // 只建 wiki/,不建 raw/ + await fs.mkdir(path.join(dir, 'wiki'), { recursive: true }); + + let threw = false; + try { + await scanRaw(dir); + } catch (err) { + threw = true; + assert( + String(err).includes('karpathy-wiki-new') || String(err).includes('raw/'), + `错误信息提示先建库 (实际:${String(err).slice(0, 120)})`, + ); + } + assert(threw, '缺 raw/ 时抛错(没瞎建目录)'); +} + +// ---------- 用例 ⑦: appendLog 格式正确 + type 白名单 ---------- + +async function testAppendLogFormat(): Promise { + section('⑦ appendLog:格式 + type 白名单 + 最小正文'); + + const today = todayLocal(); + const dir = await makeFixture({ rawFiles: ['a.md'] }); + await scanRaw(dir); // 顺带确认 log.md 还没建时 appendLog 会自动建 + + await appendLog(dir, 'ingest', 'a.md'); + await appendLog(dir, 'wiki', '新建 [[A]] 并更新 INDEX'); + await appendLog(dir, 'lint', '体检通过'); + + const text = await fs.readFile(path.join(dir, 'wiki', 'log.md'), 'utf-8'); + assert( + new RegExp(`## \\[${today}\\] ingest \\| a\\.md`).test(text), + `log 含 ## [${today}] ingest | a.md 标题行`, + ); + assert( + new RegExp(`## \\[${today}\\] wiki \\| 新建 \\[\\[A\\]\\]`).test(text), + `log 含 ## [${today}] wiki | 新建 [[A]]... 标题行`, + ); + assert(text.includes('## 7 步') === false, 'log 顶部仍是骨架(没把 SKILL.md 内容混进来)'); + // 最小正文 ≥ 3 行(类型/内容/涉及)—— 三行默认 body 用 \n 串接 + const ingestBlock = text.split(/^## \[/m).find((b) => b.startsWith(`${today}] ingest | a.md`)); + const bodyLines = (ingestBlock ?? '').split('\n').filter((l) => l.trim().length > 0); + assert(bodyLines.length >= 3, `ingest 条目正文 ≥ 3 行(实际 ${bodyLines.length}: ${JSON.stringify(bodyLines)})`); + assert(bodyLines.some((l) => l.includes('类型')) && bodyLines.some((l) => l.includes('内容')), + '正文含「类型」与「内容」字段'); + + // type 白名单:非法 type 应抛错 + let badTypeThrew = false; + try { + await appendLog(dir, 'bogus' as unknown as LogType, 'x'); + } catch { + badTypeThrew = true; + } + assert(badTypeThrew, '非法 type("bogus")抛错'); + // LOG_TYPES 枚举正好 7 个 + assert(LOG_TYPES.length === 7, `LOG_TYPES 长度=7 (实际 ${LOG_TYPES.length})`); + assert(LOG_TYPES.includes('todo'), 'LOG_TYPES 含 todo'); +} + +// ---------- 用例 ⑧: appendLog 幂等(append-only,同标题重复调每次新增一行) ---------- + +async function testAppendLogIdempotent(): Promise { + section('⑧ appendLog:append-only(同标题重复调每次新增一行)'); + + const dir = await makeFixture({ rawFiles: [] }); + await appendLog(dir, 'ingest', 'a.md', { date: '2026-08-15', body: 'first body' }); + await appendLog(dir, 'ingest', 'a.md', { date: '2026-08-15', body: 'second body' }); + await appendLog(dir, 'ingest', 'a.md', { date: '2026-08-15', body: 'third body' }); + + const text = await fs.readFile(path.join(dir, 'wiki', 'log.md'), 'utf-8'); + const ingestTitleCount = (text.match(/^## \[2026-08-15\] ingest \| a\.md$/gm) ?? []).length; + assert(ingestTitleCount === 3, `同标题重复调出现 3 次 (实际 ${ingestTitleCount})`); + assert(text.includes('first body') && text.includes('second body') && text.includes('third body'), + '三条正文都被保留(不覆盖)'); +} + +// ---------- 用例 ⑨: 打包断言(dist 资源齐全) ---------- + +async function testDistPackaging(): Promise { + section('⑨ 打包:dist 含 SKILL.md + 编译后的 ingest.js'); + + // 注意:build 用 bun(v3.0.1 起,#19 修复了 Node<22.18 下的 npm run build 失败) + const cmd = process.platform === 'win32' ? 'bun.cmd' : 'bun'; + const res = spawnSync(cmd, ['run', 'build'], { + cwd: REPO_ROOT, + encoding: 'utf-8', + timeout: 300_000, + env: { ...process.env, PATH: process.env['PATH'] }, + }); + assert(res.status === 0, `bun run build 成功 (stderr: ${(res.stderr ?? '').slice(-200)})`); + + const bundledDist = path.join(REPO_ROOT, 'dist', 'skills', 'bundled', 'karpathy-wiki-ingest'); + assert(await exists(path.join(bundledDist, 'SKILL.md')), 'dist 含 SKILL.md'); + assert(await exists(path.join(bundledDist, 'ingest.js')), 'dist 含编译后的 ingest.js'); + + // 生产路径自检:dist 里的 skillManager 能发现 bundled skill + const distManagerPath = path.join(REPO_ROOT, 'dist', 'core', 'skillManager.js'); + const { skillManager: distSkillManager } = await import(distManagerPath); + const distSkills = await distSkillManager.listBundledSkills(); + assert( + distSkills.some((s) => s.name === 'karpathy-wiki-ingest'), + 'dist 产物能从 dist/skills/bundled 发现 karpathy-wiki-ingest', + ); +} + +// ---------- 主入口 ---------- + +async function main(): Promise { + console.log('🧪 test-issue-020 — karpathy-wiki-ingest bundled skill\n'); + + try { + await testLoaderRegistration(); + await testSkillMdContract(); + await testScanRawBasic(); + await testScanRawOrphans(); + await testScanRawIdempotent(); + await testScanRawMissingRaw(); + await testAppendLogFormat(); + await testAppendLogIdempotent(); + await testDistPackaging(); + } catch (err) { + console.error('uncaught:', err); + failures.push('uncaught: ' + (err instanceof Error ? err.message : String(err))); + failed++; + } + + console.log(`\n────────────────────────────`); + console.log(`PASS: ${passed} FAIL: ${failed}`); + if (failed > 0) { + console.log('\n失败明细:'); + failures.forEach((m) => console.log(` - ${m}`)); + process.exit(1); + } else { + process.exit(0); + } +} + +void main(); diff --git a/test-case/test-list.md b/test-case/test-list.md index b116bd5..3163519 100644 --- a/test-case/test-list.md +++ b/test-case/test-list.md @@ -7,8 +7,8 @@ ## 全量回归 ```bash -# issue 回归套件(当前基线 168 断言) -for t in 001 002 003 004 005 012 019; do bun run test-case/test-issue-$t.ts || exit 1; done +# issue 回归套件(当前基线 219 断言:168 旧 + 51 新) +for t in 001 002 003 004 005 012 019 020; do bun run test-case/test-issue-$t.ts || exit 1; done ``` ## 清单(按 issue 编号排序) @@ -23,7 +23,9 @@ for t in 001 002 003 004 005 012 019; do bun run test-case/test-issue-$t.ts || e | `test-issue-005.ts` | Workspace Backend 收敛守卫:daemon 不得直接 import *Backend 实现(grep + tsc 两层) | workspace 解耦(daemon、runtime/workspace) | issue #5(IK8MWK)/ PR !6 | | `test-issue-010.ts` | ripgrep 子进程替换 glob:`rg --json` NDJSON 解析、空 PATH 自动降级、ignore 列表对齐、CI 基准 | 工具性能(utils/ripgrepRunner、tools/builtin/searchFiles) | issue #10(IK8MWP)/ PR !11 | | `test-issue-019.ts` | karpathy-wiki-new bundled skill:SKILL.md 契约、scaffold 执行器、listBundledSkills、dist 打包 | 内置 skills(skills/bundled) | issue #19(IK8MWL)/ PR !7 | +<<<<<<< HEAD | `test-issue-012.ts` | token 预算接通 TUI:getUsage 边界、ChatStreamEvent.budget_update 类型联合、TokenBudgetBar 字符串、联调事件序列 | runtime/agent/tokenBudget → types/chatStream → UI/Footer | issue #12(IK8MWR)/ PR !8 | +| `test-issue-020.ts` | karpathy-wiki-ingest bundled skill:scanRaw(pending/ingested/orphans)+appendLog(type 白名单、append-only)+SKILL.md 契约 + dist 打包 | 内置 skills(skills/bundled) | issue #20(IK8MWT)/ PR 待回填 | | `test-model.ts` | 手动入口:模型连通性 + 速度检查(等价 `alice --test-model`);实现位于 `src/utils/testModel.ts` | 模型诊断(utils/testModel) | 历史 dev 脚本(无 PR);2026-08-15 修复为可运行薄壳 | | `test-tools.ts` | 手动入口:toolRegistry / builtinTools / ToolExecutor 冒烟 | 工具系统 | 历史 dev 脚本(无 PR) | | `test-function-calling.ts` | 手动入口:LLM function calling 端到端(需真实 API) | function calling | 历史 dev 脚本(无 PR) | -- Gitee From da081a4af793eac8eadc44d7e070d4fb40b0764f Mon Sep 17 00:00:00 2001 From: andershsueh Date: Sat, 15 Aug 2026 01:26:41 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(test):=20=E5=9B=9E=E5=A1=AB=20test-iss?= =?UTF-8?q?ue-020=20PR=20=E5=8F=B7=E4=B8=BA=20!9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test-case/test-list.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test-case/test-list.md b/test-case/test-list.md index 3163519..305f709 100644 --- a/test-case/test-list.md +++ b/test-case/test-list.md @@ -24,8 +24,12 @@ for t in 001 002 003 004 005 012 019 020; do bun run test-case/test-issue-$t.ts | `test-issue-010.ts` | ripgrep 子进程替换 glob:`rg --json` NDJSON 解析、空 PATH 自动降级、ignore 列表对齐、CI 基准 | 工具性能(utils/ripgrepRunner、tools/builtin/searchFiles) | issue #10(IK8MWP)/ PR !11 | | `test-issue-019.ts` | karpathy-wiki-new bundled skill:SKILL.md 契约、scaffold 执行器、listBundledSkills、dist 打包 | 内置 skills(skills/bundled) | issue #19(IK8MWL)/ PR !7 | <<<<<<< HEAD +<<<<<<< HEAD | `test-issue-012.ts` | token 预算接通 TUI:getUsage 边界、ChatStreamEvent.budget_update 类型联合、TokenBudgetBar 字符串、联调事件序列 | runtime/agent/tokenBudget → types/chatStream → UI/Footer | issue #12(IK8MWR)/ PR !8 | | `test-issue-020.ts` | karpathy-wiki-ingest bundled skill:scanRaw(pending/ingested/orphans)+appendLog(type 白名单、append-only)+SKILL.md 契约 + dist 打包 | 内置 skills(skills/bundled) | issue #20(IK8MWT)/ PR 待回填 | +======= +| `test-issue-020.ts` | karpathy-wiki-ingest bundled skill:scanRaw(pending/ingested/orphans)+appendLog(type 白名单、append-only)+SKILL.md 契约 + dist 打包 | 内置 skills(skills/bundled) | issue #20(IK8MWT)/ PR !9 | +>>>>>>> a3520c4 (docs(test): 回填 test-issue-020 PR 号为 !9) | `test-model.ts` | 手动入口:模型连通性 + 速度检查(等价 `alice --test-model`);实现位于 `src/utils/testModel.ts` | 模型诊断(utils/testModel) | 历史 dev 脚本(无 PR);2026-08-15 修复为可运行薄壳 | | `test-tools.ts` | 手动入口:toolRegistry / builtinTools / ToolExecutor 冒烟 | 工具系统 | 历史 dev 脚本(无 PR) | | `test-function-calling.ts` | 手动入口:LLM function calling 端到端(需真实 API) | function calling | 历史 dev 脚本(无 PR) | -- Gitee