From 577969e064373620df35a668a0e99b19e0fff664 Mon Sep 17 00:00:00 2001 From: DJJ Date: Mon, 31 Aug 2026 20:20:56 +0800 Subject: [PATCH 1/4] feat: configure llmdoc startup context --- .agents/skills/init/SKILL.md | 1 + .agents/skills/llmdoc/SKILL.md | 5 +- .../llmdoc/references/startup-config.md | 40 +++ .agents/skills/prune/SKILL.md | 2 + .agents/skills/update/SKILL.md | 1 + .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .github/workflows/ci.yml | 27 ++ README.md | 50 ++- README.zh-CN.md | 43 ++- cli/assets/viewer-app.js | 22 +- cli/assets/viewer-detail.js | 8 +- cli/assets/viewer-graph.js | 6 +- cli/assets/viewer.html | 38 +-- cli/package-lock.json | 4 +- cli/package.json | 2 +- cli/schemas/config.schema.json | 34 ++ cli/schemas/output.schema.json | 12 + cli/src/cli.ts | 136 ++++---- cli/src/commands/adopt.ts | 12 +- cli/src/commands/commit.ts | 12 +- cli/src/commands/context.ts | 2 +- cli/src/commands/fingerprint.ts | 6 +- cli/src/commands/hook.ts | 101 ++++-- cli/src/commands/init-state.ts | 10 +- cli/src/commands/mv.ts | 37 ++- cli/src/commands/new.ts | 10 +- cli/src/commands/prune.ts | 15 +- cli/src/commands/search.ts | 2 +- cli/src/commands/serve.ts | 2 +- cli/src/commands/show.ts | 2 +- cli/src/commands/status.ts | 2 +- cli/src/commands/tree.ts | 2 +- cli/src/commands/upgrade.ts | 30 +- cli/src/lib/config.ts | 170 ++++++++++ cli/src/lib/doc-shape.ts | 10 +- cli/src/lib/fs.ts | 16 +- cli/src/lib/git.ts | 12 +- cli/src/lib/markdown.ts | 10 +- cli/src/lib/output-schema.ts | 6 +- cli/src/lib/package-root.ts | 2 +- cli/src/lib/pagination.ts | 4 +- cli/src/lib/schema.ts | 9 + cli/src/lib/state.ts | 37 ++- cli/src/lib/viewer-http.ts | 4 +- cli/src/lib/workspace.ts | 60 ++-- cli/src/types.ts | 17 + cli/tests/cli.test.ts | 294 +++++++++++++++--- cli/tests/cold-start.test.ts | 2 +- cli/tests/search-cjk.test.ts | 2 +- cli/tests/viewer-assets.test.ts | 9 + cli/tests/viewer-state.test.ts | 6 + docs/agent-integration.md | 6 + docs/v3-design/03-cli.md | 10 +- docs/v3-design/04-workflows.md | 5 +- docs/v3-design/05-packaging.md | 6 +- hooks/hooks.json | 5 +- llmdoc.config.json | 11 + package-lock.json | 6 +- package.json | 2 +- scripts/check-codex-surface.mjs | 14 +- scripts/check-prompt-budget.mjs | 2 - skills/init/SKILL.md | 1 + skills/llmdoc/SKILL.md | 5 +- skills/llmdoc/references/startup-config.md | 40 +++ skills/prune/SKILL.md | 2 + skills/update/SKILL.md | 1 + tests/parity-checklist.md | 4 +- website/package.json | 1 + website/public/llms.txt | 4 + website/scripts/check-published-schema.mjs | 17 + website/src/pages/docs/cli/index.astro | 20 +- .../src/pages/schemas/config.schema.json.ts | 12 + website/src/pages/zh/docs/cli/index.astro | 20 +- 75 files changed, 1194 insertions(+), 352 deletions(-) create mode 100644 .agents/skills/llmdoc/references/startup-config.md create mode 100644 cli/schemas/config.schema.json create mode 100644 cli/src/lib/config.ts create mode 100644 llmdoc.config.json create mode 100644 skills/llmdoc/references/startup-config.md create mode 100644 website/scripts/check-published-schema.mjs create mode 100644 website/src/pages/schemas/config.schema.json.ts diff --git a/.agents/skills/init/SKILL.md b/.agents/skills/init/SKILL.md index b76454c..b05db01 100644 --- a/.agents/skills/init/SKILL.md +++ b/.agents/skills/init/SKILL.md @@ -43,6 +43,7 @@ Stop instead of improvising when: - Prefer the smallest sufficient set of high-value owner docs over broad shallow inventory. Depth never excuses a first-class subsystem with neither an owner nor an intentional no-doc decision. - Keep stable knowledge in `llmdoc/` and validity state in `llmdoc/meta.json`. - Create root singleton docs only for genuinely cross-topic contracts; otherwise create only the necessary one-level topic directories. Topics are plain directories with no `index.mdx` entry node. + - If the user wants non-default SessionStart guidance or deliberate document preload, read [Startup Configuration](../llmdoc/references/startup-config.md) and create `llmdoc.config.json`; otherwise do not add optional startup config during bootstrap. 3. Validate before reporting success. - Seed the ledger with `init-state` (writes meta.json with null revisions), then run `validate` and fix all schema, routing, and reference failures. diff --git a/.agents/skills/llmdoc/SKILL.md b/.agents/skills/llmdoc/SKILL.md index 1455682..7943b11 100644 --- a/.agents/skills/llmdoc/SKILL.md +++ b/.agents/skills/llmdoc/SKILL.md @@ -9,9 +9,7 @@ allowed-tools: 'Read, Glob, Grep, Bash, Write, Edit, WebSearch, WebFetch' # /llmdoc -The operating protocol for V3 `llmdoc` projects. `llmdoc/` holds the architecture, constraints, and working agreements that source code does not cheaply give back; the CLI is how you reach them. - -Every command below runs as `npx -y @tokenroll/llmdoc `; the CLI Invocation section holds the full rules. +Use the CLI to retrieve durable architecture, constraints, and working agreements from V3 `llmdoc/` projects. Run commands as `npx -y @tokenroll/llmdoc `. ## Retrieval Gate @@ -51,6 +49,7 @@ These entry points are alternatives, not a sequence. Stop as soon as the task ha - If `llmdoc/` does not exist, suggest `/llmdoc:init`; do not fabricate the knowledge surface ad hoc. - When a task produces durable knowledge changes, suggest `/llmdoc:update` at the end. - For topology or routing work, read [Knowledge Topology](references/knowledge-topology.md). +- For SessionStart reminders or document preload, read [Startup Configuration](references/startup-config.md). - Never suggest `/llmdoc:upgrade`; it runs only when the user asks for it by name. ## Reflection Gate diff --git a/.agents/skills/llmdoc/references/startup-config.md b/.agents/skills/llmdoc/references/startup-config.md new file mode 100644 index 0000000..8ed9be8 --- /dev/null +++ b/.agents/skills/llmdoc/references/startup-config.md @@ -0,0 +1,40 @@ ++# Startup Configuration + +Read this reference when a user asks to configure SessionStart guidance or preload documents, or when a workflow creates, renames, merges, or deletes a document named in startup preload. + +## File and schema + +Place the optional `llmdoc.config.json` at the llmdoc workspace root. In a Git repository this is the nearest Git root that owns `llmdoc/`; the no-Git fallback is the directory that owns `llmdoc/`. + +```json +{ + "$schema": "https://llmdoc.tokenroll.ai/schemas/config.schema.json", + "schema": "llmdoc.config/v1", + "startup": { + "remindSkill": true, + "preload": [ + "architecture.mdx", + "api-client/contracts.mdx" + ] + } +} +``` + +- `startup.remindSkill` defaults to `true`. Set it to `false` only when the repository deliberately supplies equivalent operating guidance elsewhere or wants no proactive reminder. +- `startup.preload` contains exact `.mdx` document IDs in declaration order. An entry may include the `llmdoc/` prefix. +- Cold SessionStart injects configured bodies directly and has no llmdoc character or token budget. A final completion marker distinguishes a complete preload from host-side truncation; if it is absent, retrieve only the missing body with `show`. +- Compact re-entry lists configured document IDs without injecting the bodies again. Use the compacted `LLMDOC_STATE` first and retrieve a body only when needed. + +## Validation and degradation + +Run `validate` after creating or editing the file. Invalid JSON or schema cannot preserve field intent, so hooks use the default reminder and skip preload. When the schema is valid but a preload path is invalid or missing, hooks preserve the valid `remindSkill` choice and skip the preload field. Entries that normalize to the same document are deduplicated with a warning. + +## Structural changes + +A preload entry is a persistent reference and must stay synchronized with document identity: + +- `llmdoc mv` rewrites matching preload entries transactionally with document references and the ledger. +- Before a prune workflow manually merges or deletes documents, inspect the report's startup preload references and update or remove affected entries in the same write set. +- After any manual path change, run `validate` before `commit`. A missing preload target is an error and intentionally blocks finalization. + +Do not create this file during init unless the user or repository requirements call for non-default startup behavior or deliberate document preload. diff --git a/.agents/skills/prune/SKILL.md b/.agents/skills/prune/SKILL.md index 020ae2c..2fdb597 100644 --- a/.agents/skills/prune/SKILL.md +++ b/.agents/skills/prune/SKILL.md @@ -37,6 +37,7 @@ This command does not authorize source-code edits. 2. Decide the convergence plan with `recorder`. - If the plan moves ownership, changes topic boundaries, or merges/splits documents, read [Knowledge Topology and Context Floor](../llmdoc/references/knowledge-topology.md) before rewriting. + - Read [Startup Configuration](../llmdoc/references/startup-config.md) when the report lists startup preload references. Update or remove affected config entries in the same write set before merging or deleting their documents; `mv` handles direct renames automatically. - Merge duplicated docs. - Rewrite fragmented docs when a clearer topic boundary exists. - Apply the Stable Knowledge Gate sentence by sentence. Remove command/file inventories, current-state evidence, and other facts that a reader can cheaply recover from canonical sources. @@ -45,6 +46,7 @@ This command does not authorize source-code edits. - Delete a document when it has no unique durable knowledge; canonical source, schema, help, or tests are valid destinations for discarded evidence. Do not copy low-value content elsewhere merely to justify deletion. 3. Re-validate the result. + - Confirm every configured startup preload still targets the surviving owner document. - Run `validate`. - When ownership or routing changed, run the reference's scoped concept, per-file owner, broad-glob precision, and prerequisite checks; structural validation alone is insufficient. - Re-run `prune --report` and compare document/token scale with the first report. diff --git a/.agents/skills/update/SKILL.md b/.agents/skills/update/SKILL.md index 80e837d..28d0d51 100644 --- a/.agents/skills/update/SKILL.md +++ b/.agents/skills/update/SKILL.md @@ -54,6 +54,7 @@ This command does not authorize source-code edits. - Scaffold brand-new docs with `new`; register docs that already exist as files with `adopt ` — never hand-edit `meta.json` or recreate the file through `new`. 5. Finalize. + - If document identities changed, read [Startup Configuration](../llmdoc/references/startup-config.md). `mv` syncs renames; sync manual merges or deletions before validation. - Run `validate`; after mapping or boundary changes, also run the reference's scoped routing acceptance. - If prose changed, run `commit -m ""`, adding `--verified ` for reviewed unchanged docs. If all stayed unchanged, run `commit --verified `. Full verification uses `--all`, never with `--verified`. - `commit` validates, commits prose, refreshes fingerprints, and lands `meta.json` separately. Never reconstruct this sequence manually or `--amend` it. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 498fd90..3ad249c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "llmdoc", "source": "./", "description": "Persistent engineering context powered by the llmdoc CLI and progressive MDX knowledge retrieval", - "version": "3.5.3" + "version": "3.6.0" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b3ce8db..33600bb 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "llmdoc", "description": "Persistent engineering context powered by the llmdoc CLI and progressive MDX knowledge retrieval", - "version": "3.5.3", + "version": "3.6.0", "author": { "name": "DJJ & Danniel" } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 2f6c1fe..cd2ccc0 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "llmdoc", - "version": "3.5.3", + "version": "3.6.0", "description": "Persistent engineering context powered by the llmdoc CLI and progressive MDX knowledge retrieval", "skills": "./.agents/skills/" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d43535..80380fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,3 +83,30 @@ jobs: npm init --yes >/dev/null npm install "${GITHUB_WORKSPACE}/cli/${tarball}" >/dev/null npx --no-install @tokenroll/llmdoc --help + + website: + name: Website and published schema + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: website + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2 + + - name: Use Node.js 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install + run: npm ci + + - name: Check and build + run: npm run check && npm run build + + - name: Verify published config schema + run: npm run check:schema diff --git a/README.md b/README.md index e7d37f1..04fd52a 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,50 @@ Use `npx -y @tokenroll/llmdoc --help` or reference. `status` and `delta` assess validity and impact; they are not retrieval steps. +### Startup context configuration + +Repositories that use the lifecycle hooks can add an optional +`llmdoc.config.json` at the llmdoc workspace root. In a Git repository, this is +the nearest Git root that owns `llmdoc/`: + +```json +{ + "$schema": "https://llmdoc.tokenroll.ai/schemas/config.schema.json", + "schema": "llmdoc.config/v1", + "startup": { + "remindSkill": true, + "preload": [ + "architecture.mdx", + "plugin-packaging/claude-and-codex.mdx" + ] + } +} +``` + +- `remindSkill` controls the SessionStart operating guidance: load the llmdoc + skill, use the CLI retrieval gate, and delegate to the llmdoc roles. It + defaults to `true`; set it to `false` to opt out. +- `preload` lists exact document IDs, with an optional `llmdoc/` prefix. Cold + SessionStart inserts their bodies directly in the listed order with no llmdoc + character or token budget. A final completion marker confirms that the host + supplied the full preload; without it, retrieve only the missing body with + `show`. +- Compact re-entry lists configured document IDs but does not inject their full + bodies again. Continue from `LLMDOC_STATE` and retrieve a body only if needed. +- `validate` reports malformed config, missing paths, and path escapes. + Normalized aliases of the same document are deduplicated with a warning. + Lifecycle hooks stay fail-open: unreadable schema/JSON uses the default + reminder, while a valid `remindSkill` choice survives preload-only errors. +- `mv` rewrites matching preload paths transactionally. `prune --report` lists + preload references that must be updated before a manual merge or deletion. + +Without this file, SessionStart emits its state plus the default operating +guidance and does not preload documents. + +All fixed CLI interface text is English, including help, diagnostics, hook +messages, and the local Viewer. Chinese queries and repository document content +remain fully supported and are returned unchanged. + ## Knowledge and safety boundaries - Stable knowledge belongs in tracked `llmdoc/`; investigations, caches, and @@ -161,8 +205,10 @@ retrieval steps. - Every workflow authorizes knowledge maintenance only, not source-code edits. Structural writes are validated and confined to the repository's `llmdoc/` boundary. -- Hooks emit read-only, fail-open signals through the scoped CLI. Review hooks - and trust the plugin source before enabling them. +- Hooks emit read-only, fail-open signals through the scoped CLI. SessionStart + also injects the configurable operating guidance, so projects do not need to + repeat it in CLAUDE.md/AGENTS.md. Review hooks and trust the plugin source + before enabling them. - A `delta` match means “review this claim,” not “rewrite this document.” Preserve decisions, rationale, boundaries, invariants, contracts, and non-obvious failure semantics; leave reconstructable facts in source, schemas, diff --git a/README.zh-CN.md b/README.zh-CN.md index 5d68af8..cd23517 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -130,6 +130,44 @@ npx -y @tokenroll/llmdoc serve `npx -y @tokenroll/llmdoc help ` 为准。`status` 与 `delta` 用于评估有效性和影响面,不是检索步骤。 +### 启动上下文配置 + +启用 lifecycle hooks 的仓库可以在 llmdoc workspace 根目录增加可选的 +`llmdoc.config.json`;在 Git 仓库中,它就是拥有 `llmdoc/` 的最近 Git 根: + +```json +{ + "$schema": "https://llmdoc.tokenroll.ai/schemas/config.schema.json", + "schema": "llmdoc.config/v1", + "startup": { + "remindSkill": true, + "preload": [ + "architecture.mdx", + "plugin-packaging/claude-and-codex.mdx" + ] + } +} +``` + +- `remindSkill` 控制 SessionStart 是否注入最小操作守则:加载 llmdoc skill、 + 经过 CLI retrieval gate,并委派给 llmdoc roles。默认值为 `true`;显式设为 + `false` 才关闭。 +- `preload` 按顺序列出精确文档 ID,可以带或不带 `llmdoc/` 前缀。冷启动会按 + 声明顺序直接注入完整正文,llmdoc 不设置字符或 token 预算。只有最终完成标记 + 可见时才表示宿主提供了完整 preload;若标记缺失,只对缺少的正文执行 `show`。 +- compact 重入只列出配置的文档 ID,不会再次注入完整正文;优先延续 + `LLMDOC_STATE`,确有需要时才重新读取。 +- `validate` 会报告非法配置、不存在的路径与路径越界;规范化后指向同一文档的 + 别名会去重并产生 warning。hook 保持 fail-open:JSON/schema 无法读取时使用 + 默认提醒;如果只有 preload 项无效,合法的 `remindSkill` 选择仍会保留。 +- `mv` 会事务性重写匹配的 preload 路径;`prune --report` 会列出手工合并或删除 + 文档前必须同步的 preload 引用。 + +没有该文件时,SessionStart 输出状态和默认操作守则,但不预载文档。 + +CLI 自身的固定界面文案全部使用英文,包括 help、诊断、hook message 与本地 +Viewer;中文查询与仓库文档正文仍完整支持,并保持原文返回。 + ## 知识与安全边界 - 稳定知识属于 tracked `llmdoc/`;调查、缓存和反思候选属于本地 `.llmdoc-tmp/`。 @@ -144,8 +182,9 @@ npx -y @tokenroll/llmdoc serve 安全的经验候选,`recorder` 是唯一写入 tracked knowledge 的角色。 - 每条 workflow 都只授权知识维护,不授权源码编辑。结构写入会经过校验,并被限制在 仓库的 `llmdoc/` 边界内。 -- Hooks 通过 scoped CLI 发出只读、fail-open 的信号。启用前先审查 hooks,并 - 确认插件来源可信。 +- Hooks 通过 scoped CLI 发出只读、fail-open 的信号。SessionStart 还会注入 + 可配置的最小操作守则,因此项目无需在 CLAUDE.md/AGENTS.md 重复该引导。 + 启用前先审查 hooks,并确认插件来源可信。 - `delta` 命中表示“复核这条结论”,不是“改写这篇文档”。保留决策及理由、 边界、不变量、契约和非显然失败语义;可重建事实应留在源码、schema、help、 测试或生成配置中。 diff --git a/cli/assets/viewer-app.js b/cli/assets/viewer-app.js index d5c2fb6..6675d46 100644 --- a/cli/assets/viewer-app.js +++ b/cli/assets/viewer-app.js @@ -58,7 +58,7 @@ async function loadState() { const response = await fetch("/api/state", { headers: { accept: "application/json" } }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const nextState = await response.json(); - if (!Array.isArray(nextState.nodes) || !Array.isArray(nextState.edges)) throw new Error("服务返回了无效状态"); + if (!Array.isArray(nextState.nodes) || !Array.isArray(nextState.edges)) throw new Error("The server returned invalid state"); state = nextState; topicColors = createTopicColors(state.nodes); if (selectedDocument && !state.nodes.some((node) => node.path === selectedDocument)) selectedDocument = null; @@ -83,15 +83,15 @@ function renderHeader() { const baseline = state.baseline; const shortRevision = baseline.revision?.slice(0, 7); if (!baseline.revision) { - setChip(elements.statBaseline, "baseline 缺失", "bad"); + setChip(elements.statBaseline, "baseline missing", "bad"); } else if (baseline.degradedReason || baseline.relevantBehindHead === null) { - setChip(elements.statBaseline, `baseline ${shortRevision} · 状态未知`, "warn"); + setChip(elements.statBaseline, `baseline ${shortRevision} · status unknown`, "warn"); } else if (baseline.relevantBehindHead > 0) { - setChip(elements.statBaseline, `baseline ${shortRevision} · ${baseline.relevantBehindHead} 个源码提交待复核`, "warn"); + setChip(elements.statBaseline, `baseline ${shortRevision} · ${baseline.relevantBehindHead} source commit(s) need review`, "warn"); } else if (baseline.metadataOnlyBehind) { - setChip(elements.statBaseline, `baseline ${shortRevision} · metadata-only,知识干净`, "ok"); + setChip(elements.statBaseline, `baseline ${shortRevision} · metadata-only, knowledge clean`, "ok"); } else { - setChip(elements.statBaseline, `baseline ${shortRevision} · 最新`, "ok"); + setChip(elements.statBaseline, `baseline ${shortRevision} · current`, "ok"); } const validation = state.validate; @@ -103,9 +103,9 @@ function renderHeader() { const stale = state.nodes.filter((node) => node.status !== "fresh").length; const unmapped = state.delta.unmappedCommittedPaths.length + state.delta.unmappedDirtyPaths.length; - if (stale) setChip(elements.statDelta, `${stale} 份待同步 · 建议 ${state.delta.suggestedMode}`, "warn"); - else if (unmapped) setChip(elements.statDelta, `${unmapped} 个未映射变化 · 建议 ${state.delta.suggestedMode}`, "warn"); - else setChip(elements.statDelta, "知识面新鲜", "ok"); + if (stale) setChip(elements.statDelta, `${stale} document(s) pending · ${state.delta.suggestedMode} recommended`, "warn"); + else if (unmapped) setChip(elements.statDelta, `${unmapped} unmapped change(s) · ${state.delta.suggestedMode} recommended`, "warn"); + else setChip(elements.statDelta, "knowledge is current", "ok"); } function setChip(chip, text, stateClass) { @@ -154,7 +154,7 @@ function renderSidebar() { fragment.append(item); } } - if (!fragment.childNodes.length) fragment.append(element("div", "empty-list", "没有匹配的文档")); + if (!fragment.childNodes.length) fragment.append(element("div", "empty-list", "No matching documents")); elements.documentList.replaceChildren(fragment); } @@ -195,7 +195,7 @@ async function selectDocument(path) { } function showError(message) { - elements.error.textContent = `Viewer 加载失败:${message}`; + elements.error.textContent = `Viewer load failed: ${message}`; elements.error.hidden = false; } diff --git a/cli/assets/viewer-detail.js b/cli/assets/viewer-detail.js index 87dd04d..a71060d 100644 --- a/cli/assets/viewer-detail.js +++ b/cli/assets/viewer-detail.js @@ -9,7 +9,7 @@ const DROP_TAGS = new Set(["EMBED", "FORM", "IFRAME", "INPUT", "LINK", "META", " export function createDetailRenderer({ panel, container, onSelectDocument, getState }) { let activeRequest = null; - function showPlaceholder(message = "选择一个 topic 或文档查看详情") { + function showPlaceholder(message = "Select a topic or document to view details") { activeRequest?.abort(); const placeholder = element("div", "placeholder", message); container.replaceChildren(placeholder); @@ -31,7 +31,7 @@ export function createDetailRenderer({ panel, container, onSelectDocument, getSt const description = element( "div", "desc", - `${documents.length} docs · ~${tokenCount} tokens · 状态最差 ${worstStatus(documents)}` + `${documents.length} docs · ~${tokenCount} tokens · worst status ${worstStatus(documents)}` ); const cards = documents.map((node) => { const card = element("button", "topic-doc-card"); @@ -52,7 +52,7 @@ export function createDetailRenderer({ panel, container, onSelectDocument, getSt activeRequest?.abort(); activeRequest = new AbortController(); const request = activeRequest; - container.replaceChildren(element("div", "placeholder", "正在加载文档…")); + container.replaceChildren(element("div", "placeholder", "Loading document…")); openPanel(); try { @@ -64,7 +64,7 @@ export function createDetailRenderer({ panel, container, onSelectDocument, getSt } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; const message = error instanceof Error ? error.message : String(error); - container.replaceChildren(element("div", "placeholder", `加载失败:${message}`)); + container.replaceChildren(element("div", "placeholder", `Load failed: ${message}`)); } } diff --git a/cli/assets/viewer-graph.js b/cli/assets/viewer-graph.js index 9b532e1..41e32b8 100644 --- a/cli/assets/viewer-graph.js +++ b/cli/assets/viewer-graph.js @@ -332,11 +332,11 @@ export function createGraphController(options) { function setGraphChrome(mode) { hint.textContent = mode === "topics" - ? "Topic 拓扑 · 边粗细表示跨域引用数 · 点击下钻" - : "文档全图 · 点击查看详情 · 拖动画布 / 滚轮缩放"; + ? "Topic topology · edge width shows cross-domain references · click to drill down" + : "Document graph · click for details · drag to pan / scroll to zoom"; legend.replaceChildren(); const edgeItems = mode === "topics" - ? [["", "跨域引用"]] + ? [["", "Cross-domain references"]] : [["", "requires"], ["related", "related"], ["link", "link"]]; for (const [className, label] of edgeItems) { const item = document.createElement("span"); diff --git a/cli/assets/viewer.html b/cli/assets/viewer.html index 8cc7d24..2c24099 100644 --- a/cli/assets/viewer.html +++ b/cli/assets/viewer.html @@ -12,46 +12,46 @@

llmdoc

-
+
- -
- - + +
+ +
-
diff --git a/cli/package-lock.json b/cli/package-lock.json index c8d9e68..182a5af 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tokenroll/llmdoc", - "version": "3.5.3", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tokenroll/llmdoc", - "version": "3.5.3", + "version": "3.6.0", "dependencies": { "ajv": "^8.17.1", "commander": "^13.1.0", diff --git a/cli/package.json b/cli/package.json index f0799ca..26eb43e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/llmdoc", - "version": "3.5.3", + "version": "3.6.0", "description": "V3 runtime CLI for llmdoc repositories", "homepage": "https://github.com/TokenRollAI/llmdoc", "repository": { diff --git a/cli/schemas/config.schema.json b/cli/schemas/config.schema.json new file mode 100644 index 0000000..ee78154 --- /dev/null +++ b/cli/schemas/config.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://llmdoc.tokenroll.ai/schemas/config.schema.json", + "type": "object", + "additionalProperties": false, + "required": ["schema"], + "properties": { + "$schema": { + "type": "string" + }, + "schema": { + "type": "string", + "const": "llmdoc.config/v1" + }, + "startup": { + "type": "object", + "additionalProperties": false, + "properties": { + "remindSkill": { + "type": "boolean", + "default": true + }, + "preload": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "default": [] + } + } + } + } +} diff --git a/cli/schemas/output.schema.json b/cli/schemas/output.schema.json index 8de54a9..5e331de 100644 --- a/cli/schemas/output.schema.json +++ b/cli/schemas/output.schema.json @@ -727,6 +727,7 @@ "status", "writable", "growth", + "startupPreloads", "duplicateCandidates", "mergeCandidates" ], @@ -742,6 +743,13 @@ "growth": { "$ref": "#/$defs/growth" }, + "startupPreloads": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "duplicateCandidates": { "type": "array", "items": { @@ -912,6 +920,7 @@ "additionalProperties": false, "required": [ "moved", + "rewrittenConfig", "rewrittenDocuments" ], "properties": { @@ -939,6 +948,9 @@ "type": "string", "minLength": 1 } + }, + "rewrittenConfig": { + "type": "boolean" } } }, diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 10b7485..c82d291 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -50,42 +50,42 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .name("llmdoc") - .description("面向 LLM 的项目知识库 CLI:渐进检索 llmdoc/ 文档,维护 revision 台账") - .version(readPackageVersion(), "--version", "输出 CLI 版本") - .helpOption("-h, --help", "显示帮助") - .helpCommand("help [command]", "显示指定命令的帮助") - .showHelpAfterError("(用 --help 查看用法)"); + .description("Project knowledge CLI for LLMs: progressively retrieve llmdoc/ documents and maintain the revision ledger") + .version(readPackageVersion(), "--version", "output the CLI version") + .helpOption("-h, --help", "display help") + .helpCommand("help [command]", "display help for a command") + .showHelpAfterError("(use --help to view usage)"); program - .option("--json", "以 JSON 输出(经 schema 校验)") - .option("--cursor ", "上次输出截断处的游标,从该处继续") - .option("--budget ", "输出 token 预算,超出即截断并返回 cursor", parseInteger) - .option("--limit ", "最多返回条数", parseInteger); + .option("--json", "emit schema-validated JSON") + .option("--cursor ", "continue from a cursor returned by a truncated response") + .option("--budget ", "truncate output at this token budget and return a cursor", parseInteger) + .option("--limit ", "maximum number of items to return", parseInteger); program.addHelpText( "after", [ "", - "按用途速查:", - " 检索(只读) tree → index / search / context → show", - " 状态诊断 status · delta · validate", - " 结构改写 new · adopt · mv · fingerprint · init-state · commit", - " 维护诊断 prune · upgrade", - " 集成 hook · serve", + "Quick reference by purpose:", + " Retrieval (read-only) tree → index / search / context → show", + " State diagnostics status · delta · validate", + " Structural mutation new · adopt · mv · fingerprint · init-state · commit", + " Maintenance prune · upgrade", + " Integration hook · serve", "", - "常用示例:", - " llmdoc tree --docs 全局地图,展开到文档级", - " llmdoc search \"重试策略\" --limit 5 词法检索文档", - " llmdoc context --files src/api/retry.ts 按源码反查应读文档", - " llmdoc show api-client/retry-policy.mdx 读取正文", - " llmdoc commit -m \"docs: ...\" 校验并提交 llmdoc 写集", + "Common examples:", + " llmdoc tree --docs expand the global map to documents", + " llmdoc search \"retry policy\" --limit 5 search documents lexically", + " llmdoc context --files src/api/retry.ts map source files to documents to read", + " llmdoc show api-client/retry-policy.mdx read selected bodies", + " llmdoc commit -m \"docs: ...\" validate and commit the llmdoc write set", "", - "所有检索命令支持 --json / --budget / --limit;输出被截断时带 --cursor 继续。" + "All retrieval commands support --json / --budget / --limit; use --cursor to continue truncated output." ].join("\n") ); program .command("tree") - .description("输出 llmdoc 全局地图(默认停在 topic 层)") - .option("--docs", "展开到文档级") + .description("output the global llmdoc map (topics by default)") + .option("--docs", "expand to document level") .action((commandOptions) => { const rootDir = findProjectRoot(cwd); const result = runTree({ ...globalOptions, ...commandOptions, cwd: rootDir }); @@ -94,9 +94,9 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("index") - .description("列出文档元数据索引(不读正文即可判断相关性)") - .option("--topic ", "只列指定 topic 下的文档") - .option("--kind ", "只列指定类型: architecture | guide | reference") + .description("list document metadata without reading bodies") + .option("--topic ", "list documents only under this topic") + .option("--kind ", "filter by kind: architecture | guide | reference") .action((commandOptions) => { const rootDir = findProjectRoot(cwd); output.push(writeOutput("index", runIndex({ ...globalOptions, ...commandOptions, cwd: rootDir }), globalOptions.json)); @@ -104,8 +104,8 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("show") - .description("读取一个或多个文档正文") - .argument("", "llmdoc/ 下的相对路径,如 api-client/retry-policy.mdx") + .description("read one or more document bodies") + .argument("", "paths relative to llmdoc/, such as api-client/retry-policy.mdx") .action((paths) => { const rootDir = findProjectRoot(cwd); output.push(writeOutput("show", runShow({ ...globalOptions, cwd: rootDir, paths }), globalOptions.json)); @@ -113,10 +113,10 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("search") - .description("按词法检索 llmdoc 文档(自动中文分词,必要时使用 CJK bigram 降级)") - .argument("", "检索词") - .option("--topic ", "限定 topic") - .option("--kind ", "限定类型: architecture | guide | reference") + .description("search llmdoc documents lexically (Chinese segmentation with CJK bigram fallback)") + .argument("", "search query") + .option("--topic ", "limit to a topic") + .option("--kind ", "filter by kind: architecture | guide | reference") .action((query, commandOptions) => { const rootDir = findProjectRoot(cwd); output.push(writeOutput("search", runSearch({ ...globalOptions, ...commandOptions, cwd: rootDir, query }), globalOptions.json)); @@ -124,8 +124,8 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("context") - .description("按源码文件反查应读文档(含 requires 前置闭包)") - .requiredOption("--files ", "源码文件路径,可多个") + .description("map source files to documents to read, including the requires closure") + .requiredOption("--files ", "one or more source file paths") .action((commandOptions) => { const rootDir = findProjectRoot(cwd); output.push( @@ -143,7 +143,7 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("validate") - .description("校验 llmdoc 结构与引用") + .description("validate llmdoc structure and references") .action((commandOptions) => { const rootDir = findProjectRoot(cwd); const result = runValidate({ ...globalOptions, ...commandOptions, cwd: rootDir }); @@ -153,7 +153,7 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("status") - .description("查看 baseline、dirty 与增长状态") + .description("inspect baseline, dirty, and growth state") .action((commandOptions) => { const rootDir = findProjectRoot(cwd); output.push(writeOutput("status", runStatus({ ...globalOptions, ...commandOptions, cwd: rootDir }), globalOptions.json)); @@ -161,8 +161,8 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("delta") - .description("查看代码变化对应的文档影响面(决定 update 走 light 还是 deep)") - .option("--scope ", "限定参与比对的代码路径") + .description("inspect document impacts from code changes and choose light or deep update mode") + .option("--scope ", "limit comparison to selected topics or documents") .action((commandOptions) => { const rootDir = findProjectRoot(cwd); output.push(writeOutput("delta", runDelta({ ...globalOptions, ...commandOptions, cwd: rootDir }), globalOptions.json)); @@ -170,9 +170,9 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("fingerprint") - .description("把文档 validatedRevision 刷新到当前 HEAD") - .option("--update ", "只刷新指定文档") - .option("--all", "刷新全部文档并推进 baseline") + .description("refresh document validatedRevision values to the current HEAD") + .option("--update ", "refresh only selected documents") + .option("--all", "refresh every document and advance the baseline") .action((commandOptions) => { const rootDir = findProjectRoot(cwd); output.push( @@ -191,10 +191,10 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("init-state") - .description("首次生成 llmdoc/meta.json 台账骨架(validatedRevision 全部为 null)") + .description("create the initial llmdoc/meta.json ledger with null validatedRevision values") .addHelpText( "after", - "\n前置: Git HEAD 必须已有真实 commit。生成后先 validate,再用 commit --all 完成 bootstrap。" + "\nPrerequisite: Git HEAD must reference a real commit. Then run validate and finish bootstrap with commit --all." ) .action(async () => { const { runInitState } = await import("./commands/init-state.js"); @@ -204,11 +204,11 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("commit") - .description("一体化收尾:validate 门控 → 可选提交正文 → 刷新 changed/verified revision → meta 小 commit") + .description("finalize atomically: validate, optionally commit prose, refresh revisions, and commit metadata") .option("-m, --message ", "docs commit message") - .option("--all", "fingerprint 全部文档并推进 baseline") - .option("--verified ", "正文复核后无需改写,刷新指定文档的 validatedRevision") - .option("--no-verify", "透传 git commit --no-verify(husky 等重钩子仓库)") + .option("--all", "fingerprint every document and advance the baseline") + .option("--verified ", "refresh validatedRevision for reviewed documents whose bodies did not change") + .option("--no-verify", "pass --no-verify through to git commit") .action(async (commandOptions) => { const { runCommit } = await import("./commands/commit.js"); const rootDir = findProjectRoot(cwd); @@ -230,13 +230,13 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("new") - .description("在 llmdoc/ 下生成文档脚手架") - .argument("", "目标相对路径,如 api-client/retry-policy.mdx") - .requiredOption("--kind ", "文档类型: architecture | guide | reference") - .option("--description ", "front matter 一句话描述") + .description("scaffold a document under llmdoc/") + .argument("", "target path, such as api-client/retry-policy.mdx") + .requiredOption("--kind ", "document kind: architecture | guide | reference") + .option("--description ", "one-line front matter description") .addHelpText( "after", - "\n首次创建时会自动建立 llmdoc/;完成初始文档后运行 init-state → validate → commit --all。" + "\nThe first document creates llmdoc/ automatically. Finish bootstrap with init-state → validate → commit --all." ) .action((targetPath, commandOptions) => { output.push( @@ -256,8 +256,8 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("adopt") - .description("无损登记已存在的 .mdx 到 meta.json(validatedRevision: null,不改正文,幂等)") - .argument("", "已存在的 llmdoc/ 下相对路径,可多个") + .description("register existing .mdx documents in meta.json without rewriting them") + .argument("", "one or more existing paths under llmdoc/") .action(async (paths) => { const { runAdopt } = await import("./commands/adopt.js"); const rootDir = findProjectRoot(cwd); @@ -266,9 +266,9 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("mv") - .description("移动/重命名文档或整个 topic,并重写内部引用") - .argument("", "源路径") - .argument("", "目标路径") + .description("move or rename a document or topic and rewrite internal references") + .argument("", "source path") + .argument("", "target path") .action((from, to) => { const rootDir = findProjectRoot(cwd); output.push(writeOutput("mv", runMove({ ...globalOptions, cwd: rootDir, from, to }), globalOptions.json)); @@ -276,8 +276,8 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("prune") - .description("输出只读收敛报告(增长趋势、重复候选、小文档合并候选)") - .option("--report", "输出只读收敛报告") + .description("output a read-only convergence report") + .option("--report", "output the read-only report") .action((commandOptions) => { const rootDir = findProjectRoot(cwd); output.push(writeOutput("prune", runPrune({ ...globalOptions, cwd: rootDir, report: commandOptions.report }), globalOptions.json)); @@ -285,31 +285,31 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("upgrade") - .description("盘点 legacy/V2 到 V3 的迁移需求") + .description("inventory legacy/V2 to V3 migration needs") .action(async (commandOptions) => { const { runUpgrade } = await import("./commands/upgrade.js"); const rootDir = findProjectRootOrNull(cwd) ?? cwd; output.push(writeOutput("upgrade", await runUpgrade({ ...globalOptions, ...commandOptions, cwd: rootDir }), globalOptions.json)); }); - const hookCommand = program.command("hook").description("供编辑器/Agent hooks 调用的只读信号(异常时 fail-open)"); + const hookCommand = program.command("hook").description("read-only, fail-open signals for editor and Agent hooks"); hookCommand .command("session-start") - .description("输出 SessionStart 短状态信号") + .description("output SessionStart state and apply optional llmdoc.config.json startup config") .action(() => { const rootDir = findProjectRootOrNull(cwd) ?? cwd; output.push(runHook({ cwd: rootDir, mode: "session-start", stdin })); }); hookCommand .command("stop") - .description("输出 Stop hook JSON 提醒") + .description("output the Stop hook JSON reminder") .action(() => { const rootDir = findProjectRootOrNull(cwd) ?? cwd; output.push(parseAndValidateJsonString("hook", runHook({ cwd: rootDir, mode: "stop", stdin }))); }); hookCommand .command("compact") - .description("输出 PreCompact hook JSON 指令") + .description("output the PreCompact hook JSON instruction") .action(() => { const rootDir = findProjectRootOrNull(cwd) ?? cwd; output.push(parseAndValidateJsonString("hook", runHook({ cwd: rootDir, mode: "compact", stdin }))); @@ -317,8 +317,8 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P program .command("serve") - .description("启动本地 Web Viewer(HTTP 服务,浏览文档结构与关联,Ctrl-C 退出)") - .option("--port ", "监听端口", parseInteger) + .description("start the local Web Viewer; press Ctrl-C to stop") + .option("--port ", "listening port", parseInteger) .action(async (commandOptions) => { const { runServe } = await import("./commands/serve.js"); const rootDir = findProjectRoot(cwd); @@ -357,7 +357,7 @@ function writeOutput(schemaName: OutputSchemaName, value: unknown, expectJson = function parseInteger(input: string): number { const value = Number.parseInt(input, 10); if (Number.isNaN(value) || value <= 0) { - throw new CliError(`非法整数: ${input}`); + throw new CliError(`Invalid integer: ${input}`); } return value; } diff --git a/cli/src/commands/adopt.ts b/cli/src/commands/adopt.ts index ddc58aa..0d02063 100644 --- a/cli/src/commands/adopt.ts +++ b/cli/src/commands/adopt.ts @@ -18,22 +18,22 @@ export function runAdopt(options: AdoptOptions): unknown { const rootDir = findProjectRoot(options.cwd); const workspace = loadWorkspace(rootDir); if (!workspace.meta) { - throw new CliError("缺少 llmdoc/meta.json,先运行 llmdoc init-state。"); + throw new CliError("llmdoc/meta.json is missing; run llmdoc init-state first."); } const llmdocPaths = options.paths.map((input) => { const repoRelativePath = normalizeRepoRelativePath(input.startsWith("llmdoc/") ? input : `llmdoc/${input}`); if (!repoRelativePath.startsWith("llmdoc/") || !repoRelativePath.endsWith(".mdx")) { - throw new CliError(`adopt 只能登记 llmdoc/ 下的 .mdx 文档: ${input}`); + throw new CliError(`adopt can register only .mdx documents under llmdoc/: ${input}`); } const shape = parseDocTargetShape(repoRelativePath); assertDocKindMatchesShape(shape); const absolutePath = resolveInsideRoot(rootDir, repoRelativePath, { allowMissing: true }); if (!fs.existsSync(absolutePath)) { - throw new CliError(`目标不存在,adopt 只登记已有文档(新建请用 llmdoc new): ${repoRelativePath}`); + throw new CliError(`Target does not exist; adopt registers existing documents only (use llmdoc new to create one): ${repoRelativePath}`); } if (!workspace.documentsByLlmdocPath.has(shape.llmdocPath)) { - throw new CliError(`文档未被 workspace 识别(front matter 可能不合法): ${repoRelativePath}`); + throw new CliError(`The workspace did not recognize the document (its front matter may be invalid): ${repoRelativePath}`); } return shape.llmdocPath; }); @@ -45,7 +45,7 @@ export function runAdopt(options: AdoptOptions): unknown { ); if (blockingIssues.length > 0) { throw new CliError( - `目标文档未通过校验,拒绝登记:\n${blockingIssues.map((issue) => ` ${issue.code} (${issue.path}) ${issue.message}`).join("\n")}` + `Target documents failed validation; refusing to register them:\n${blockingIssues.map((issue) => ` ${issue.code} (${issue.path}) ${issue.message}`).join("\n")}` ); } @@ -74,7 +74,7 @@ export function runAdopt(options: AdoptOptions): unknown { lines.push(`already registered (no-op): ${alreadyRegistered.join(", ")}`); } if (adopted.length > 0) { - lines.push("validatedRevision 登记为 null;经 validate 后用 fingerprint/commit 写入有效 revision。"); + lines.push("validatedRevision was registered as null; after validation, use fingerprint/commit to write a valid revision."); } return lines.join("\n"); } diff --git a/cli/src/commands/commit.ts b/cli/src/commands/commit.ts index 0f2edae..43e64cc 100644 --- a/cli/src/commands/commit.ts +++ b/cli/src/commands/commit.ts @@ -24,24 +24,24 @@ export function runCommit(options: CommitOptions): unknown { (options.verified ?? []).map((value) => (value.startsWith("llmdoc/") ? value.slice("llmdoc/".length) : value)) ); if (options.all && verifiedPaths.length > 0) { - throw new CliError("commit 不能同时使用 --all 与 --verified。"); + throw new CliError("commit cannot use --all and --verified together."); } for (const docPath of verifiedPaths) { if (!preflightWorkspace.documentsByLlmdocPath.has(docPath)) { - throw new CliError(`文档不存在: ${docPath}`); + throw new CliError(`Document does not exist: ${docPath}`); } } const issues = validateWorkspace(preflightWorkspace); const errors = issues.filter((issue) => issue.severity === "error"); if (errors.length > 0) { - throw new CliError(`validate 未通过,拒绝提交:\n${errors.map((issue) => ` ${issue.code} (${issue.path})`).join("\n")}`); + throw new CliError(`Validation failed; refusing to commit:\n${errors.map((issue) => ` ${issue.code} (${issue.path})`).join("\n")}`); } const porcelain = runGit(rootDir, ["status", "--porcelain", "--", "llmdoc"]); const hasLlmdocChanges = Boolean(porcelain.trim()); if (!hasLlmdocChanges && !options.all && verifiedPaths.length === 0) { - return options.json ? { status: "no_change", commits: [], updated: [] } : "no_change: llmdoc/ 无待提交变更"; + return options.json ? { status: "no_change", commits: [], updated: [] } : "no_change: llmdoc/ has no changes to commit"; } const changedDocPaths = porcelain .split(/\r?\n/) @@ -65,7 +65,7 @@ export function runCommit(options: CommitOptions): unknown { updateAll: options.all ?? false }); } catch (error) { - throw new CliError(`fingerprint 预检未通过,未创建任何 commit: ${(error as Error).message}`, 70); + throw new CliError(`Fingerprint preflight failed; no commit was created: ${(error as Error).message}`, 70); } if (changedDocPaths.length === 0) { @@ -191,7 +191,7 @@ function uniquePaths(paths: string[]): string[] { function runGit(rootDir: string, args: string[]): string { const result = spawnSync("git", ["-c", "core.quotePath=false", ...args], { cwd: rootDir, encoding: "utf8" }); if (result.status !== 0) { - throw new CliError((result.stderr || result.stdout || `git ${args[0]} 失败`).trim()); + throw new CliError((result.stderr || result.stdout || `git ${args[0]} failed`).trim()); } return result.stdout; } diff --git a/cli/src/commands/context.ts b/cli/src/commands/context.ts index 5c2eed8..36826bc 100644 --- a/cli/src/commands/context.ts +++ b/cli/src/commands/context.ts @@ -14,7 +14,7 @@ interface ContextOptions extends OutputOptions { export function runContext(options: ContextOptions): unknown { if (options.files.length === 0) { - throw new CliError("context 需要至少一个 --files 输入。"); + throw new CliError("context requires at least one --files input."); } const workspace = loadWorkspace(options.cwd); diff --git a/cli/src/commands/fingerprint.ts b/cli/src/commands/fingerprint.ts index 1728fa7..961d930 100644 --- a/cli/src/commands/fingerprint.ts +++ b/cli/src/commands/fingerprint.ts @@ -14,16 +14,16 @@ export function runFingerprint(options: FingerprintOptions): unknown { const targetPaths = options.update?.map((value) => (value.startsWith("llmdoc/") ? value.slice("llmdoc/".length) : value)) ?? []; if (options.all && targetPaths.length > 0) { - throw new CliError("fingerprint 不能同时使用 --all 与 --update。"); + throw new CliError("fingerprint cannot use --all and --update together."); } if (!options.all && targetPaths.length === 0) { - throw new CliError("fingerprint 需要 --all 或 --update 。"); + throw new CliError("fingerprint requires --all or --update ."); } for (const docPath of targetPaths) { if (!workspace.documentsByLlmdocPath.has(docPath)) { - throw new CliError(`文档不存在: ${docPath}`); + throw new CliError(`Document does not exist: ${docPath}`); } } diff --git a/cli/src/commands/hook.ts b/cli/src/commands/hook.ts index 0ae7522..4c6e7ee 100644 --- a/cli/src/commands/hook.ts +++ b/cli/src/commands/hook.ts @@ -3,6 +3,8 @@ import path from "node:path"; import { analyzeDelta } from "../lib/state.js"; import { loadWorkspace } from "../lib/workspace.js"; +import { formatCompactPreloadIndex, formatStartupPreloadDocuments, loadLlmdocConfig } from "../lib/config.js"; +import type { LoadedLlmdocConfig } from "../types.js"; interface HookOptions { cwd: string; @@ -10,6 +12,10 @@ interface HookOptions { stdin: string; } +// Minimal SessionStart guidance; the complete protocol remains in the llmdoc skill. +const SESSION_START_GUIDANCE = + "Operating guidance: Before broad exploration, planning, or documentation work, load the llmdoc skill and retrieve through the CLI instead of crawling the whole repository; align with the user before non-trivial plans or edits; proactively delegate to subagents (investigator for current-state and unfamiliar subsystems, recorder exclusively for stable llmdoc/ writes, reflector for workflow lessons); when a task produces durable knowledge, finish with /llmdoc:update."; + export function runHook(options: HookOptions): string { try { const hasLlmdoc = fs.existsSync(path.join(options.cwd, "llmdoc")); @@ -51,29 +57,68 @@ function runSessionStart(cwd: string, stdin: string): string { // fingerprint/commit 正常收尾会让 HEAD 前进到仅修改 llmdoc/meta.json 的 follow-up commit。 // 没有可执行影响时不展示 raw baseline 落后数,避免把知识面自身更新误报成需要再次 update。 if (!signal.shouldUpdate) { - parts.push("文档无待处理影响"); + parts.push("documents have no actionable impacts"); + if (pendingLessonCandidates > 0) { + parts.push(`${pendingLessonCandidates} pending reflection candidate(s)`); + } + } else { + const baseline = workspace.meta?.baseline.revision ? workspace.meta.baseline.revision.slice(0, 7) : "missing"; + const freshness = delta.git.baselineBehindHead === null ? "distance unknown" : delta.git.baselineBehindHead === 0 ? "at HEAD" : `${delta.git.baselineBehindHead} commit(s) behind HEAD`; + parts.push(`baseline ${baseline}(${freshness})`); + const summary = [ + signal.impactedCount > 0 ? `${signal.impactedCount} impacted document(s)` : null, + signal.needsReviewCount > 0 ? `${signal.needsReviewCount} document(s) need review` : null, + signal.unmappedCount > 0 ? `${signal.unmappedCount} unmapped code path(s)` : null + ] + .filter(Boolean) + .join(", "); + parts.push(`${summary} → inspect npx @tokenroll/llmdoc delta first`); if (pendingLessonCandidates > 0) { - parts.push(`待处理反思候选 ${pendingLessonCandidates} 个`); + parts.push(`${pendingLessonCandidates} pending reflection candidate(s)`); } - return parts.join("; "); } - const baseline = workspace.meta?.baseline.revision ? workspace.meta.baseline.revision.slice(0, 7) : "缺失"; - const freshness = delta.git.baselineBehindHead === null ? "落后未知" : delta.git.baselineBehindHead === 0 ? "与 HEAD 同步" : `落后 HEAD ${delta.git.baselineBehindHead} commit`; - parts.push(`baseline ${baseline}(${freshness})`); - const summary = [ - signal.impactedCount > 0 ? `受影响 ${signal.impactedCount} 篇` : null, - signal.needsReviewCount > 0 ? `待复核 ${signal.needsReviewCount} 篇` : null, - signal.unmappedCount > 0 ? `未映射代码路径 ${signal.unmappedCount} 个` : null - ] - .filter(Boolean) - .join(", "); - parts.push(`${summary} → 建议先看 npx @tokenroll/llmdoc delta`); - if (pendingLessonCandidates > 0) { - parts.push(`待处理反思候选 ${pendingLessonCandidates} 个`); + + const context = [parts.join("; ")]; + const startupConfig = workspace.llmdocConfig; + if (startupConfig.config?.startup?.remindSkill ?? true) { + context.push(SESSION_START_GUIDANCE); } - return parts.join("; "); + appendStartupConfigIssues(context, startupConfig); + const preloaded = + source === "compact" + ? formatCompactPreloadIndex(startupConfig.preloadPaths) + : formatStartupPreloadDocuments( + startupConfig.preloadPaths.map((preloadPath) => workspace.documentsByLlmdocPath.get(preloadPath)!) + ); + if (preloaded) { + context.push(preloaded); + } + return context.join("\n\n"); } catch { - return `llmdoc ${lifecycle}; 状态读取失败,检索不受影响`; + const context = [`llmdoc ${lifecycle}; status read failed, retrieval remains available`]; + const startupConfig = loadLlmdocConfig(cwd); + if (startupConfig.config?.startup?.remindSkill ?? true) { + context.push(SESSION_START_GUIDANCE); + } + appendStartupConfigIssues(context, startupConfig); + return context.join("\n\n"); + } +} + +function appendStartupConfigIssues(context: string[], startupConfig: LoadedLlmdocConfig): void { + const errors = startupConfig.issues.filter((issue) => issue.severity === "error"); + const warnings = startupConfig.issues.filter((issue) => issue.severity === "warning"); + if (errors.length > 0) { + context.push( + startupConfig.config + ? "llmdoc.config.json has invalid startup.preload entries; preload was skipped while the valid remindSkill preference remained applied. Run npx @tokenroll/llmdoc validate for details." + : "llmdoc.config.json could not be applied; preload was skipped and the default skill reminder remains active. Run npx @tokenroll/llmdoc validate for details." + ); + } + if (warnings.length > 0) { + context.push( + "llmdoc.config.json has warnings; normalized duplicate startup.preload entries were ignored. Run npx @tokenroll/llmdoc validate for details." + ); } } @@ -87,24 +132,24 @@ function runStop(cwd: string): object { return { continue: true }; } const summary = [ - signal.impactedCount > 0 ? `${signal.impactedCount} 篇文档受代码变更影响` : null, - signal.needsReviewCount > 0 ? `${signal.needsReviewCount} 篇文档需要复核` : null, - signal.unmappedCount > 0 ? `${signal.unmappedCount} 个代码路径未映射到任何文档` : null, - pendingLessonCandidates > 0 ? `${pendingLessonCandidates} 个反思候选待处理` : null + signal.impactedCount > 0 ? `${signal.impactedCount} document(s) impacted by code changes` : null, + signal.needsReviewCount > 0 ? `${signal.needsReviewCount} document(s) need review` : null, + signal.unmappedCount > 0 ? `${signal.unmappedCount} code path(s) are not mapped to any document` : null, + pendingLessonCandidates > 0 ? `${pendingLessonCandidates} reflection candidate(s) pending` : null ] .filter(Boolean) .join(", "); - const reasons = delta.reasons.length > 0 ? `信号: ${delta.reasons.join("; ")}` : ""; + const reasons = delta.reasons.length > 0 ? `Signals: ${delta.reasons.join("; ")}` : ""; const updateCommand = pendingLessonCandidates > 0 ? "/llmdoc:update --reflection" : "/llmdoc:update"; const guidance = pendingLessonCandidates > 0 ? signal.shouldUpdate - ? "workflow 会读取 pending 候选;代码影响面可先用 npx @tokenroll/llmdoc delta 查看" - : "workflow 会读取 pending 候选" - : "先用 npx @tokenroll/llmdoc delta 查看影响面"; + ? "the workflow will read pending candidates; inspect code impact with npx @tokenroll/llmdoc delta first" + : "the workflow will read pending candidates" + : "inspect impact with npx @tokenroll/llmdoc delta first"; return { continue: true, - systemMessage: `llmdoc: ${summary},建议运行 ${updateCommand}(${guidance})。${reasons}` + systemMessage: `llmdoc: ${summary}. Consider running ${updateCommand} (${guidance}). ${reasons}`.trim() }; } catch (error) { return { @@ -135,7 +180,7 @@ function runCompact(): object { return { continue: true, systemMessage: - "即将 compact:请在 summary 中写入 LLMDOC_STATE(active goal、已读 llmdoc 文档、关键结论与不变量、用户决策、lesson_candidates、next step、open risks)。恢复后若该状态仍充分,直接继续,不要重放 tree/show。" + "Compaction is imminent. Preserve LLMDOC_STATE in the summary (active goal, llmdoc documents already read, key conclusions and invariants, user decisions, lesson_candidates, next step, open risks). After resuming, continue directly when that state is sufficient; do not replay tree/show." }; } diff --git a/cli/src/commands/init-state.ts b/cli/src/commands/init-state.ts index 4a09406..86dd2e2 100644 --- a/cli/src/commands/init-state.ts +++ b/cli/src/commands/init-state.ts @@ -19,19 +19,19 @@ export function runInitState(options: InitStateOptions): unknown { const rootDir = findProjectRoot(options.cwd); const workspace = loadWorkspace(rootDir); if (workspace.meta) { - throw new CliError("llmdoc/meta.json 已存在;init-state 只用于首次建立台账,不覆盖现有状态。"); + throw new CliError("llmdoc/meta.json already exists; init-state creates only the initial ledger and never overwrites it."); } const git = readWorkspaceGitState(workspace); if (!git.available) { - throw new CliError("init-state 需要 Git 仓库;请先运行 `git init` 并创建一次真实的初始提交。"); + throw new CliError("init-state requires a Git repository. Run `git init` and create a real initial commit first."); } if (!git.headRevision) { if (isUnbornHead(rootDir)) { throw new CliError( - "HEAD 尚无 commit(当前分支尚未创建首次提交)。请先创建一次真实的初始提交;空仓库可运行 `git commit --allow-empty -m \"chore: initial commit\"`。" + "HEAD has no commit (the current branch is unborn). Create a real initial commit first; an empty repository can run `git commit --allow-empty -m \"chore: initial commit\"`." ); } - throw new CliError(`${git.degradedReason ?? "无法解析 HEAD commit。"}请先修复 Git HEAD,再运行 init-state。`); + throw new CliError(`${git.degradedReason ?? "Unable to resolve the HEAD commit."} Repair Git HEAD before running init-state.`); } const now = new Date().toISOString().replace(/\.\d+Z$/, "Z"); @@ -60,5 +60,5 @@ export function runInitState(options: InitStateOptions): unknown { "npx -y @tokenroll/llmdoc validate && npx -y @tokenroll/llmdoc commit --all -m \"docs: bootstrap llmdoc\"" }; } - return `initialized llmdoc/meta.json: ${workspace.documents.length} documents (validatedRevision: null), baseline ${git.headRevision.slice(0, 7)}\nnext: 先运行 \`npx -y @tokenroll/llmdoc validate\`;全部通过后运行 \`npx -y @tokenroll/llmdoc commit --all -m "docs: bootstrap llmdoc"\` 收尾`; + return `initialized llmdoc/meta.json: ${workspace.documents.length} documents (validatedRevision: null), baseline ${git.headRevision.slice(0, 7)}\nnext: Run \`npx -y @tokenroll/llmdoc validate\` first. After it passes, finish with \`npx -y @tokenroll/llmdoc commit --all -m "docs: bootstrap llmdoc"\`.`; } diff --git a/cli/src/commands/mv.ts b/cli/src/commands/mv.ts index 53f3e90..a3fafe3 100644 --- a/cli/src/commands/mv.ts +++ b/cli/src/commands/mv.ts @@ -7,6 +7,7 @@ import { findProjectRoot, normalizeRepoRelativePath, repoPath, resolveInsideRoot import { gitMove, gitRestorePaths } from "../lib/git.js"; import { MoveMapping, updateDocumentForMove, writeFileIfChanged } from "../lib/rewrite.js"; import { loadWorkspace } from "../lib/workspace.js"; +import { LLMDOC_CONFIG_FILENAME, rewriteStartupPreloadForMove } from "../lib/config.js"; import { MetaLedger, ParsedDocument } from "../types.js"; interface MoveOptions { @@ -23,25 +24,25 @@ export function runMove(options: MoveOptions): unknown { const fromAbsolutePath = resolveInsideRoot(rootDir, fromRepoPath); if (!fromRepoPath.startsWith("llmdoc/") || !toRepoPath.startsWith("llmdoc/")) { - throw new CliError("mv 仅支持 llmdoc/ 内部移动。"); + throw new CliError("mv supports moves only within llmdoc/."); } if (fromRepoPath === "llmdoc/meta.json" || toRepoPath === "llmdoc/meta.json") { - throw new CliError("mv 不允许移动 meta.json。"); + throw new CliError("mv cannot move meta.json."); } const fromStats = fs.statSync(fromAbsolutePath); const beforeWorkspace = loadWorkspace(rootDir); if (fromStats.isDirectory()) { if (!isDirectTopicDirectory(fromRepoPath) || !isDirectTopicDirectory(toRepoPath)) { - throw new CliError("topic 目录重命名仅允许 llmdoc/ -> llmdoc/。"); + throw new CliError("Topic renames must use llmdoc/ -> llmdoc/."); } } else { if (!fromRepoPath.endsWith(".mdx") || !toRepoPath.endsWith(".mdx")) { - throw new CliError("mv 仅允许移动 .mdx 文档。"); + throw new CliError("mv can move only .mdx documents."); } const fromDocument = beforeWorkspace.documents.find((document) => document.repoPath === fromRepoPath); if (!fromDocument) { - throw new CliError("mv 仅允许移动已索引的 llmdoc 文档。"); + throw new CliError("mv can move only indexed llmdoc documents."); } const targetShape = parseDocTargetShape(toRepoPath); validateMoveTargetShape(targetShape); @@ -49,10 +50,15 @@ export function runMove(options: MoveOptions): unknown { const toAbsolutePath = resolveInsideRoot(rootDir, toRepoPath, { allowMissing: true }); if (fs.existsSync(toAbsolutePath)) { - throw new CliError(`目标已存在: ${toRepoPath}`); + throw new CliError(`Target already exists: ${toRepoPath}`); } const mapping = buildMoveMapping(beforeWorkspace.documents, beforeWorkspace.llmdocDir, fromAbsolutePath, toAbsolutePath); + const configRewrite = beforeWorkspace.llmdocConfig.config + ? rewriteStartupPreloadForMove(beforeWorkspace.llmdocConfig.config, mapping) + : { config: null, changed: false }; + const configPath = path.join(rootDir, LLMDOC_CONFIG_FILENAME); + const originalConfig = configRewrite.changed ? fs.readFileSync(configPath, "utf8") : null; // 目标 topic 目录不存在时自动创建(topic 即纯目录,没有入口节点前置要求)。 fs.mkdirSync(path.dirname(toAbsolutePath), { recursive: true }); gitMove(rootDir, fromRepoPath, toRepoPath); @@ -88,13 +94,16 @@ export function runMove(options: MoveOptions): unknown { const nextMeta = rewriteMeta(refreshedWorkspace.meta, mapping); fs.writeFileSync(refreshedWorkspace.metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`); } + if (configRewrite.changed && configRewrite.config) { + fs.writeFileSync(configPath, `${JSON.stringify(configRewrite.config, null, 2)}\n`); + } } catch (error) { - const rolledBack = rollbackMove(rootDir, toAbsolutePath); + const rolledBack = rollbackMove(rootDir, toAbsolutePath, configPath, originalConfig); const reason = error instanceof Error ? error.message : String(error); throw new CliError( rolledBack - ? `mv 引用重写阶段失败,llmdoc/ 已回滚到移动前状态: ${reason}` - : `mv 引用重写阶段失败,且自动回滚未完成,请检查 git status -- llmdoc/ 后手动恢复: ${reason}` + ? `Reference rewriting failed during mv; llmdoc/ was rolled back to its previous state: ${reason}` + : `Reference rewriting failed during mv and automatic rollback did not complete. Inspect git status -- llmdoc/ llmdoc.config.json and recover manually: ${reason}` ); } @@ -104,10 +113,11 @@ export function runMove(options: MoveOptions): unknown { from: fromRepoPath, to: toRepoPath }, + rewrittenConfig: configRewrite.changed, rewrittenDocuments: [...documentTargets].map((absolutePath) => repoPath(rootDir, absolutePath)).sort() }; } - return `moved: ${fromRepoPath} -> ${toRepoPath}`; + return `moved: ${fromRepoPath} -> ${toRepoPath}${configRewrite.changed ? "; updated llmdoc.config.json startup preload" : ""}`; } function buildMoveMapping( @@ -127,7 +137,7 @@ function buildMoveMapping( return relative.replaceAll(path.sep, "/"); } } - throw new CliError(`无法从路径推导 llmdoc 相对路径: ${absolutePath}`); + throw new CliError(`Unable to derive an llmdoc-relative path from: ${absolutePath}`); }; const fromPathIsDirectory = fs.statSync(fromAbsolutePath).isDirectory(); @@ -153,10 +163,13 @@ function buildMoveMapping( return mapping.sort((left, right) => right.oldLlmdocPath.length - left.oldLlmdocPath.length); } -function rollbackMove(rootDir: string, toAbsolutePath: string): boolean { +function rollbackMove(rootDir: string, toAbsolutePath: string, configPath: string, originalConfig: string | null): boolean { try { fs.rmSync(toAbsolutePath, { recursive: true, force: true }); gitRestorePaths(rootDir, ["llmdoc"]); + if (originalConfig !== null) { + fs.writeFileSync(configPath, originalConfig); + } return true; } catch { return false; diff --git a/cli/src/commands/new.ts b/cli/src/commands/new.ts index 915e076..d01e41a 100644 --- a/cli/src/commands/new.ts +++ b/cli/src/commands/new.ts @@ -24,7 +24,7 @@ export function runNew(options: NewOptions): unknown { assertDocKindMatchesShape(shape); const absolutePath = resolveInsideRoot(rootDir, repoRelativePath, { allowMissing: true }); if (fs.existsSync(absolutePath)) { - throw new CliError(`目标已存在: ${repoRelativePath}`); + throw new CliError(`Target already exists: ${repoRelativePath}`); } ensureDirectory(absolutePath); @@ -33,7 +33,7 @@ export function runNew(options: NewOptions): unknown { // description 经 JSON.stringify 得到合法的 YAML double-quoted scalar,防止引号/冒号/换行破坏 front matter; // replace 一律用函数形式,避免 $& 等替换模式展开。 const content = template - .replace("__DESCRIPTION__", () => JSON.stringify(options.description ?? "TODO: 补充文档描述。")) + .replace("__DESCRIPTION__", () => JSON.stringify(options.description ?? "TODO: Add a document description.")) .replace("__KIND__", () => kind) .replace("__TITLE__", () => title); @@ -46,18 +46,18 @@ export function runNew(options: NewOptions): unknown { created: repoRelativePath, next: metaExists ? null - : "若 HEAD 尚无 commit,请先创建首次 Git 提交;然后运行 `npx -y @tokenroll/llmdoc init-state`。" + : "If HEAD has no commit, create the initial Git commit, then run `npx -y @tokenroll/llmdoc init-state`." }; } return metaExists ? `created: ${repoRelativePath}` - : `created: ${repoRelativePath}\nnext: 若仓库尚无提交,请先创建首次 Git 提交;然后运行 \`npx -y @tokenroll/llmdoc init-state\` 建立台账。`; + : `created: ${repoRelativePath}\nnext: If the repository has no commit, create the initial Git commit, then run \`npx -y @tokenroll/llmdoc init-state\` to create the ledger.`; } function normalizeDocDestination(input: string): string { const repoRelativePath = normalizeRepoRelativePath(input.startsWith("llmdoc/") ? input : `llmdoc/${input}`); if (!repoRelativePath.startsWith("llmdoc/") || !repoRelativePath.endsWith(".mdx")) { - throw new CliError("new 只能创建 llmdoc/ 下的 .mdx 文档。"); + throw new CliError("new can create only .mdx documents under llmdoc/."); } return repoRelativePath; } diff --git a/cli/src/commands/prune.ts b/cli/src/commands/prune.ts index 0c0e8d8..cf58df6 100644 --- a/cli/src/commands/prune.ts +++ b/cli/src/commands/prune.ts @@ -19,20 +19,22 @@ interface CandidatePair { export function runPrune(options: PruneOptions): unknown { if (!options.report) { return options.json - ? { status: "dry_run", message: "prune 当前仅支持 --report。", writable: false } - : "prune 当前仅支持 --report。"; + ? { status: "dry_run", message: "prune currently supports only --report.", writable: false } + : "prune currently supports only --report."; } const workspace = loadWorkspace(options.cwd); const growth = computeGrowthState(workspace); const duplicateCandidates = findDuplicateCandidates(workspace.documents); const mergeCandidates = findMergeCandidates(workspace.documents); + const startupPreloads = workspace.llmdocConfig.preloadPaths.map((preloadPath) => `llmdoc/${preloadPath}`); if (options.json) { return { status: "dry_run", writable: false, growth, + startupPreloads, duplicateCandidates: duplicateCandidates.map((candidate) => ({ paths: [`llmdoc/${candidate.left.llmdocPath}`, `llmdoc/${candidate.right.llmdocPath}`], score: candidate.score, @@ -52,7 +54,14 @@ export function runPrune(options: PruneOptions): unknown { ? `growth: ${growth.currentDocumentCount} docs, ~${growth.currentTotalEstimatedTokens} tokens (no convergence baseline)` : `growth: ${growth.currentDocumentCount} docs, ~${growth.currentTotalEstimatedTokens} tokens; baseline ${growth.baselineDocumentCount} docs, ~${growth.baselineTotalEstimatedTokens} tokens; delta ${growth.documentDelta}, ~${growth.tokenDelta} tokens; ${growth.exceedsGate ? "above gate" : "below gate"}`; - const lines = [growthLine, ""]; + const lines = [growthLine, "", "startup preload references:"]; + if (startupPreloads.length === 0) { + lines.push(" none"); + } else { + lines.push(...startupPreloads.map((preloadPath) => ` ${preloadPath}`)); + lines.push(" Keep llmdoc.config.json synchronized when merging or deleting these documents."); + } + lines.push(""); lines.push("duplicate candidates:"); if (duplicateCandidates.length === 0) { lines.push(" none"); diff --git a/cli/src/commands/search.ts b/cli/src/commands/search.ts index 1ee2b2c..f072794 100644 --- a/cli/src/commands/search.ts +++ b/cli/src/commands/search.ts @@ -37,7 +37,7 @@ export function runSearch(options: SearchOptions): unknown { const lines: string[] = []; if (search.mode === "cjk-bigram-fallback") { - lines.push("note: 中文分词未命中,已使用 CJK bigram 降级检索。", ""); + lines.push("note: Chinese segmentation returned no matches; CJK bigram fallback was used.", ""); } for (const entry of paginated.items) { lines.push(`llmdoc/${entry.document.llmdocPath} [${entry.document.frontmatter.kind}]`); diff --git a/cli/src/commands/serve.ts b/cli/src/commands/serve.ts index 8826942..aa74ff7 100644 --- a/cli/src/commands/serve.ts +++ b/cli/src/commands/serve.ts @@ -17,7 +17,7 @@ interface ServeOptions { // 长驻前台进程:启动本地 Viewer,Ctrl-C 退出。仅绑定 127.0.0.1,不对外暴露。 export async function runServe(options: ServeOptions): Promise { const server = await startViewerServer(options.cwd, options.port ?? 4973); - process.stdout.write(`llmdoc viewer: ${server.url} (Ctrl-C 退出)\n`); + process.stdout.write(`llmdoc viewer: ${server.url} (press Ctrl-C to stop)\n`); await new Promise((resolve, reject) => { const shutdown = (): void => { process.off("SIGINT", shutdown); diff --git a/cli/src/commands/show.ts b/cli/src/commands/show.ts index b71e611..bbecf89 100644 --- a/cli/src/commands/show.ts +++ b/cli/src/commands/show.ts @@ -17,7 +17,7 @@ export function runShow(options: ShowOptions): unknown { const normalized = rawPath.startsWith("llmdoc/") ? rawPath.slice("llmdoc/".length) : rawPath; const document = workspace.documentsByLlmdocPath.get(normalized); if (!document) { - throw new CliError(`文档不存在: ${rawPath}`); + throw new CliError(`Document does not exist: ${rawPath}`); } return document; }); diff --git a/cli/src/commands/status.ts b/cli/src/commands/status.ts index 95f259e..e1fec6a 100644 --- a/cli/src/commands/status.ts +++ b/cli/src/commands/status.ts @@ -53,7 +53,7 @@ export function runStatus(options: StatusOptions): unknown { if (unmapped.length > 0) { const shown = unmapped.slice(0, 5); const rest = unmapped.length - shown.length; - lines.push(`unmapped changes (${unmapped.length}): ${shown.join(", ")}${rest > 0 ? ` … +${rest} more (--json 查看全部)` : ""}`); + lines.push(`unmapped changes (${unmapped.length}): ${shown.join(", ")}${rest > 0 ? ` … +${rest} more (use --json for all)` : ""}`); } lines.push(growthLabel); if (delta.git.degradedReason) { diff --git a/cli/src/commands/tree.ts b/cli/src/commands/tree.ts index 64e588d..4bb2237 100644 --- a/cli/src/commands/tree.ts +++ b/cli/src/commands/tree.ts @@ -84,7 +84,7 @@ export function runTree(options: TreeOptions): unknown { lines.push(` ${entry.topic.summary}`); } lines.push("", ...formatPaginationSummary(result)); - lines.push("hint: `npx @tokenroll/llmdoc tree --docs` 展开文档级;`... index --topic ` 看文档元数据"); + lines.push("hint: use `npx @tokenroll/llmdoc tree --docs` to expand documents; use `... index --topic ` for metadata"); return lines.join("\n"); } diff --git a/cli/src/commands/upgrade.ts b/cli/src/commands/upgrade.ts index 6d15cf3..b452750 100644 --- a/cli/src/commands/upgrade.ts +++ b/cli/src/commands/upgrade.ts @@ -44,11 +44,11 @@ export async function runUpgrade(options: UpgradeOptions): Promise { function inspectMissingLlmdoc(): UpgradeReport { return { status: "no_change", - summary: "当前仓库不存在 llmdoc/,没有可升级的 V2/V3 知识面。", + summary: "The repository has no llmdoc/ directory and no V2/V3 knowledge surface to upgrade.", legacyPaths: [], targetStructure: [], requiresRecorderSemanticMigration: false, - notes: ["如需首次建立知识面,请走 init,而不是 upgrade。"] + notes: ["Use init, not upgrade, to create the first knowledge surface."] }; } @@ -72,9 +72,9 @@ function inspectLlmdocDirectory(llmdocDir: string): UpgradeReport { if (errors.length > 0) { return { status: "dry_run", - summary: "检测到部分 V3 结构,但当前知识面未通过 V3 校验。", + summary: "A partial V3 structure was detected, but the current knowledge surface failed V3 validation.", legacyPaths: [], - targetStructure: ["修复现有 V3 结构错误后再判断是否需要 upgrade"], + targetStructure: ["Repair the existing V3 structure before deciding whether upgrade is needed"], requiresRecorderSemanticMigration: false, notes: errors.slice(0, 5).map((issue) => `${issue.path ?? "unknown"}: ${issue.message}`) }; @@ -82,43 +82,43 @@ function inspectLlmdocDirectory(llmdocDir: string): UpgradeReport { } catch (error) { return { status: "dry_run", - summary: "检测到部分 V3 结构,但当前知识面无法稳定装载。", + summary: "A partial V3 structure was detected, but the current knowledge surface cannot be loaded reliably.", legacyPaths: [], - targetStructure: ["修复现有 V3 结构错误后再判断是否需要 upgrade"], + targetStructure: ["Repair the existing V3 structure before deciding whether upgrade is needed"], requiresRecorderSemanticMigration: false, notes: [(error as Error).message] }; } return { status: "no_change", - summary: "检测到现有知识面已经是 V3 结构,无需 upgrade。", + summary: "The existing knowledge surface is already V3; no upgrade is needed.", legacyPaths: [], targetStructure: [], requiresRecorderSemanticMigration: false, - notes: ["upgrade 当前只输出盘点,不会改写 V3 知识。"] + notes: ["upgrade currently reports an inventory only and does not rewrite V3 knowledge."] }; } if (legacyPaths.length === 0 && !hasV3Meta && !hasMdx) { return { status: "dry_run", - summary: "检测到 llmdoc/ 目录,但没有明确的 V2 或 V3 结构特征。", + summary: "An llmdoc/ directory was found, but it has no definitive V2 or V3 structure markers.", legacyPaths: [], - targetStructure: ["llmdoc/meta.json", "llmdoc/architecture.mdx", "llmdoc//*.mdx (纯目录,无 index.mdx 入口节点)"], + targetStructure: ["llmdoc/meta.json", "llmdoc/architecture.mdx", "llmdoc//*.mdx (plain directories, no index.mdx entry nodes)"], requiresRecorderSemanticMigration: false, - notes: ["需要人工确认该目录是否为遗留知识面。"] + notes: ["A human must confirm whether this directory is a legacy knowledge surface."] }; } return { status: "dry_run", - summary: "检测到 legacy/V2 结构,需要 Recorder 参与语义迁移到 V3。", + summary: "A legacy/V2 structure was detected; Recorder must perform the semantic migration to V3.", legacyPaths, - targetStructure: ["llmdoc/meta.json", "llmdoc/architecture.mdx", "llmdoc//*.mdx (纯目录,无 index.mdx 入口节点)"], + targetStructure: ["llmdoc/meta.json", "llmdoc/architecture.mdx", "llmdoc//*.mdx (plain directories, no index.mdx entry nodes)"], requiresRecorderSemanticMigration: true, notes: [ - hasV3Meta ? "存在部分 V3 迹象,但 legacy 结构仍在,需要整理边界后再迁移。" : "未发现完整 V3 ledger,需要生成新的 meta.json baseline。", - legacyPaths.includes("state/sync.md") ? "state/sync.md 需要迁移为 meta.json baseline.revision。" : "未发现 state/sync.md watermark。" + hasV3Meta ? "Some V3 markers exist, but legacy structure remains; reconcile the boundary before migration." : "No complete V3 ledger was found; create a new meta.json baseline.", + legacyPaths.includes("state/sync.md") ? "Migrate state/sync.md to meta.json baseline.revision." : "No state/sync.md watermark was found." ] }; } diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts new file mode 100644 index 0000000..ad16527 --- /dev/null +++ b/cli/src/lib/config.ts @@ -0,0 +1,170 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { LlmdocConfig, LoadedLlmdocConfig, ParsedDocument, ValidationIssue } from "../types.js"; +import { normalizeRepoRelativePath, resolveInsideRoot } from "./fs.js"; +import { validateLlmdocConfig } from "./schema.js"; + +export const LLMDOC_CONFIG_FILENAME = "llmdoc.config.json"; + +export function loadLlmdocConfig( + rootDir: string, + documentsByLlmdocPath?: ReadonlyMap +): LoadedLlmdocConfig { + const configPath = path.join(rootDir, LLMDOC_CONFIG_FILENAME); + if (!fs.existsSync(configPath)) { + return { exists: false, config: null, preloadPaths: [], issues: [] }; + } + + let safeConfigPath: string; + try { + safeConfigPath = resolveInsideRoot(rootDir, LLMDOC_CONFIG_FILENAME); + } catch (error) { + return invalidConfig(`The config file must remain inside the repository: ${(error as Error).message}`); + } + + let input: unknown; + try { + input = JSON.parse(fs.readFileSync(safeConfigPath, "utf8")); + } catch (error) { + return invalidConfig(`JSON parse failed: ${(error as Error).message}`); + } + + const schemaErrors = validateLlmdocConfig(input); + if (schemaErrors.length > 0) { + return { + exists: true, + config: null, + preloadPaths: [], + issues: schemaErrors.map((message) => configIssue("config.invalid", `Invalid config: ${message}`)) + }; + } + + const config = input as LlmdocConfig; + const preloadPaths: string[] = []; + const issues: ValidationIssue[] = []; + const seen = new Set(); + for (const rawPath of config.startup?.preload ?? []) { + let normalized: string; + try { + normalized = normalizePreloadPath(rawPath); + } catch (error) { + issues.push(configIssue("config.startup.preload.path", (error as Error).message)); + continue; + } + if (seen.has(normalized)) { + issues.push( + configIssue( + "config.startup.preload.duplicate", + `startup.preload contains the same normalized document more than once; duplicate ignored: llmdoc/${normalized}`, + "warning" + ) + ); + continue; + } + seen.add(normalized); + preloadPaths.push(normalized); + if (documentsByLlmdocPath && !documentsByLlmdocPath.has(normalized)) { + issues.push(configIssue("config.startup.preload.missing", `startup.preload document does not exist: llmdoc/${normalized}`)); + } + } + + return { + exists: true, + config, + preloadPaths: issues.some((issue) => issue.severity === "error") ? [] : preloadPaths, + issues + }; +} + +export function formatStartupPreloadDocuments(documents: readonly ParsedDocument[]): string { + if (documents.length === 0) { + return ""; + } + const lines = [ + `llmdoc startup preload begins (${documents.length} document(s)). The configured bodies are provided directly below. If the final completion marker is visible, these bodies are complete and do not need another search/show; otherwise retrieve any missing document with npx @tokenroll/llmdoc show.` + ]; + for (const document of documents) { + lines.push("", `=== llmdoc/${document.llmdocPath} [${document.frontmatter.kind}] (startup preload) ===`, document.body); + } + lines.push("", "=== llmdoc startup preload complete ==="); + return lines.join("\n"); +} + +export function formatCompactPreloadIndex(preloadPaths: readonly string[]): string { + if (preloadPaths.length === 0) { + return ""; + } + return `Configured startup preload bodies were not re-injected after compaction. Document IDs: ${preloadPaths + .map((preloadPath) => `llmdoc/${preloadPath}`) + .join(", ")}. Retrieve a body with npx @tokenroll/llmdoc show only when the compacted context no longer contains what the task needs.`; +} + +export function rewriteStartupPreloadForMove( + config: LlmdocConfig, + mapping: ReadonlyArray<{ oldLlmdocPath: string; newLlmdocPath: string }> +): { config: LlmdocConfig; changed: boolean } { + const preload = config.startup?.preload; + if (!preload || preload.length === 0) { + return { config, changed: false }; + } + + const moveByPath = new Map(mapping.map((item) => [item.oldLlmdocPath, item.newLlmdocPath])); + let changed = false; + const rewritten = preload.map((rawPath) => { + let normalized: string; + try { + normalized = normalizePreloadPath(rawPath); + } catch { + return rawPath; + } + const nextPath = moveByPath.get(normalized); + if (!nextPath) { + return rawPath; + } + changed = true; + const normalizedRaw = rawPath.trim().replaceAll("\\", "/"); + return normalizedRaw.startsWith("llmdoc/") ? `llmdoc/${nextPath}` : nextPath; + }); + + return { + changed, + config: changed + ? { + ...config, + startup: { + ...config.startup, + preload: rewritten + } + } + : config + }; +} + +function normalizePreloadPath(rawPath: string): string { + const trimmed = rawPath.trim().replaceAll("\\", "/"); + const withoutPrefix = trimmed.startsWith("llmdoc/") ? trimmed.slice("llmdoc/".length) : trimmed; + const normalized = normalizeRepoRelativePath(withoutPrefix); + if (!normalized.endsWith(".mdx")) { + throw new Error(`startup.preload accepts only .mdx documents: ${rawPath}`); + } + return normalized; +} + +function invalidConfig(message: string): LoadedLlmdocConfig { + return { + exists: true, + config: null, + preloadPaths: [], + issues: [configIssue("config.read", message)] + }; +} + +function configIssue(code: string, message: string, severity: ValidationIssue["severity"] = "error"): ValidationIssue { + return { + severity, + code, + path: LLMDOC_CONFIG_FILENAME, + message + }; +} diff --git a/cli/src/lib/doc-shape.ts b/cli/src/lib/doc-shape.ts index c53cdee..bd8f318 100644 --- a/cli/src/lib/doc-shape.ts +++ b/cli/src/lib/doc-shape.ts @@ -16,15 +16,15 @@ export interface DocTargetShape { export function parseDocTargetShape(repoRelativePath: string): DocTargetShape { if (!repoRelativePath.startsWith("llmdoc/")) { - throw new CliError("目标必须位于 llmdoc/ 下。"); + throw new CliError("The target must be under llmdoc/."); } if (!repoRelativePath.endsWith(".mdx")) { - throw new CliError("目标必须是 .mdx 文档。"); + throw new CliError("The target must be an .mdx document."); } const llmdocPath = repoRelativePath.slice("llmdoc/".length); const segments = llmdocPath.split("/"); if (segments.length !== 1 && segments.length !== 2) { - throw new CliError("V3 文档路径仅允许根 singleton 或 topic/file 两层。"); + throw new CliError("V3 document paths allow only a root singleton or topic/file depth."); } const basename = path.posix.basename(llmdocPath); const topic = segments.length === 2 ? (segments[0] ?? null) : null; @@ -42,7 +42,7 @@ export function parseDocTargetShape(repoRelativePath: string): DocTargetShape { export function assertDocKindMatchesShape(shape: DocTargetShape): void { // V3 不设入口节点:topic 即纯目录,描述由 CLI 从文档 front matter 聚合。 if (shape.basename === "index.mdx") { - throw new CliError("V3 不使用 index.mdx 入口节点;请使用普通命名,topic 描述由 llmdoc tree 聚合。"); + throw new CliError("V3 does not use index.mdx entry nodes. Use a regular name; llmdoc tree derives topic descriptions."); } } @@ -63,5 +63,5 @@ export function assertDocumentKind(input: string): DocumentKind { if ((DOCUMENT_KINDS as string[]).includes(input)) { return input as DocumentKind; } - throw new CliError(`非法 kind: ${input}。允许值: ${DOCUMENT_KINDS.join(", ")}`); + throw new CliError(`Invalid kind: ${input}. Allowed values: ${DOCUMENT_KINDS.join(", ")}`); } diff --git a/cli/src/lib/fs.ts b/cli/src/lib/fs.ts index 59e818a..f040ace 100644 --- a/cli/src/lib/fs.ts +++ b/cli/src/lib/fs.ts @@ -8,7 +8,7 @@ export function findProjectRoot(startDir: string): string { if (found) { return found; } - throw new CliError("未找到 llmdoc/ 目录,请在仓库内运行该命令。", 2); + throw new CliError("No llmdoc/ directory was found. Run this command inside an llmdoc repository.", 2); } // new 是唯一允许在 llmdoc/ 尚不存在时运行的结构改写命令。 @@ -23,7 +23,7 @@ export function findProjectRootForNew(startDir: string): string { if (gitRoot) { return gitRoot; } - throw new CliError("未找到 Git 仓库;请先运行 `git init`,再运行 `llmdoc new`。", 2); + throw new CliError("No Git repository was found. Run `git init` before `llmdoc new`.", 2); } export function findProjectRootOrNull(startDir: string): string | null { @@ -75,14 +75,14 @@ function isDirectory(candidate: string): boolean { export function normalizeRepoRelativePath(input: string): string { const normalized = input.replaceAll("\\", "/"); if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) { - throw new CliError(`非法路径: ${input}`); + throw new CliError(`Invalid path: ${input}`); } const parts = normalized.split("/").filter((part) => part !== "."); if (parts.length === 0) { - throw new CliError(`非法路径: ${input}`); + throw new CliError(`Invalid path: ${input}`); } if (parts.some((part) => part === ".." || part === "")) { - throw new CliError(`路径必须是仓库内规范化相对路径: ${input}`); + throw new CliError(`Path must be a normalized repository-relative path: ${input}`); } return parts.join("/"); } @@ -95,13 +95,13 @@ export function resolveInsideRoot(rootDir: string, repoRelativePath: string, opt const ancestorRealPath = fs.realpathSync(nearestExistingAncestor); if (!isWithinRoot(rootRealPath, ancestorRealPath)) { - throw new CliError(`路径越界或经由符号链接逃逸: ${repoRelativePath}`); + throw new CliError(`Path escapes the repository directly or through a symlink: ${repoRelativePath}`); } if (fs.existsSync(candidate)) { const candidateRealPath = fs.realpathSync(candidate); if (!isWithinRoot(rootRealPath, candidateRealPath)) { - throw new CliError(`路径越界或经由符号链接逃逸: ${repoRelativePath}`); + throw new CliError(`Path escapes the repository directly or through a symlink: ${repoRelativePath}`); } return candidateRealPath; } @@ -110,7 +110,7 @@ export function resolveInsideRoot(rootDir: string, repoRelativePath: string, opt return candidate; } - throw new CliError(`路径不存在: ${repoRelativePath}`); + throw new CliError(`Path does not exist: ${repoRelativePath}`); } export function isWithinRoot(rootRealPath: string, candidateRealPath: string): boolean { diff --git a/cli/src/lib/git.ts b/cli/src/lib/git.ts index f9c7bd0..632ca39 100644 --- a/cli/src/lib/git.ts +++ b/cli/src/lib/git.ts @@ -12,7 +12,7 @@ function runGit(rootDir: string, args: string[]): string { }); if (result.status !== 0) { - throw new CliError((result.stderr || result.stdout || "git 命令失败").trim()); + throw new CliError((result.stderr || result.stdout || "Git command failed").trim()); } return result.stdout.trim(); @@ -59,7 +59,7 @@ export function readGitState(rootDir: string, baselineRevision: string | null): stagedPaths: [], unstagedPaths: [], untrackedPaths: [], - degradedReason: "当前目录不是 git 仓库,状态面降级为不可用。" + degradedReason: "The current directory is not a Git repository; state diagnostics are unavailable." }; } @@ -80,9 +80,9 @@ export function readGitState(rootDir: string, baselineRevision: string | null): let degradedReason: string | null = null; if (!headRevision) { - degradedReason = isUnbornHead(rootDir) ? "HEAD 尚无 commit(当前分支尚未创建首次提交)。" : "无法解析 HEAD commit。"; + degradedReason = isUnbornHead(rootDir) ? "HEAD has no commit (the current branch is unborn)." : "Unable to resolve the HEAD commit."; } else if (baselineRevision && !gitCommitExists(rootDir, baselineRevision)) { - degradedReason = `baseline.revision 不存在于当前 git 历史: ${baselineRevision}`; + degradedReason = `baseline.revision does not exist in current Git history: ${baselineRevision}`; } return { @@ -137,10 +137,10 @@ export function readCommitsWithChangedPathsSince( export function canAdvanceRevisions(gitState: GitState): { ok: true } | { ok: false; reason: string } { if (!gitState.available || !gitState.headRevision) { - return { ok: false, reason: gitState.degradedReason ?? "git 状态不可用。" }; + return { ok: false, reason: gitState.degradedReason ?? "Git state is unavailable." }; } if (gitState.inProgressOperation) { - return { ok: false, reason: `${gitState.inProgressOperation} 进行中,不推进 revision。` }; + return { ok: false, reason: `${gitState.inProgressOperation} is in progress; revisions cannot advance.` }; } return { ok: true }; } diff --git a/cli/src/lib/markdown.ts b/cli/src/lib/markdown.ts index d3484ac..04b4ca9 100644 --- a/cli/src/lib/markdown.ts +++ b/cli/src/lib/markdown.ts @@ -77,27 +77,27 @@ export function validateCodeRefTags(body: string): string[] { const raw = match[0] ?? ""; const attributes = match[1] ?? ""; if (!raw.endsWith("/>")) { - issues.push("CodeRef 必须使用自闭合写法 。"); + issues.push("CodeRef must use the self-closing form ."); continue; } const attrMatches = [...attributes.matchAll(/([A-Za-z]+)="([^"]*)"/g)]; const consumed = attrMatches.map((attr) => attr[0]).join(" ").trim(); const normalized = attributes.replace(/\s+/g, " ").trim().replace(/\/$/, "").trim(); if (normalized && consumed !== normalized) { - issues.push("CodeRef 仅允许双引号属性 path 和可选 symbol。"); + issues.push("CodeRef allows only the double-quoted path attribute and optional symbol attribute."); continue; } const names = attrMatches.map((attr) => attr[1]!); const duplicates = names.filter((name, index) => names.indexOf(name) !== index); if (duplicates.length > 0) { - issues.push(`CodeRef 存在重复属性: ${[...new Set(duplicates)].join(", ")}`); + issues.push(`CodeRef contains duplicate attributes: ${[...new Set(duplicates)].join(", ")}`); } if (!names.includes("path")) { - issues.push("CodeRef 缺少必填属性 path。"); + issues.push("CodeRef is missing the required path attribute."); } for (const name of names) { if (name !== "path" && name !== "symbol") { - issues.push(`CodeRef 存在未知属性: ${name}`); + issues.push(`CodeRef contains an unknown attribute: ${name}`); } } } diff --git a/cli/src/lib/output-schema.ts b/cli/src/lib/output-schema.ts index a547ccd..7c5a8da 100644 --- a/cli/src/lib/output-schema.ts +++ b/cli/src/lib/output-schema.ts @@ -42,7 +42,7 @@ export function assertOutputSchema(name: OutputSchemaName, payload: unknown): vo if (ok) { return; } - throw new CliError(`内部输出契约错误(${name}): ${formatErrors(validator.errors ?? [])}`, 70); + throw new CliError(`Internal output contract error (${name}): ${formatErrors(validator.errors ?? [])}`, 70); } export function stringifyValidatedOutput(name: OutputSchemaName, payload: unknown): string { @@ -55,7 +55,7 @@ export function parseAndValidateJsonString(name: OutputSchemaName, input: string try { payload = JSON.parse(input); } catch (error) { - throw new CliError(`内部输出契约错误(${name}): JSON 解析失败: ${(error as Error).message}`, 70); + throw new CliError(`Internal output contract error (${name}): JSON parse failed: ${(error as Error).message}`, 70); } return stringifyValidatedOutput(name, payload); } @@ -67,7 +67,7 @@ function getValidator(name: OutputSchemaName): ValidateFunction { } const defs = (schemaDocument as { $defs?: Record }).$defs; if (!defs?.[name]) { - throw new CliError(`内部输出契约错误: 未找到 schema ${name}`, 70); + throw new CliError(`Internal output contract error: schema ${name} was not found`, 70); } const validator = ajv.compile({ $schema: "https://json-schema.org/draft/2020-12/schema", diff --git a/cli/src/lib/package-root.ts b/cli/src/lib/package-root.ts index ce2b5e4..6c70d4c 100644 --- a/cli/src/lib/package-root.ts +++ b/cli/src/lib/package-root.ts @@ -10,7 +10,7 @@ export function packageRootFromImport(metaUrl: string): string { } const parent = path.dirname(current); if (parent === current) { - throw new Error(`无法从 ${metaUrl} 向上定位 package root。`); + throw new Error(`Unable to locate the package root above ${metaUrl}.`); } current = parent; } diff --git a/cli/src/lib/pagination.ts b/cli/src/lib/pagination.ts index fbf2221..412d039 100644 --- a/cli/src/lib/pagination.ts +++ b/cli/src/lib/pagination.ts @@ -27,7 +27,7 @@ export function paginate({ items, estimate, options }: PaginateInput): Pag const offset = decodeCursor(options.cursor); if (offset < 0 || offset > items.length) { - throw new CliError("cursor 非法或已失效。"); + throw new CliError("The cursor is invalid or expired."); } const selected: T[] = []; @@ -89,6 +89,6 @@ function decodeCursor(cursor: string | undefined): number { } return payload.offset; } catch { - throw new CliError("cursor 非法或已损坏。"); + throw new CliError("The cursor is invalid or corrupted."); } } diff --git a/cli/src/lib/schema.ts b/cli/src/lib/schema.ts index 2d358f3..a962bd0 100644 --- a/cli/src/lib/schema.ts +++ b/cli/src/lib/schema.ts @@ -20,6 +20,7 @@ function readSchema(fileName: string): unknown { const docValidator = ajv.compile(readSchema("doc-frontmatter.schema.json")); const metaValidator = ajv.compile(readSchema("meta.schema.json")); +const configValidator = ajv.compile(readSchema("config.schema.json")); export function validateDocFrontmatter(input: unknown): string[] { const ok = docValidator(input); @@ -48,6 +49,14 @@ export function validateMeta(input: unknown): string[] { return errors; } +export function validateLlmdocConfig(input: unknown): string[] { + const ok = configValidator(input); + if (ok) { + return []; + } + return (configValidator.errors ?? []).map(formatAjvError); +} + function formatAjvError(error: ErrorObject): string { return `${error.instancePath || "/"} ${error.message ?? "invalid"}`; } diff --git a/cli/src/lib/state.ts b/cli/src/lib/state.ts index b613fce..5b8e0fd 100644 --- a/cli/src/lib/state.ts +++ b/cli/src/lib/state.ts @@ -4,6 +4,7 @@ import { DocumentImpact, GitState, MetaLedger, ParsedDocument, WorkspaceData } f import { canAdvanceRevisions, gitCommitExists, readChangedPathsSince, readGitState } from "./git.js"; import { normalizeRepoRelativePath } from "./fs.js"; import { matchesCodePathPattern } from "./search.js"; +import { LLMDOC_CONFIG_FILENAME } from "./config.js"; export interface ScopeFilter { topics: Set; @@ -63,8 +64,8 @@ export function analyzeDelta(workspace: WorkspaceData, scope?: ScopeFilter): Del invalidRevisionDocuments.push(document); invalidRevisionReasons.push( !validatedRevision - ? `文档 ${document.llmdocPath} 缺少 validatedRevision` - : `文档 ${document.llmdocPath} 的 validatedRevision 不存在于当前 git 历史: ${validatedRevision}` + ? `Document ${document.llmdocPath} has no validatedRevision` + : `Document ${document.llmdocPath} has a validatedRevision that does not exist in current Git history: ${validatedRevision}` ); } const changedCommittedPaths = @@ -119,30 +120,24 @@ export function analyzeDelta(workspace: WorkspaceData, scope?: ScopeFilter): Del const reasons: string[] = []; if (!git.available || git.degradedReason) { - reasons.push(git.degradedReason ?? "git 状态不可用"); + reasons.push(git.degradedReason ?? "Git state is unavailable"); } if (invalidRevisionReasons.length > 0) { reasons.push(...invalidRevisionReasons); } if (unmappedCommittedPaths.length > 0 || unmappedDirtyPaths.length > 0) { - reasons.push("存在未映射代码路径"); + reasons.push("Unmapped code paths exist"); } if (needsReview.length > 0) { - reasons.push("存在 requires 反向一跳需复核文档"); + reasons.push("Documents in the reverse requires closure need review"); } if (directImpacts.length > 8) { - reasons.push("受影响文档过多,建议 deep"); + reasons.push("Too many documents are impacted; deep mode is recommended"); } if (dirtyDocuments.length > 0) { - reasons.push("存在未提交 dirty 关联代码"); + reasons.push("Mapped implementation code has uncommitted changes"); } - const suggestedMode: "light" | "deep" = reasons.some((reason) => - ["未映射", "需复核", "过多", "dirty", "不可用", "不存在于当前 git 历史", "validatedRevision", "缺少"].some((keyword) => - reason.includes(keyword) - ) - ) - ? "deep" - : "light"; + const suggestedMode: "light" | "deep" = reasons.length > 0 ? "deep" : "light"; return { git, @@ -179,7 +174,7 @@ export function parseScope(values: string[] | undefined, workspace: WorkspaceDat continue; } if (!workspace.documentsByLlmdocPath.has(normalized)) { - throw new Error(`scope 未命中任何 topic 或文档: ${raw}`); + throw new Error(`Scope did not match any topic or document: ${raw}`); } documentPaths.add(normalized); } @@ -214,7 +209,7 @@ export function assertRevisionAdvancePreconditions(input: { }): { git: GitState; targetPaths: string[] } { const { workspace, llmdocPaths, updateAll } = input; if (!workspace.meta) { - throw new Error("缺少 meta.json"); + throw new Error("meta.json is missing"); } const git = readWorkspaceGitState(workspace); const advance = canAdvanceRevisions(git); @@ -236,8 +231,8 @@ export function assertRevisionAdvancePreconditions(input: { if (blocked.length > 0) { throw new Error( updateAll - ? `全量 fingerprint 时以下文档关联代码存在 dirty 变更,不能伪造 revision: ${blocked.join(", ")}` - : `以下文档关联代码存在 dirty 变更,不能伪造 revision: ${blocked.join(", ")}` + ? `A full fingerprint cannot advance while mapped code for these documents is dirty: ${blocked.join(", ")}` + : `Cannot advance revisions while mapped code for these documents is dirty: ${blocked.join(", ")}` ); } return { git, targetPaths }; @@ -336,7 +331,11 @@ export function loadIgnorePatterns(rootDir: string): string[] { } export function isImplementationSurfacePath(repoRelativePath: string, ignorePatterns: string[] = []): boolean { - if (repoRelativePath.startsWith("llmdoc/") || repoRelativePath.startsWith(".llmdoc-tmp/")) { + if ( + repoRelativePath === LLMDOC_CONFIG_FILENAME || + repoRelativePath.startsWith("llmdoc/") || + repoRelativePath.startsWith(".llmdoc-tmp/") + ) { return false; } const basename = repoRelativePath.split("/").pop() ?? repoRelativePath; diff --git a/cli/src/lib/viewer-http.ts b/cli/src/lib/viewer-http.ts index 902e905..02d96d3 100644 --- a/cli/src/lib/viewer-http.ts +++ b/cli/src/lib/viewer-http.ts @@ -61,7 +61,7 @@ export function createViewerRequestHandler(rootDir: string): ViewerRequestHandle // 仅按扫描产生的 canonical llmdocPath 查表,不接受任意文件系统路径。 const document = workspace.documentsByLlmdocPath.get(docPath); if (!document) { - sendJson(response, 404, { error: `未找到文档: ${docPath}` }, headOnly); + sendJson(response, 404, { error: `Document not found: ${docPath}` }, headOnly); return; } sendJson( @@ -97,7 +97,7 @@ function sendStaticAsset( // fileName 只可能来自上方常量白名单;请求值不会参与路径解析。 const assetPath = path.join(assetsRoot, asset.fileName); if (!fs.existsSync(assetPath)) { - throw new CliError(`viewer 资产缺失: ${asset.fileName}`); + throw new CliError(`Viewer asset is missing: ${asset.fileName}`); } sendBody(response, 200, fs.readFileSync(assetPath), asset.contentType, headOnly); } diff --git a/cli/src/lib/workspace.ts b/cli/src/lib/workspace.ts index 6f95981..4edb841 100644 --- a/cli/src/lib/workspace.ts +++ b/cli/src/lib/workspace.ts @@ -11,11 +11,12 @@ import { normalizeRepoRelativePath, repoPath, resolveInsideRoot } from "./fs.js" import { gitCommitExists, isShallowRepository } from "./git.js"; import { matchesCodePathPattern } from "./search.js"; import { validateDocFrontmatter, validateMeta } from "./schema.js"; +import { loadLlmdocConfig } from "./config.js"; export function loadWorkspace(rootDir: string): WorkspaceData { const configuredLlmdocDir = path.join(rootDir, "llmdoc"); if (!fs.existsSync(configuredLlmdocDir) || !fs.statSync(configuredLlmdocDir).isDirectory()) { - throw new CliError("仓库内不存在 llmdoc/ 目录。", 2); + throw new CliError("The repository does not contain an llmdoc/ directory.", 2); } resolveInsideRoot(rootDir, "llmdoc"); const llmdocDir = configuredLlmdocDir; @@ -43,6 +44,7 @@ export function loadWorkspace(rootDir: string): WorkspaceData { const metaPath = path.join(llmdocDir, "meta.json"); const meta = loadMeta(metaPath, preloadIssues); + const llmdocConfig = loadLlmdocConfig(rootDir, documentsByLlmdocPath); return { rootDir, @@ -53,6 +55,7 @@ export function loadWorkspace(rootDir: string): WorkspaceData { topics, rootSingletons, meta, + llmdocConfig, preloadIssues }; } @@ -68,7 +71,7 @@ function scanDocuments(rootDir: string, llmdocDir: string, preloadIssues: Valida severity: "error", code: "file.extension.invalid", path: repoRelativePath, - message: "llmdoc/ 下仅允许 .mdx 文档与根级唯一 meta.json。" + message: "llmdoc/ allows only .mdx documents and one root-level meta.json file." }); } return; @@ -83,7 +86,7 @@ function scanDocuments(rootDir: string, llmdocDir: string, preloadIssues: Valida severity: "error", code: "frontmatter.parse", path: repoRelativePath, - message: `front matter 解析失败: ${(error as Error).message}` + message: `Front matter parse failed: ${(error as Error).message}` }); return; } @@ -136,7 +139,7 @@ function loadMeta(metaPath: string, preloadIssues: ValidationIssue[]): MetaLedge severity: "error", code: "meta.parse", path: "llmdoc/meta.json", - message: `meta.json 解析失败: ${(error as Error).message}` + message: `meta.json parse failed: ${(error as Error).message}` }); return null; } @@ -147,7 +150,7 @@ function loadMeta(metaPath: string, preloadIssues: ValidationIssue[]): MetaLedge severity: "error", code: "meta.invalid", path: "llmdoc/meta.json", - message: `meta.json 非法: ${error}` + message: `Invalid meta.json: ${error}` }); } return null; @@ -157,6 +160,7 @@ function loadMeta(metaPath: string, preloadIssues: ValidationIssue[]): MetaLedge export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { const issues: ValidationIssue[] = [...workspace.preloadIssues]; + issues.push(...workspace.llmdocConfig.issues); const seenDocPaths = new Set(); if (!workspace.meta) { @@ -165,7 +169,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { code: "meta.missing", path: "llmdoc/meta.json", message: - "缺少 llmdoc/meta.json。请运行 `npx -y @tokenroll/llmdoc init-state` 建立台账(若仓库尚无提交,请先创建首次 Git 提交)。" + "llmdoc/meta.json is missing. Run `npx -y @tokenroll/llmdoc init-state` to create the ledger (create the initial Git commit first if the repository has no commits)." }); } @@ -177,7 +181,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "error", code: "frontmatter.invalid", path: document.repoPath, - message: `front matter 非法: ${error}` + message: `Invalid front matter: ${error}` }); } @@ -199,7 +203,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "error", code: "hierarchy.nested", path: document.repoPath, - message: "topic 下不允许继续嵌套子目录。" + message: "A topic cannot contain nested subdirectories." }); } @@ -218,7 +222,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "warning", code: "link.wikilink", path: document.repoPath, - message: "正文包含 [[wikilink]] 语法;llmdoc 不解析它,请改用标准 Markdown 相对链接。" + message: "The body contains [[wikilink]] syntax, which llmdoc does not parse. Use a standard relative Markdown link." }); } @@ -236,7 +240,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "error", code: "relations.requires.missing", path: document.repoPath, - message: `requires 指向不存在的文档: ${relationPath}` + message: `requires points to a missing document: ${relationPath}` }); } } @@ -255,7 +259,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "error", code: "relations.related.missing", path: document.repoPath, - message: `related 指向不存在的文档: ${relationPath}` + message: `related points to a missing document: ${relationPath}` }); } } @@ -268,14 +272,14 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "error", code: "link.invalid-path", path: document.repoPath, - message: `正文链接非法: ${linkTarget} (${normalizedLink.message})` + message: `Invalid body link: ${linkTarget} (${normalizedLink.message})` }); } else if (!workspace.documentsByLlmdocPath.has(normalizedLink.value)) { issues.push({ severity: "error", code: "link.missing", path: document.repoPath, - message: `正文链接悬空: ${linkTarget}` + message: `Body link points to a missing document: ${linkTarget}` }); } } @@ -294,7 +298,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "error", code: "coderef.path.missing", path: document.repoPath, - message: `CodeRef path 不存在: ${codeRef.path}` + message: `CodeRef path does not exist: ${codeRef.path}` }); } } @@ -313,7 +317,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "error", code: "code.paths.missing", path: document.repoPath, - message: `code.paths 指向不存在的路径: ${codePath}` + message: `code.paths points to a missing path: ${codePath}` }); } else if ( normalized.isGlob && @@ -323,7 +327,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "error", code: "code.paths.unmatched", path: document.repoPath, - message: `code.paths glob 未命中任何现有文件: ${codePath}` + message: `code.paths glob matches no existing files: ${codePath}` }); } } @@ -333,7 +337,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "warning", code: "size.line-warning", path: document.repoPath, - message: `文档 ${document.lineCount} 行,超过建议上限 ${DOC_LINE_WARNING_LIMIT} 行。` + message: `The document has ${document.lineCount} lines, exceeding the recommended limit of ${DOC_LINE_WARNING_LIMIT}.` }); } } @@ -344,7 +348,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "warning", code: "topic.empty", path: `llmdoc/${topic}`, - message: "topic 目录为空。" + message: "The topic directory is empty." }); } } @@ -354,13 +358,13 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { // 否则任何 fetch-depth:1 的 CI 都会误报陈旧。 const shallow = isShallowRepository(workspace.rootDir); const revisionSeverity = shallow ? ("warning" as const) : ("error" as const); - const shallowHint = shallow ? "(shallow clone;完整校验请用 fetch-depth: 0 或 git fetch --unshallow)" : ""; + const shallowHint = shallow ? " (shallow clone; use fetch-depth: 0 or git fetch --unshallow for complete validation)" : ""; if (!gitCommitExists(workspace.rootDir, workspace.meta.baseline.revision)) { issues.push({ severity: revisionSeverity, code: "meta.baseline.revision.missing", path: "llmdoc/meta.json", - message: `baseline.revision 不存在于当前 git 历史: ${workspace.meta.baseline.revision}${shallowHint}` + message: `baseline.revision does not exist in current Git history: ${workspace.meta.baseline.revision}${shallowHint}` }); } const metaPaths = Object.keys(workspace.meta.documents).sort(); @@ -372,7 +376,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "error", code: "meta.entry.missing", path: `llmdoc/${docPath}`, - message: "meta.json 缺少对应 documents entry。可用 `llmdoc adopt ` 无损登记。" + message: "meta.json is missing the corresponding documents entry. Use `llmdoc adopt ` to register it without rewriting the document." }); } } @@ -383,7 +387,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: "error", code: "meta.entry.orphaned", path: `llmdoc/${metaPath}`, - message: "meta.json 中存在孤儿 documents entry。" + message: "meta.json contains an orphan documents entry." }); } const revision = workspace.meta.documents[metaPath]?.validatedRevision; @@ -392,7 +396,7 @@ export function validateWorkspace(workspace: WorkspaceData): ValidationIssue[] { severity: revisionSeverity, code: "meta.document.revision.missing", path: `llmdoc/${metaPath}`, - message: `validatedRevision 不存在于当前 git 历史: ${revision}${shallowHint}` + message: `validatedRevision does not exist in current Git history: ${revision}${shallowHint}` }); } } @@ -409,13 +413,13 @@ function validateMdxBody(body: string): string[] { const cleanBody = stripMarkdownLiterals(body); const issues: string[] = []; if (/^\s*import\s+/m.test(cleanBody) || /^\s*export\s+/m.test(cleanBody)) { - issues.push("仅允许纯 Markdown 与自闭合 CodeRef,禁止 import/export。"); + issues.push("Only plain Markdown and self-closing CodeRef tags are allowed; import/export is forbidden."); } if (/<(?!CodeRef\b)[A-Za-z][^>]*>/m.test(cleanBody) || /<\/[A-Za-z][^>]*>/m.test(cleanBody)) { - issues.push("仅允许自闭合 组件,禁止任意 JSX 标签。"); + issues.push("Only self-closing components are allowed; arbitrary JSX tags are forbidden."); } if (/\{[^}\n]+\}/m.test(cleanBody)) { - issues.push("禁止在正文中使用 MDX/JS 表达式。"); + issues.push("MDX/JS expressions are forbidden in document bodies."); } issues.push(...validateCodeRefTags(body)); return [...new Set(issues)]; @@ -425,10 +429,10 @@ function validateLlmdocRelativePath(input: string): { ok: true; value: string } try { const normalized = normalizeRepoRelativePath(input); if (!normalized.endsWith(".mdx")) { - return { ok: false, message: `路径必须指向 .mdx 文档: ${input}` }; + return { ok: false, message: `Path must point to an .mdx document: ${input}` }; } if (normalized.startsWith("llmdoc/")) { - return { ok: false, message: `路径必须是 llmdoc/ 下相对路径而非仓库根路径: ${input}` }; + return { ok: false, message: `Path must be relative to llmdoc/, not the repository root: ${input}` }; } return { ok: true, value: normalized }; } catch (error) { diff --git a/cli/src/types.ts b/cli/src/types.ts index 3030978..fce0653 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -48,6 +48,22 @@ export interface MetaLedger { }; } +export interface LlmdocConfig { + $schema?: string; + schema: "llmdoc.config/v1"; + startup?: { + remindSkill?: boolean; + preload?: string[]; + }; +} + +export interface LoadedLlmdocConfig { + exists: boolean; + config: LlmdocConfig | null; + preloadPaths: string[]; + issues: ValidationIssue[]; +} + export interface WorkspaceData { rootDir: string; llmdocDir: string; @@ -57,6 +73,7 @@ export interface WorkspaceData { topics: Map; rootSingletons: ParsedDocument[]; meta: MetaLedger | null; + llmdocConfig: LoadedLlmdocConfig; preloadIssues: ValidationIssue[]; } diff --git a/cli/tests/cli.test.ts b/cli/tests/cli.test.ts index 106592e..fc067f6 100644 --- a/cli/tests/cli.test.ts +++ b/cli/tests/cli.test.ts @@ -115,7 +115,7 @@ describe("llmdoc cli", () => { const result = await runCli(["show", "escape/secret.mdx"], rootDir); expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("文档不存在"); + expect(result.stdout).toContain("Document does not exist"); }); test("workspace loading rejects an llmdoc root symlink that escapes the repository", async () => { @@ -128,7 +128,7 @@ describe("llmdoc cli", () => { const result = await runCli(["tree"], rootDir); expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("符号链接逃逸"); + expect(result.stdout).toContain("through a symlink"); }); test("context validates file inputs and paginates instead of silently truncating", async () => { @@ -139,7 +139,7 @@ describe("llmdoc cli", () => { const invalid = await runCli(["context", "--files", "../outside.ts"], rootDir); expect(invalid.exitCode).toBe(1); - expect(invalid.stdout).toContain("路径必须是仓库内规范化相对路径"); + expect(invalid.stdout).toContain("normalized repository-relative path"); }); test("context reports mapped and unmapped inputs independently", async () => { @@ -234,7 +234,7 @@ code: const malformedCursor = Buffer.from(JSON.stringify({ offset: "0" })).toString("base64url"); const invalid = await runCli(["index", "--cursor", malformedCursor], rootDir); expect(invalid.exitCode).toBe(1); - expect(invalid.stdout).toContain("cursor 非法"); + expect(invalid.stdout).toContain("cursor is invalid"); }); test("new scaffolds a document under llmdoc", async () => { @@ -256,32 +256,48 @@ code: const indexName = await runCli(["new", "fresh-topic/index.mdx", "--kind", "guide"], rootDir); expect(indexName.exitCode).toBe(1); - expect(indexName.stdout).toContain("不使用 index.mdx"); + expect(indexName.stdout).toContain("does not use index.mdx"); const invalidKind = await runCli(["new", "another.mdx", "--kind", "index"], rootDir); expect(invalidKind.exitCode).toBe(1); - expect(invalidKind.stdout).toContain("非法 kind"); + expect(invalidKind.stdout).toContain("Invalid kind"); const nested = await runCli(["new", "topic/nested/file.mdx", "--kind", "guide"], rootDir); expect(nested.exitCode).toBe(1); - expect(nested.stdout).toContain("两层"); + expect(nested.stdout).toContain("topic/file depth"); const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "outside-")); fs.symlinkSync(outsideDir, path.join(rootDir, "llmdoc", "escape-topic")); const escaped = await runCli(["new", "escape-topic/file.mdx", "--kind", "guide"], rootDir); expect(escaped.exitCode).toBe(1); - expect(escaped.stdout).toContain("逃逸"); + expect(escaped.stdout).toContain("escapes the repository"); }); test("mv uses git move and rewrites links plus meta ledger", async () => { const rootDir = createFixture(); + writeRepoFile( + rootDir, + "llmdoc.config.json", + `${JSON.stringify( + { + schema: "llmdoc.config/v1", + startup: { + remindSkill: false, + preload: ["architecture.mdx", "llmdoc/api-client/retry-policy.mdx"] + } + }, + null, + 2 + )}\n` + ); fs.appendFileSync( path.join(rootDir, "llmdoc", "api-client", "overview.mdx"), "\n[query link](./retry-policy.mdx?view=full#anchor)\n\n`[inline example](./retry-policy.mdx)`\n\n```md\n[fenced example](./retry-policy.mdx)\n```\n" ); - const result = await runCli(["mv", "api-client/retry-policy.mdx", "api-client/retry-strategy.mdx"], rootDir); + const result = await runCli(["mv", "api-client/retry-policy.mdx", "api-client/retry-strategy.mdx", "--json"], rootDir); expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ rewrittenConfig: true }); expect(fs.existsSync(path.join(rootDir, "llmdoc", "api-client", "retry-strategy.mdx"))).toBe(true); expect(fs.existsSync(path.join(rootDir, "llmdoc", "api-client", "retry-policy.mdx"))).toBe(false); @@ -296,6 +312,16 @@ code: }; expect(meta.documents["api-client/retry-strategy.mdx"]).toBeTruthy(); expect(meta.documents["api-client/retry-policy.mdx"]).toBeUndefined(); + + const config = JSON.parse(fs.readFileSync(path.join(rootDir, "llmdoc.config.json"), "utf8")) as { + startup: { preload: string[] }; + }; + expect(config.startup.preload).toEqual(["architecture.mdx", "llmdoc/api-client/retry-strategy.mdx"]); + + const validate = await runCli(["validate"], rootDir); + expect(validate.exitCode).toBe(0); + const commit = await runCli(["commit", "-m", "docs: move retry policy"], rootDir); + expect(commit.exitCode).toBe(0); }); test("mv rejects invalid targets before git mv runs", async () => { @@ -306,7 +332,7 @@ code: const wrongIndex = await runCli(["mv", "api-client/retry-policy.mdx", "api-client/index.mdx"], rootDir); expect(wrongIndex.exitCode).toBe(1); - expect(wrongIndex.stdout).toContain("不使用 index.mdx"); + expect(wrongIndex.stdout).toContain("does not use index.mdx"); expect(fs.existsSync(path.join(rootDir, "llmdoc", "api-client", "retry-policy.mdx"))).toBe(true); const metaMove = await runCli(["mv", "meta.json", "meta2.json"], rootDir); @@ -320,7 +346,7 @@ code: const existingTarget = await runCli(["mv", "api-client/error-model.mdx", "api-client/overview.mdx"], rootDir); expect(existingTarget.exitCode).toBe(1); - expect(existingTarget.stdout).toContain("目标已存在"); + expect(existingTarget.stdout).toContain("Target already exists"); }); test("status and delta reflect committed, dirty, unmapped, and scope-aware git state", async () => { @@ -393,8 +419,8 @@ code: impacted: Array<{ path: string; changedCommittedPaths: string[]; dirtyPaths: string[] }>; }; expect(deltaJson.suggestedMode).toBe("deep"); - expect(deltaJson.reasons.some((reason) => reason.includes("缺少 validatedRevision"))).toBe(true); - expect(deltaJson.reasons.some((reason) => reason.includes("不存在于当前 git 历史"))).toBe(true); + expect(deltaJson.reasons.some((reason) => reason.includes("has no validatedRevision"))).toBe(true); + expect(deltaJson.reasons.some((reason) => reason.includes("does not exist in current Git history"))).toBe(true); expect(deltaJson.impacted.some((item) => item.path === "llmdoc/api-client/retry-policy.mdx")).toBe(true); expect(deltaJson.impacted.some((item) => item.path === "llmdoc/api-client/error-model.mdx")).toBe(true); }); @@ -447,12 +473,17 @@ code: const sessionStart = await runCli(["hook", "session-start"], rootDir, JSON.stringify({ source: "compact" })); expect(sessionStart.exitCode).toBe(0); expect(sessionStart.stdout).toContain("compact re-entry"); + expect(sessionStart.stdout).toContain("llmdoc skill"); + expect(sessionStart.stdout).toContain("investigator"); + expect(sessionStart.stdout).toContain("/llmdoc:update"); + expect(sessionStart.stdout).not.toMatch(/[\p{Script=Han}]/u); const stop = await runCli(["hook", "stop"], rootDir); const stopJson = JSON.parse(stop.stdout) as { continue: boolean; systemMessage?: string }; expect(stop.exitCode).toBe(0); expect(Object.keys(stopJson).every((key) => key === "continue" || key === "systemMessage")).toBe(true); expect(stopJson.continue).toBe(true); + expect(stop.stdout).not.toMatch(/[\p{Script=Han}]/u); const compact = await runCli(["hook", "compact"], rootDir); const compactJson = JSON.parse(compact.stdout) as { continue: boolean; systemMessage?: string }; @@ -460,6 +491,7 @@ code: expect(compactJson.continue).toBe(true); expect(compactJson.systemMessage).toContain("LLMDOC_STATE"); expect(compactJson.systemMessage).toContain("lesson_candidates"); + expect(compact.stdout).not.toMatch(/[\p{Script=Han}]/u); removeGitDirectory(rootDir); const degradedStop = await runCli(["hook", "stop"], rootDir); @@ -467,6 +499,179 @@ code: expect(() => JSON.parse(degradedStop.stdout)).not.toThrow(); }); + test("session-start follows startup config for skill reminders and direct document preload", async () => { + const rootDir = createFixture(); + writeRepoFile( + rootDir, + "llmdoc.config.json", + `${JSON.stringify( + { + schema: "llmdoc.config/v1", + startup: { + remindSkill: true, + preload: ["architecture.mdx", "llmdoc/api-client/error-model.mdx"] + } + }, + null, + 2 + )}\n` + ); + + const validate = await runCli(["validate"], rootDir); + expect(validate.exitCode).toBe(0); + + const sessionStart = await runCli(["hook", "session-start"], rootDir); + expect(sessionStart.exitCode).toBe(0); + expect(sessionStart.stdout).toContain("Operating guidance:"); + expect(sessionStart.stdout).toContain("llmdoc skill"); + expect(sessionStart.stdout).toContain("investigator"); + expect(sessionStart.stdout).toContain("startup preload begins (2 document(s))"); + expect(sessionStart.stdout).toContain("do not need another search/show"); + expect(sessionStart.stdout).toContain("=== llmdoc/architecture.mdx [architecture] (startup preload) ==="); + expect(sessionStart.stdout).toContain("=== llmdoc/api-client/error-model.mdx [reference] (startup preload) ==="); + expect(sessionStart.stdout).toContain("# 错误模型"); + expect(sessionStart.stdout).toContain("=== llmdoc startup preload complete ==="); + + const compactReentry = await runCli(["hook", "session-start"], rootDir, JSON.stringify({ source: "compact" })); + expect(compactReentry.stdout).toContain("compact re-entry"); + expect(compactReentry.stdout).toContain("startup preload bodies were not re-injected after compaction"); + expect(compactReentry.stdout).toContain("llmdoc/api-client/error-model.mdx"); + expect(compactReentry.stdout).not.toContain("# 错误模型"); + expect(compactReentry.stdout).not.toContain("startup preload complete"); + + const status = await runCli(["--json", "status"], rootDir); + const statusJson = JSON.parse(status.stdout) as { unmapped: { dirty: string[] } }; + expect(statusJson.unmapped.dirty).not.toContain("llmdoc.config.json"); + + commitAll(rootDir, "configure llmdoc startup context"); + const committedStatus = await runCli(["--json", "status"], rootDir); + const committedStatusJson = JSON.parse(committedStatus.stdout) as { relevantCommitsBehindHead: number }; + expect(committedStatusJson.relevantCommitsBehindHead).toBe(0); + const configOnlySessionStart = await runCli(["hook", "session-start"], rootDir); + expect(configOnlySessionStart.stdout).toContain("documents have no actionable impacts"); + expect(configOnlySessionStart.stdout).not.toContain("behind HEAD"); + }); + + test("startup config can keep the skill reminder disabled", async () => { + const rootDir = createFixture(); + writeRepoFile( + rootDir, + "llmdoc.config.json", + `${JSON.stringify( + { + schema: "llmdoc.config/v1", + startup: { remindSkill: false, preload: ["api-client/error-model.mdx"] } + }, + null, + 2 + )}\n` + ); + + const sessionStart = await runCli(["hook", "session-start"], rootDir); + expect(sessionStart.stdout).not.toContain("Operating guidance:"); + expect(sessionStart.stdout).not.toContain("llmdoc skill"); + expect(sessionStart.stdout).toContain("# 错误模型"); + expect(sessionStart.stdout).toContain("startup preload complete"); + }); + + test("validate rejects invalid startup config while session-start stays fail-open", async () => { + const rootDir = createFixture(); + writeRepoFile( + rootDir, + "llmdoc.config.json", + `${JSON.stringify( + { + schema: "llmdoc.config/v1", + startup: { remindSkill: false, preload: ["api-client/missing.mdx"] } + }, + null, + 2 + )}\n` + ); + + const validate = await runCli(["validate"], rootDir); + expect(validate.exitCode).toBe(1); + expect(validate.stdout).toContain("config.startup.preload.missing"); + expect(validate.stdout).not.toMatch(/[\p{Script=Han}]/u); + + const sessionStart = await runCli(["hook", "session-start"], rootDir); + expect(sessionStart.exitCode).toBe(0); + expect(sessionStart.stdout).toContain("invalid startup.preload entries"); + expect(sessionStart.stdout).toContain("valid remindSkill preference remained applied"); + expect(sessionStart.stdout).not.toContain("Operating guidance:"); + expect(sessionStart.stdout).not.toContain("startup preload begins"); + + const invalidSchemaRoot = createFixture(); + writeRepoFile( + invalidSchemaRoot, + "llmdoc.config.json", + `${JSON.stringify( + { + schema: "llmdoc.config/v0", + startup: { remindSkill: false, preload: ["architecture.mdx"] } + }, + null, + 2 + )}\n` + ); + const invalidSchemaSession = await runCli(["hook", "session-start"], invalidSchemaRoot); + expect(invalidSchemaSession.stdout).toContain("llmdoc.config.json could not be applied"); + expect(invalidSchemaSession.stdout).toContain("default skill reminder remains active"); + expect(invalidSchemaSession.stdout).toContain("Operating guidance:"); + expect(invalidSchemaSession.stdout).not.toContain("startup preload begins"); + }); + + test("startup config deduplicates normalized preload aliases with a warning", async () => { + const rootDir = createFixture(); + writeRepoFile( + rootDir, + "llmdoc.config.json", + `${JSON.stringify( + { + schema: "llmdoc.config/v1", + startup: { remindSkill: false, preload: ["architecture.mdx", "llmdoc/architecture.mdx"] } + }, + null, + 2 + )}\n` + ); + + const validate = await runCli(["validate"], rootDir); + expect(validate.exitCode).toBe(0); + expect(validate.stdout).toContain("config.startup.preload.duplicate"); + + const sessionStart = await runCli(["hook", "session-start"], rootDir); + expect(sessionStart.stdout).not.toContain("Operating guidance:"); + expect(sessionStart.stdout).toContain("normalized duplicate startup.preload entries were ignored"); + expect(sessionStart.stdout.match(/=== llmdoc\/architecture\.mdx \[architecture\] \(startup preload\) ===/g)).toHaveLength(1); + }); + + test("startup preload has no character or token budget", async () => { + const rootDir = createFixture(); + const marker = "UNBOUNDED_STARTUP_CONTEXT_END"; + fs.appendFileSync( + path.join(rootDir, "llmdoc", "architecture.mdx"), + `\n${"large startup context ".repeat(1_000)}${marker}\n` + ); + writeRepoFile( + rootDir, + "llmdoc.config.json", + `${JSON.stringify( + { schema: "llmdoc.config/v1", startup: { remindSkill: false, preload: ["architecture.mdx"] } }, + null, + 2 + )}\n` + ); + + const validate = await runCli(["validate"], rootDir); + expect(validate.exitCode).toBe(0); + + const sessionStart = await runCli(["hook", "session-start"], rootDir); + expect(sessionStart.exitCode).toBe(0); + expect(sessionStart.stdout).toContain(marker); + expect(sessionStart.stdout).toContain("=== llmdoc startup preload complete ==="); + }); + test("llmdoc and tmp-only changes do not become unmapped update signals", async () => { const rootDir = createFixture(); writeRepoFile(rootDir, "llmdoc/api-client/overview.mdx", "---\ndescription: API client 的边界与路由。\nkind: reference\n---\n\n# API Client\n"); @@ -497,8 +702,8 @@ code: const sessionStart = await runCli(["hook", "session-start"], rootDir); expect(sessionStart.exitCode).toBe(0); - expect(sessionStart.stdout).toContain("文档无待处理影响"); - expect(sessionStart.stdout).not.toContain("落后 HEAD"); + expect(sessionStart.stdout).toContain("documents have no actionable impacts"); + expect(sessionStart.stdout).not.toContain("behind HEAD"); const stop = await runCli(["hook", "stop"], rootDir); const stopJson = JSON.parse(stop.stdout) as { continue: boolean; systemMessage?: string }; @@ -514,13 +719,13 @@ code: const sessionStart = await runCli(["hook", "session-start"], rootDir); expect(sessionStart.exitCode).toBe(0); - expect(sessionStart.stdout).toContain("待处理反思候选 2 个"); + expect(sessionStart.stdout).toContain("2 pending reflection candidate(s)"); const stop = await runCli(["hook", "stop"], rootDir); const stopJson = JSON.parse(stop.stdout) as { continue: boolean; systemMessage?: string }; expect(stop.exitCode).toBe(0); expect(stopJson.continue).toBe(true); - expect(stopJson.systemMessage).toContain("2 个反思候选待处理"); + expect(stopJson.systemMessage).toContain("2 reflection candidate(s) pending"); expect(stopJson.systemMessage).toContain("/llmdoc:update --reflection"); expect(stopJson.systemMessage).not.toContain("user-correction.md"); expect(stopJson.systemMessage).not.toContain("test-failure.md"); @@ -531,7 +736,7 @@ code: writeRepoFile(rootDir, ".llmdoc-tmp/reflections/pending/notes.txt", "not a candidate\n"); const sessionStart = await runCli(["hook", "session-start"], rootDir); - expect(sessionStart.stdout).not.toContain("反思候选"); + expect(sessionStart.stdout).not.toContain("reflection candidate"); const stop = await runCli(["hook", "stop"], rootDir); expect(JSON.parse(stop.stdout)).toEqual({ continue: true }); @@ -544,13 +749,29 @@ code: status: string; writable: boolean; growth: { currentDocumentCount: number }; + startupPreloads: string[]; mergeCandidates: unknown[]; }; expect(pruneJson.status).toBe("dry_run"); expect(pruneJson.writable).toBe(false); expect(pruneJson.growth.currentDocumentCount).toBeGreaterThan(0); + expect(pruneJson.startupPreloads).toEqual([]); expect(Array.isArray(pruneJson.mergeCandidates)).toBe(true); + writeRepoFile( + rootDir, + "llmdoc.config.json", + `${JSON.stringify( + { schema: "llmdoc.config/v1", startup: { preload: ["api-client/error-model.mdx"] } }, + null, + 2 + )}\n` + ); + const configuredPrune = await runCli(["prune", "--report"], rootDir); + expect(configuredPrune.stdout).toContain("startup preload references:"); + expect(configuredPrune.stdout).toContain("llmdoc/api-client/error-model.mdx"); + expect(configuredPrune.stdout).toContain("Keep llmdoc.config.json synchronized"); + const v3Upgrade = await runCli(["upgrade", "--json"], rootDir); const v3UpgradeJson = JSON.parse(v3Upgrade.stdout) as { status: string; requiresRecorderSemanticMigration: boolean }; expect(v3UpgradeJson.status).toBe("no_change"); @@ -603,6 +824,8 @@ code: const result = spawnSync(binPath, ["--help"], { cwd: consumerDir, encoding: "utf8" }); expect(result.status).toBe(0); expect(result.stdout).toContain("Usage: llmdoc"); + expect(result.stdout).toContain("Quick reference by purpose"); + expect(result.stdout).not.toMatch(/[\p{Script=Han}]/u); } finally { fs.rmSync(consumerDir, { recursive: true, force: true }); } @@ -627,19 +850,19 @@ code: const badScope = await runCli(["delta", "--scope", "missing-topic"], rootDir); expect(badScope.exitCode).toBe(1); - expect(badScope.stdout).toContain("scope 未命中"); + expect(badScope.stdout).toContain("Scope did not match"); const badFingerprint = await runCli(["fingerprint", "--all", "--update", "api-client/overview.mdx"], rootDir); expect(badFingerprint.exitCode).toBe(1); - expect(badFingerprint.stdout).toContain("不能同时"); + expect(badFingerprint.stdout).toContain("cannot use --all and --update together"); const badKindIndex = await runCli(["index", "--kind", "bad-kind"], rootDir); expect(badKindIndex.exitCode).toBe(1); - expect(badKindIndex.stdout).toContain("非法 kind"); + expect(badKindIndex.stdout).toContain("Invalid kind"); const badKindSearch = await runCli(["search", "重试", "--kind", "bad-kind"], rootDir); expect(badKindSearch.exitCode).toBe(1); - expect(badKindSearch.stdout).toContain("非法 kind"); + expect(badKindSearch.stdout).toContain("Invalid kind"); writeRepoFile( rootDir, @@ -677,10 +900,10 @@ code: writeRepoFile(rootDir, "src/team/foo.ts", "export const foo = true;\n"); const validate = await runCli(["validate"], rootDir); expect(validate.exitCode).toBe(1); - expect(validate.stdout).toContain("CodeRef 存在未知属性"); - expect(validate.stdout).not.toContain("正文链接悬空: ./missing.mdx"); - expect(validate.stdout).not.toContain("禁止在正文中使用 MDX/JS 表达式"); - expect(validate.stdout).not.toContain("code.paths 指向不存在的路径: src/*/foo.ts"); + expect(validate.stdout).toContain("CodeRef contains an unknown attribute"); + expect(validate.stdout).not.toContain("Body link points to a missing document: ./missing.mdx"); + expect(validate.stdout).not.toContain("MDX/JS expressions are forbidden"); + expect(validate.stdout).not.toContain("code.paths points to a missing path: src/*/foo.ts"); const tmpEscape = fs.mkdtempSync(path.join(rootDir, "tmp-escape-")); fs.symlinkSync(tmpEscape, path.join(rootDir, ".llmdoc-tmp")); @@ -701,6 +924,7 @@ code: const version = await runCli(["--version"], rootDir); expect(version.exitCode).toBe(0); expect(version.stdout.trim()).toBe(PACKAGE_VERSION); + }); test("all public json payloads validate through runtime output schemas", async () => { @@ -758,14 +982,14 @@ code: exceedsGate: false } }) - ).toThrow("内部输出契约错误"); + ).toThrow("Internal output contract error"); expect(() => assertOutputSchema("hook", { continue: true, systemMessage: 42 }) - ).toThrow("内部输出契约错误"); + ).toThrow("Internal output contract error"); }); test("mv stays correct when an ancestor directory is named llmdoc", async () => { @@ -826,7 +1050,7 @@ code: const rootDir = createFixture(); const result = await runCli(["tree", "--limit", "abc"], rootDir); expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("非法整数"); + expect(result.stdout).toContain("Invalid integer"); }); test("hooks stay silent in repositories without llmdoc", async () => { @@ -935,7 +1159,7 @@ code: const refused = await runCli(["init-state"], rootDir); expect(refused.exitCode).toBe(1); - expect(refused.stdout).toContain("已存在"); + expect(refused.stdout).toContain("already exists"); }); test("commit finalizes llmdoc writes as docs+meta commits with fingerprints", async () => { @@ -1048,16 +1272,16 @@ code: const conflict = await runCli(["commit", "--all", "--verified", "api-client/retry-policy.mdx"], rootDir); expect(conflict.exitCode).not.toBe(0); - expect(conflict.stdout).toContain("不能同时使用 --all 与 --verified"); + expect(conflict.stdout).toContain("cannot use --all and --verified together"); const missing = await runCli(["commit", "--verified", "api-client/missing.mdx"], rootDir); expect(missing.exitCode).not.toBe(0); - expect(missing.stdout).toContain("文档不存在"); + expect(missing.stdout).toContain("Document does not exist"); fs.appendFileSync(path.join(rootDir, "src", "api", "retry.ts"), "// dirty change\n"); const blocked = await runCli(["commit", "--verified", "api-client/retry-policy.mdx"], rootDir); expect(blocked.exitCode).not.toBe(0); - expect(blocked.stdout).toContain("fingerprint 预检未通过"); + expect(blocked.stdout).toContain("Fingerprint preflight failed"); }); test("llmdocignore filters unmapped noise", async () => { @@ -1084,7 +1308,7 @@ code: const result = await runCli(["commit", "--all", "-m", "repro: llmdoc partial commit"], rootDir); expect(result.exitCode).not.toBe(0); - expect(result.stdout).toContain("未创建任何 commit"); + expect(result.stdout).toContain("no commit was created"); const headAfter = spawnSync("git", ["rev-parse", "HEAD"], { cwd: rootDir, encoding: "utf8" }).stdout.trim(); expect(headAfter).toBe(headBefore); @@ -1123,7 +1347,7 @@ code: const missing = await runCli(["adopt", "api-client/not-there.mdx"], rootDir); expect(missing.exitCode).toBe(1); - expect(missing.stdout).toContain("目标不存在"); + expect(missing.stdout).toContain("Target does not exist"); }); test("status distinguishes metadata-only commits behind baseline from relevant source commits", async () => { diff --git a/cli/tests/cold-start.test.ts b/cli/tests/cold-start.test.ts index 5df7512..b64773f 100644 --- a/cli/tests/cold-start.test.ts +++ b/cli/tests/cold-start.test.ts @@ -62,7 +62,7 @@ describe("cold-start workflow", () => { const unborn = await runCli(["init-state"], rootDir); expect(unborn.exitCode).toBe(1); - expect(unborn.stdout).toContain("HEAD 尚无 commit"); + expect(unborn.stdout).toContain("HEAD has no commit"); expect(unborn.stdout).toContain("git commit --allow-empty"); commitEmpty(rootDir); diff --git a/cli/tests/search-cjk.test.ts b/cli/tests/search-cjk.test.ts index 89714d2..59e9864 100644 --- a/cli/tests/search-cjk.test.ts +++ b/cli/tests/search-cjk.test.ts @@ -96,7 +96,7 @@ kind: reference expect(payload.results[0]?.path).toBe("llmdoc/devices/throughput.mdx"); const textResult = await runCli(["search", "吞吐量"], rootDir); - expect(textResult.stdout).toContain("已使用 CJK bigram 降级检索"); + expect(textResult.stdout).toContain("CJK bigram fallback was used"); const noisyLongQuery = await runCli(["--json", "search", "吞吐量完全未知"], rootDir); const noisyPayload = JSON.parse(noisyLongQuery.stdout) as SearchPayload; diff --git a/cli/tests/viewer-assets.test.ts b/cli/tests/viewer-assets.test.ts index b67c83e..9b3be79 100644 --- a/cli/tests/viewer-assets.test.ts +++ b/cli/tests/viewer-assets.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import path from "node:path"; import { performance } from "node:perf_hooks"; import { describe, expect, test } from "vitest"; @@ -23,6 +25,13 @@ function documentNode(path: string, topic: string | null, status = "fresh") { } describe("viewer browser models", () => { + test("published viewer interface strings are English", () => { + for (const fileName of ["viewer.html", "viewer-app.js", "viewer-detail.js", "viewer-graph.js"]) { + const content = fs.readFileSync(path.resolve(__dirname, "..", "assets", fileName), "utf8"); + expect(content).not.toMatch(/[\p{Script=Han}]/u); + } + }); + test("topic graph deterministically aggregates cross-topic relations", () => { const state = { nodes: [ diff --git a/cli/tests/viewer-state.test.ts b/cli/tests/viewer-state.test.ts index e0b0736..cfadf2f 100644 --- a/cli/tests/viewer-state.test.ts +++ b/cli/tests/viewer-state.test.ts @@ -145,6 +145,12 @@ function makeWorkspace(documents: ParsedDocument[]): WorkspaceData { totalEstimatedTokens: 40 } }, + llmdocConfig: { + exists: false, + config: null, + preloadPaths: [], + issues: [] + }, preloadIssues: [] }; } diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 928fbea..cd53e67 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -25,6 +25,12 @@ This project uses llmdoc V3 as persistent engineering context. - If hooks are available, keep them read-only and fail-open. They may signal startup context, update needs, or compact state; they must not mutate knowledge or source code. +- SessionStart supplies the default operating guidance. A repository may disable + it with `startup.remindSkill: false` and may opt into exact document preloading + through workspace-root `llmdoc.config.json`. On cold start, treat preloaded + bodies as complete only when the final completion marker is present; otherwise + retrieve the missing body with `show`. Compact re-entry lists configured IDs + without injecting all bodies again. ## Retrieval gate diff --git a/docs/v3-design/03-cli.md b/docs/v3-design/03-cli.md index 7535b6e..446090c 100644 --- a/docs/v3-design/03-cli.md +++ b/docs/v3-design/03-cli.md @@ -43,11 +43,13 @@ CLI 是 V3 的 Runtime 实体:所有确定性、可测试、重复出现的工 | 命令 | 输出 | |---|---| -| `llmdoc hook session-start` | ≤200 token 状态信号:是否存在 llmdoc、可执行 delta、pending 反思候选数、冷启动还是 compact 重入 | +| `llmdoc hook session-start` | 输出状态信号与默认最小操作守则;workspace 根 `llmdoc.config.json` 可关闭守则或让冷启动追加指定正文,compact 重入只列配置 ID | | `llmdoc hook stop` | best-effort 提醒:代码 delta 或 pending 反思候选是否触发 update 判断;候选可在无代码变化时独立触发 | | `llmdoc hook compact` | 输出 LLMDOC_STATE 保存指令(要求 summary 保留:目标、已读文档路径、关键结论、lesson candidates、下一步) | -Fail policy:hook 执行失败不阻塞开发;hook 永不写 `llmdoc/`;写命令不依赖 hook 才正确。反思信号只统计 `.llmdoc-tmp/reflections/pending/` 直属 Markdown 文件,不读取内容、stdin 或完整 transcript。 +`llmdoc.config.json` 使用 `llmdoc.config/v1` schema;`startup.remindSkill` 默认为 `true`,控制是否注入 operating guidance,`startup.preload` 按顺序列出冷启动时直接进入 context 的精确文档 ID。llmdoc 不对冷启动 preload 设置字符/token 预算,并用结尾完成标记暴露可能的宿主截断;compact 重入不重复正文,只列 ID。`validate` 负责 schema、路径存在性与规范化去重;重复别名只 warning,preload 单项错误不会重置合法的 `remindSkill`。配置本身属于知识控制面,不进入 implementation delta/unmapped 信号。 + +Fail policy:hook 执行失败不阻塞开发;hook 永不写 `llmdoc/`;写命令不依赖 hook 才正确。JSON/schema 不可用时 SessionStart 保留状态与默认 guidance;schema 合法但 preload 有错时保留 `remindSkill` 并跳过 preload。CLI 的固定用户可见文案(help、text/JSON message、hook 与 Viewer UI/error)统一为英文;中文检索输入和文档内容不翻译。反思信号只统计 `.llmdoc-tmp/reflections/pending/` 直属 Markdown 文件,不读取内容、stdin 或完整 transcript。 ### 2.4 维护面 @@ -55,8 +57,8 @@ Fail policy:hook 执行失败不阻塞开发;hook 永不写 `llmdoc/`;写命令 |---|---| | `llmdoc new --kind ` | 脚手架:生成带合法 front matter 的空文档;最近 Git 根尚无 `llmdoc/` 时自动创建,并在缺少 ledger 时提示后续 `init-state` | | `llmdoc adopt ` | 无损登记:把已存在的合法 `.mdx` 登记进 `meta.json`(`validatedRevision: null`,不改正文,幂等) | -| `llmdoc mv ` | 重命名/移动:`git mv` + 批量更新引用与 ledger key | -| `llmdoc prune --report` | growth 报告:当前规模 vs convergence baseline、重复/碎片候选(只报告,收敛动作由 Recorder 做) | +| `llmdoc mv ` | 重命名/移动:`git mv` + 批量更新正文引用、ledger key 与 config preload | +| `llmdoc prune --report` | growth 报告:当前规模、重复/碎片候选与 config preload 引用(只报告,收敛动作由 Recorder 做) | | `llmdoc upgrade` | 盘点 legacy/V2 到 V3 的迁移需求(惰性加载:不被其他命令引用,正常上下文零出现) | | `llmdoc serve [--port]` | **Web Viewer**:一键启动本地 HTTP 服务(仅绑定 127.0.0.1),浏览文档结构、关系图与新鲜度,Ctrl-C 退出 | diff --git a/docs/v3-design/04-workflows.md b/docs/v3-design/04-workflows.md index 50c0f8c..b485831 100644 --- a/docs/v3-design/04-workflows.md +++ b/docs/v3-design/04-workflows.md @@ -28,14 +28,14 @@ ### 3.1 日常任务 ```text -SessionStart hook → 一行状态信号 +SessionStart hook → 状态信号 + 默认最小操作守则(可由 config 关闭) → (需要 llmdoc 时) llmdoc tree # 动态全局地图:根单例 + topics + descriptions → llmdoc index --topic # topic 内文档元数据 → llmdoc context --files <要改的文件> / search # 定位候选 → llmdoc show <少量文档> # 只读确有价值的正文 ``` -每一层都允许停止。没有固定 startup pack;读多少由任务决定。CLI 是强制外部工具,`tree` 就是根入口——不存在"先找 index.md"这一步。 +每一层都允许停止。默认操作守则不是 startup pack;默认不预载正文,读多少仍由任务决定。仓库只有通过 workspace 根 `llmdoc.config.json` 显式声明 `startup.preload` 时,冷 SessionStart 才直接载入指定正文;结尾完成标记存在时可跳过这些文档的 search/show。CLI 是强制外部工具,`tree` 就是根入口——不存在"先找 index.md"这一步。 ### 3.2 Compact continuation @@ -47,6 +47,7 @@ Compact summary 必须保存 `LLMDOC_STATE`:active goal、已读文档路径、 ``` 仅在状态不足、相关文档已变、进入新 topic、任务改变或证据冲突时做 targeted refresh。 +SessionStart 的 compact 重入只列出配置的 preload 文档 ID,不重新灌入正文;这不能替代 `LLMDOC_STATE`,也不能退化为固定 startup pack。 ### 3.3 任务结束 diff --git a/docs/v3-design/05-packaging.md b/docs/v3-design/05-packaging.md index 057ff76..3f77a04 100644 --- a/docs/v3-design/05-packaging.md +++ b/docs/v3-design/05-packaging.md @@ -52,7 +52,8 @@ llmdoc-repository/ # 根 = 标准 Claude Code plugin 共同约束: - upgrade 入口禁止隐式调用,正文惰性加载; -- hook 只注入短状态信号,失败不阻塞开发; +- hook 注入状态和默认最小操作守则,失败不阻塞开发; +- 消费仓库可用 workspace 根 `llmdoc.config.json` 关闭默认 guidance 或增加精确文档 preload;冷启动不设 llmdoc context 预算并带完成标记,compact 重入只列 ID;preload 字段错误不重置合法 reminder; - hook 的 `npx --package` 使用 npm alias 指向 `@tokenroll/llmdoc`,避免宿主仓库中同名但缺失 bin 的 local/file dependency 抢先满足解析; - 插件不复制 CLI 已承担的机械逻辑。 @@ -60,7 +61,8 @@ llmdoc-repository/ # 根 = 标准 Claude Code plugin - lint + CLI 单元/集成测试; - `llmdoc validate` 跑本仓库 dogfood `llmdoc/`; -- prompt 预算检查:operating skill、各命令 SOP、hook 输出的 token 上限(超限 fail); +- prompt 预算检查:operating skill、各命令 SOP,以及 Stop/PreCompact hook 输出上限;SessionStart 不设预算; +- website check/build 并验证产品域名下发布的 config schema 与 CLI 事实源结构一致; - 发布:npm 发布 `@tokenroll/llmdoc`,插件随 tag 打包。 ## 4. 实施阶段 diff --git a/hooks/hooks.json b/hooks/hooks.json index bb8c9f1..ffeca0e 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,13 +1,12 @@ { - "description": "V3 llmdoc hooks delegated to the scoped llmdoc CLI through an npm alias that cannot be shadowed by a same-name local dependency. Missing packages are installed non-interactively; SessionStart may return plain text, while Stop and PreCompact return JSON on success.", + "description": "V3 llmdoc hooks delegated to the scoped llmdoc CLI through an npm alias that cannot be shadowed by a same-name local dependency. Missing packages are installed non-interactively; SessionStart may return plain text (delta status, configurable operating guidance, cold-start document preload, or compact re-entry document IDs), while Stop and PreCompact return JSON on success.", "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", - "command": "npx -y --package=@tokenroll/llmdoc-hook-runtime@npm:@tokenroll/llmdoc -- llmdoc hook session-start", - "additionalContextLimit": 1200 + "command": "npx -y --package=@tokenroll/llmdoc-hook-runtime@npm:@tokenroll/llmdoc -- llmdoc hook session-start" } ] } diff --git a/llmdoc.config.json b/llmdoc.config.json new file mode 100644 index 0000000..8fa80f6 --- /dev/null +++ b/llmdoc.config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://llmdoc.tokenroll.ai/schemas/config.schema.json", + "schema": "llmdoc.config/v1", + "startup": { + "remindSkill": true, + "preload": [ + "architecture.mdx", + "cli-runtime/state-and-validation.mdx" + ] + } +} diff --git a/package-lock.json b/package-lock.json index f15731c..a9d230b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tokenroll/llmdoc-repository", - "version": "3.5.3", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tokenroll/llmdoc-repository", - "version": "3.5.3", + "version": "3.6.0", "devDependencies": { "@tokenroll/llmdoc": "file:cli", "gray-matter": "^4.0.3" @@ -17,7 +17,7 @@ }, "cli": { "name": "@tokenroll/llmdoc", - "version": "3.5.3", + "version": "3.6.0", "dev": true, "dependencies": { "ajv": "^8.17.1", diff --git a/package.json b/package.json index f6ce62b..a47cc0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/llmdoc-repository", - "version": "3.5.3", + "version": "3.6.0", "private": true, "description": "Development workspace for the llmdoc V3 CLI and plugin surfaces", "scripts": { diff --git a/scripts/check-codex-surface.mjs b/scripts/check-codex-surface.mjs index 5da381b..dd01681 100644 --- a/scripts/check-codex-surface.mjs +++ b/scripts/check-codex-surface.mjs @@ -166,7 +166,7 @@ for (const name of ["llmdoc", "init", "update", "prune", "upgrade"]) { // Skill references are part of the executable prompt surface too. A mirrored SKILL.md // is not sufficient when its conditionally loaded guidance is missing or stale. -for (const rel of ["llmdoc/references/knowledge-topology.md"]) { +for (const rel of ["llmdoc/references/knowledge-topology.md", "llmdoc/references/startup-config.md"]) { const claudePath = `skills/${rel}`; const codexPath = `.agents/skills/${rel}`; const claudeReference = readText(claudePath); @@ -194,11 +194,23 @@ for (const rel of [ } } +for (const name of ["init", "update", "prune"]) { + for (const rel of [`skills/${name}/SKILL.md`, `.agents/skills/${name}/SKILL.md`]) { + const content = readText(rel); + if (content !== null && !content.includes("../llmdoc/references/startup-config.md")) { + errors.push(`${rel}: 未按需路由到 startup-config reference`); + } + } +} + for (const rel of ["skills/llmdoc/SKILL.md", ".agents/skills/llmdoc/SKILL.md"]) { const content = readText(rel); if (content !== null && !content.includes("references/knowledge-topology.md")) { errors.push(`${rel}: operating skill 未暴露 knowledge-topology reference`); } + if (content !== null && !content.includes("references/startup-config.md")) { + errors.push(`${rel}: operating skill 未暴露 startup-config reference`); + } } for (const rel of ["agents/recorder.md", ".codex/agents/recorder.toml"]) { diff --git a/scripts/check-prompt-budget.mjs b/scripts/check-prompt-budget.mjs index 563265e..91de415 100644 --- a/scripts/check-prompt-budget.mjs +++ b/scripts/check-prompt-budget.mjs @@ -27,8 +27,6 @@ for (const [relativePath, limit] of limits) { } const hookCases = [ - { mode: "session-start", limit: 200, stdin: JSON.stringify({ source: "startup" }) }, - { mode: "session-start", limit: 200, stdin: JSON.stringify({ source: "compact" }) }, { mode: "stop", limit: 300, stdin: "{}", json: true }, { mode: "compact", limit: 300, stdin: "{}", json: true } ]; diff --git a/skills/init/SKILL.md b/skills/init/SKILL.md index 149894f..7cdfc59 100644 --- a/skills/init/SKILL.md +++ b/skills/init/SKILL.md @@ -41,6 +41,7 @@ Stop instead of improvising when: - Prefer the smallest sufficient set of high-value owner docs over broad shallow inventory. Depth never excuses a first-class subsystem with neither an owner nor an intentional no-doc decision. - Keep stable knowledge in `llmdoc/` and validity state in `llmdoc/meta.json`. - Create root singleton docs only for genuinely cross-topic contracts; otherwise create only the necessary one-level topic directories. Topics are plain directories with no `index.mdx` entry node. + - If the user wants non-default SessionStart guidance or deliberate document preload, read [Startup Configuration](../llmdoc/references/startup-config.md) and create `llmdoc.config.json`; otherwise do not add optional startup config during bootstrap. 3. Validate before reporting success. - Seed the ledger with `init-state` (writes meta.json with null revisions), then run `validate` and fix all schema, routing, and reference failures. diff --git a/skills/llmdoc/SKILL.md b/skills/llmdoc/SKILL.md index 483806a..b71a4ff 100644 --- a/skills/llmdoc/SKILL.md +++ b/skills/llmdoc/SKILL.md @@ -6,9 +6,7 @@ allowed-tools: Read, Glob, Grep, Bash, Write, Edit, WebSearch, WebFetch # /llmdoc -The operating protocol for V3 `llmdoc` projects. `llmdoc/` holds the architecture, constraints, and working agreements that source code does not cheaply give back; the CLI is how you reach them. - -Every command below runs as `npx -y @tokenroll/llmdoc `; the CLI Invocation section holds the full rules. +Use the CLI to retrieve durable architecture, constraints, and working agreements from V3 `llmdoc/` projects. Run commands as `npx -y @tokenroll/llmdoc `. ## Retrieval Gate @@ -48,6 +46,7 @@ These entry points are alternatives, not a sequence. Stop as soon as the task ha - If `llmdoc/` does not exist, suggest `/llmdoc:init`; do not fabricate the knowledge surface ad hoc. - When a task produces durable knowledge changes, suggest `/llmdoc:update` at the end. - For topology or routing work, read [Knowledge Topology](references/knowledge-topology.md). +- For SessionStart reminders or document preload, read [Startup Configuration](references/startup-config.md). - Never suggest `/llmdoc:upgrade`; it runs only when the user asks for it by name. ## Reflection Gate diff --git a/skills/llmdoc/references/startup-config.md b/skills/llmdoc/references/startup-config.md new file mode 100644 index 0000000..8ed9be8 --- /dev/null +++ b/skills/llmdoc/references/startup-config.md @@ -0,0 +1,40 @@ ++# Startup Configuration + +Read this reference when a user asks to configure SessionStart guidance or preload documents, or when a workflow creates, renames, merges, or deletes a document named in startup preload. + +## File and schema + +Place the optional `llmdoc.config.json` at the llmdoc workspace root. In a Git repository this is the nearest Git root that owns `llmdoc/`; the no-Git fallback is the directory that owns `llmdoc/`. + +```json +{ + "$schema": "https://llmdoc.tokenroll.ai/schemas/config.schema.json", + "schema": "llmdoc.config/v1", + "startup": { + "remindSkill": true, + "preload": [ + "architecture.mdx", + "api-client/contracts.mdx" + ] + } +} +``` + +- `startup.remindSkill` defaults to `true`. Set it to `false` only when the repository deliberately supplies equivalent operating guidance elsewhere or wants no proactive reminder. +- `startup.preload` contains exact `.mdx` document IDs in declaration order. An entry may include the `llmdoc/` prefix. +- Cold SessionStart injects configured bodies directly and has no llmdoc character or token budget. A final completion marker distinguishes a complete preload from host-side truncation; if it is absent, retrieve only the missing body with `show`. +- Compact re-entry lists configured document IDs without injecting the bodies again. Use the compacted `LLMDOC_STATE` first and retrieve a body only when needed. + +## Validation and degradation + +Run `validate` after creating or editing the file. Invalid JSON or schema cannot preserve field intent, so hooks use the default reminder and skip preload. When the schema is valid but a preload path is invalid or missing, hooks preserve the valid `remindSkill` choice and skip the preload field. Entries that normalize to the same document are deduplicated with a warning. + +## Structural changes + +A preload entry is a persistent reference and must stay synchronized with document identity: + +- `llmdoc mv` rewrites matching preload entries transactionally with document references and the ledger. +- Before a prune workflow manually merges or deletes documents, inspect the report's startup preload references and update or remove affected entries in the same write set. +- After any manual path change, run `validate` before `commit`. A missing preload target is an error and intentionally blocks finalization. + +Do not create this file during init unless the user or repository requirements call for non-default startup behavior or deliberate document preload. diff --git a/skills/prune/SKILL.md b/skills/prune/SKILL.md index 9bce0ac..6e83659 100644 --- a/skills/prune/SKILL.md +++ b/skills/prune/SKILL.md @@ -35,6 +35,7 @@ This command does not authorize source-code edits. 2. Decide the convergence plan with `recorder`. - If the plan moves ownership, changes topic boundaries, or merges/splits documents, read [Knowledge Topology and Context Floor](../llmdoc/references/knowledge-topology.md) before rewriting. + - Read [Startup Configuration](../llmdoc/references/startup-config.md) when the report lists startup preload references. Update or remove affected config entries in the same write set before merging or deleting their documents; `mv` handles direct renames automatically. - Merge duplicated docs. - Rewrite fragmented docs when a clearer topic boundary exists. - Apply the Stable Knowledge Gate sentence by sentence. Remove command/file inventories, current-state evidence, and other facts that a reader can cheaply recover from canonical sources. @@ -43,6 +44,7 @@ This command does not authorize source-code edits. - Delete a document when it has no unique durable knowledge; canonical source, schema, help, or tests are valid destinations for discarded evidence. Do not copy low-value content elsewhere merely to justify deletion. 3. Re-validate the result. + - Confirm every configured startup preload still targets the surviving owner document. - Run `validate`. - When ownership or routing changed, run the reference's scoped concept, per-file owner, broad-glob precision, and prerequisite checks; structural validation alone is insufficient. - Re-run `prune --report` and compare document/token scale with the first report. diff --git a/skills/update/SKILL.md b/skills/update/SKILL.md index 97cf7e1..6c88184 100644 --- a/skills/update/SKILL.md +++ b/skills/update/SKILL.md @@ -54,6 +54,7 @@ This command does not authorize source-code edits. - Scaffold brand-new docs with `new`; register docs that already exist as files with `adopt ` — never hand-edit `meta.json` or recreate the file through `new`. 5. Finalize. + - If document identities changed, read [Startup Configuration](../llmdoc/references/startup-config.md). `mv` syncs renames; sync manual merges or deletions before validation. - Run `validate`; after mapping or boundary changes, also run the reference's scoped routing acceptance. - If prose changed, run `commit -m ""`, adding `--verified ` for reviewed unchanged docs. If all stayed unchanged, run `commit --verified `. Full verification uses `--all`, never with `--verified`. - `commit` validates, commits prose, refreshes fingerprints, and lands `meta.json` separately. Never reconstruct this sequence manually or `--amend` it. diff --git a/tests/parity-checklist.md b/tests/parity-checklist.md index 2f3d602..ef3978f 100644 --- a/tests/parity-checklist.md +++ b/tests/parity-checklist.md @@ -9,8 +9,8 @@ Claude Code 根插件是唯一手工维护的准源。Codex 表面由 ACPlugin - [ ] `init/update/prune/upgrade` 的宿主专属 front matter / UI policy 保持正确;五个 skill 和三个 agent 的正文一致性已由脚本机械校验。 - [ ] `upgrade` 在两个平台都保持仅显式调用(Claude 侧 `disable-model-invocation: true`;Codex 侧 `policy.allow_implicit_invocation: false`),未被 operating skill 或 hook 隐式触发。 - [ ] Claude 的 `SessionStart`、`Stop`、`PreCompact` 都通过 npm alias `@tokenroll/llmdoc-hook-runtime@npm:@tokenroll/llmdoc` 调用 scoped CLI,避免消费仓库的同名本地依赖遮蔽 runtime;Codex 保留仓库根 `hooks/hooks.json`,并按官方信任模型启用。 -- [ ] hooks fail-open、永不写 `llmdoc/`;SessionStart 不超过 200 token,Stop/PreCompact 成功时输出合法 JSON;pending 反思候选能在无代码 delta 时触发 update 提醒。 -- [ ] 生成目录中没有 V2 `worker`、tracked reflection/memory 树、startup pack、watermark 或旧命令残留;恢复后的 Reflector 不保存 transcript。 +- [ ] hooks fail-open、永不写 `llmdoc/`;SessionStart 默认注入英文 operating guidance、允许 config 关闭或冷启动预载正文、且不设置 llmdoc 字符/token 预算;完成标记可检测宿主截断,preload 字段错误不重置合法 reminder;Stop/PreCompact 成功时输出合法英文 JSON message。 +- [ ] 生成目录中没有 V2 `worker`、tracked reflection/memory 树、隐式 startup pack、watermark 或旧命令残留;compact 重入只列配置的 preload ID、不重新注入正文,恢复后的 Reflector 不保存 transcript。 - [ ] `.agents/skills/upgrade/agents/openai.yaml` 设置 `policy.allow_implicit_invocation: false`,确保 upgrade 只能显式调用。 - [ ] 按本清单完成抽验,Codex plugin scanner 与完整 CI 均通过。 diff --git a/website/package.json b/website/package.json index b11226d..57c902d 100644 --- a/website/package.json +++ b/website/package.json @@ -8,6 +8,7 @@ "dev": "astro dev", "check": "astro check", "build": "astro build", + "check:schema": "node scripts/check-published-schema.mjs", "preview": "astro preview", "cf:dev": "npm run build && wrangler dev", "cf:dry-run": "npm run build && wrangler deploy --dry-run" diff --git a/website/public/llms.txt b/website/public/llms.txt index 636ad48..8efbc5e 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -21,6 +21,10 @@ Repository: https://github.com/TokenRollAI/llmdoc - https://llmdoc.tokenroll.ai/zh/docs/workflows/ - https://llmdoc.tokenroll.ai/zh/docs/cli/ +## Machine-readable schemas + +- https://llmdoc.tokenroll.ai/schemas/config.schema.json + ## CLI Treat llmdoc as external tooling. Do not add it to the consumer project's package.json or lockfile. diff --git a/website/scripts/check-published-schema.mjs b/website/scripts/check-published-schema.mjs new file mode 100644 index 0000000..743273e --- /dev/null +++ b/website/scripts/check-published-schema.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +const sourcePath = fileURLToPath(new URL("../../cli/schemas/config.schema.json", import.meta.url)); +const publishedPath = fileURLToPath(new URL("../dist/schemas/config.schema.json", import.meta.url)); +const source = JSON.parse(fs.readFileSync(sourcePath, "utf8")); +const published = JSON.parse(fs.readFileSync(publishedPath, "utf8")); + +assert.deepEqual(published, source, "Published config schema differs from the CLI source schema."); +assert.equal( + published.$id, + "https://llmdoc.tokenroll.ai/schemas/config.schema.json", + "Published config schema uses an unexpected canonical URL." +); + +console.log("published config schema check: ok"); diff --git a/website/src/pages/docs/cli/index.astro b/website/src/pages/docs/cli/index.astro index f9d54ae..5850280 100644 --- a/website/src/pages/docs/cli/index.astro +++ b/website/src/pages/docs/cli/index.astro @@ -24,14 +24,14 @@ import DocsLayout from "../../../layouts/DocsLayout.astro";
status
Validity, baseline, dirty, and growth signals.
delta
Changed code to impacted-document closure.
-
validate
Schema, structure, relations, links, code paths—including zero-match globs—and ledger consistency.
+
validate
Schema, structure, relations, links, code paths—including zero-match globs—ledger consistency, and optional startup config.

Mutate safely

new <path> --kind <kind>
Scaffold a V3 document, creating the first llmdoc/ directory at the nearest Git root when needed.
adopt <path…>
Register valid existing documents without rewriting their bodies.
-
mv <from> <to>
Move a document and rewrite internal references transactionally.
+
mv <from> <to>
Move a document and transactionally rewrite internal references, ledger identity, and matching startup preload entries.
init-state
Seed the first ledger on a real Git HEAD; an unborn repository must create its initial commit first.
commit [--verified <path…> | --all]
Validate, commit changed prose, and refresh changed or verified revisions.
fingerprint --update <path…> | --all
Refresh validated revisions in the ledger.
@@ -39,10 +39,24 @@ import DocsLayout from "../../../layouts/DocsLayout.astro";

Explicit operations and hooks

-
prune --report
Read-only convergence signals.
+
prune --report
Read-only convergence signals, including startup preload references that a manual merge or deletion must preserve.
upgrade
Explicit V2 migration diagnostics.
hook session-start | stop | compact
Read-only, fail-open host signals.
+ +

Startup context config

+

An optional llmdoc.config.json at the llmdoc workspace root—the nearest Git root that owns llmdoc/ in a Git repository—can make SessionStart proactively remind the agent to use the llmdoc skill and preload exact documents:

+
{
+  "$schema": "https://llmdoc.tokenroll.ai/schemas/config.schema.json",
+  "schema": "llmdoc.config/v1",
+  "startup": {
+    "remindSkill": true,
+    "preload": ["architecture.mdx", "api-client/contracts.mdx"]
+  }
+}
+

The published schema enables editor validation. Operating guidance is enabled by default; set remindSkill to false to disable it. Cold-start preload order is preserved and has no llmdoc character or token budget. A final completion marker exposes likely host-side truncation; compact re-entry lists document IDs without injecting their bodies again.

+

validate rejects invalid or missing paths and warns while deduplicating normalized aliases. The hook remains fail-open: unreadable JSON/schema uses the default reminder, while preload-only errors preserve a valid remindSkill choice and skip preload.

+

All fixed CLI interface text is English. Chinese queries and repository document content remain supported and are returned unchanged.