From 10ef3d4924925a1c77372a6b8707c7c0b4b79f29 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:05:29 +0800 Subject: [PATCH] docs,feat(doc-gates): fix 15 dead anchor fragments; verify-md-links now validates fragments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A corpus sweep under the doc/prose standards found 15 links whose #fragment named no anchor in its target — reworded headings, one relocated contract (tool-fs → the group README's no-timeout rule), and zh sides citing English slugs their Chinese headings never produce. Fixed all 15 (zh sides get the conventional explicit + English fragment), fixed the one generator-owned instance at its source (gen-doc-graphs), and extended verify-md-links to resolve fragments onto Markdown targets — same-file anchors included — against heading slugs and explicit , so the class is gated instead of manually grepped. Remaining probes (narrated history, duplication shingles, comment transcripts, budgets) came back clean; sibling-adapter README symmetry and implemented-note contrasts are deliberate keeps. --- .../2026-06-24-web-capability-seam.i18n.yaml | 2 +- .../2026-06-24-web-capability-seam.zh.md | 2 + ...26-08-09-md-fragment-anchor-gate.i18n.yaml | 6 + .../2026-08-09-md-fragment-anchor-gate.md | 32 ++++ .../2026-08-09-md-fragment-anchor-gate.zh.md | 32 ++++ .agents/skills/dsh-doc-standards/SKILL.md | 2 +- docs/AGENTS.md | 2 +- docs/agent-lifecycle.md | 2 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 2 +- docs/subsystems/llm-streaming.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 2 +- packages/boot/app-boot/README.zh.md | 2 + packages/core/scope/README.i18n.yaml | 4 +- packages/core/scope/README.md | 2 +- packages/core/scope/README.zh.md | 2 +- packages/core/system-prompt/README.i18n.yaml | 2 +- packages/core/system-prompt/README.zh.md | 2 + packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- .../credentials-local/README.i18n.yaml | 2 +- .../credentials-local/README.zh.md | 2 + packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/README.zh.md | 2 +- scripts/gen-doc-graphs.ts | 2 +- scripts/verify-md-links.spec.ts | 83 ++++++++++ scripts/verify-md-links.ts | 148 ++++++++++++++---- 29 files changed, 304 insertions(+), 53 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md create mode 100644 .agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.zh.md create mode 100644 scripts/verify-md-links.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index fc370429b6..331aff7916 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md 2026-06-24-web-capability-seam.md: b705236690859961ed69b307dbb59ebefcbd65ac -2026-06-24-web-capability-seam.zh.md: e3dc836004bf785c6811e4c4014e105266dbaade +2026-06-24-web-capability-seam.zh.md: 15a16b12119f69cec632e219585600d261fda54f diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index e3dc836004..15a16b1211 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -322,6 +322,8 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 **大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated`;`tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。 + + ## 推迟工作 - `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP(防御 DNS rebinding/TOCTOU)、跨重定向的每跳重新验证,以及 IPv6 边缘处理(私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断(OpenCode 做前缀检查后直接 fetch;Claude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的提示词),因此没有可复制的实现,且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前,`web_fetch` 只能在无法触达敏感内部目标的部署中启用。 diff --git a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.i18n.yaml new file mode 100644 index 0000000000..734da74005 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md +2026-08-09-md-fragment-anchor-gate.md: 190ecb0ed07152761377961ca6221c25908082b0 +2026-08-09-md-fragment-anchor-gate.zh.md: ab08609d8b1410ccf0f36a6ced68729ac20c7658 diff --git a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md new file mode 100644 index 0000000000..190ecb0ed0 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md @@ -0,0 +1,32 @@ +# Agent Note: verify-md-links validates fragment anchors, closing the last dead-link class + +Status: implemented + +English | [中文](2026-08-09-md-fragment-anchor-gate.zh.md) + +## Problem + +`verify-md-links` proved a relative link's target file exists but never looked at the `#fragment`, and the documentation standard compensated with a manual rule: grep anchors yourself before renaming a heading. A corpus sweep found 15 links whose fragments named no anchor in their target — three distinct decay modes: a heading reworded after the link was written (`#security-and-authority-are-explicit-non-goals` vs the note's current `Security and authority are non-goals`), a contract relocated to a different owning document (`tool-fs` linking the seam README for the no-timeout rule that now lives in the group README), and zh pair sides linking English slugs their Chinese headings never produce (`#deferred-work` against `## 推迟工作`). None of these fail any gate, and each silently strands the reader at the top of the target page. + +## Decision + +`verify-md-links` now resolves fragments too. For every relative link whose target is a Markdown file — same-file `#anchor` links included, which the old checker skipped entirely — the fragment must name a real anchor in the target: a heading's GitHub slug (repeated headings get the renderer's `-1`, `-2`, … suffixes) or an explicit ``. Fragments onto non-Markdown targets (`file.ts#L10`) carry renderer-owned semantics and stay out of scope, as do external and root-absolute URLs. Anchor sets are collected lazily for any existing target, so links INTO archived notes and vendor documents are validated without making those files sources. + +The slug function is GitHub's algorithm, duplicated from `gen-cordis-catalog`'s region-anchor renderer rather than shared across the script/package boundary; the corpus passing this gate is what keeps the two in sync. Chinese pair sides follow the existing repository convention (`docs/glossary.zh.md`, `docs/cordis-primer.zh.md`): keep the English fragment in the link and place an explicit `` before the Chinese heading, so both language sides expose identical anchors. + +The 15 broken fragments are fixed in the same change: stale slugs retargeted to the current headings, the relocated no-timeout contract now linked at its owning group README, and four zh documents given explicit anchors. `docs/AGENTS.md` and the `dsh-doc-standards` skill no longer prescribe the manual anchor grep for Markdown links; anchors cited from TypeScript strings still need one. + +## Verification + +`scripts/verify-md-links.spec.ts` proves the acceptance paths: slugging (backticks, punctuation, repeat suffixes, explicit ``), a resolving mixed-link document, a dead same-file fragment, a dead cross-file fragment, and a missing target still reported as `target` rather than `anchor`. The gate runs over the full corpus in doc-sync (`verify-md-links`, 1613 files) and passes only after the 15 fixes — the corpus itself is the red-to-green evidence for each decay mode. + +## Alternatives considered + +- **Keep the manual-grep rule.** It demonstrably did not hold: the 15 fragments decayed under a gate-driven maintenance culture, because heading rewrites happen in PRs that never look at inbound links. A mechanical invariant belongs in an executed gate. +- **Point zh links at Chinese-slug anchors.** GitHub slugs CJK headings fine, but the corpus convention is already explicit `` + English fragments (glossary, primer), which also survives renderers that strip non-ASCII; adopting a second convention would split the corpus. +- **Share `githubSlug` with the typert generator.** A one-function import would couple a doc gate to a package build; the algorithm is three lines and the corpus gate itself detects divergence (a generator anchor the gate cannot resolve fails the build the moment a page links it). +- **Validate VitePress slugs as well.** The published site's dead-link check already runs in `website:build`; generated regions carry explicit anchors precisely so the two renderers agree, and hand headings that diverge would fail there. + +## Consequences + +Renaming a heading now breaks the build wherever a Markdown link cites its anchor, instead of stranding readers; authors fix the inbound links in the same change, exactly as they already must for file renames. Same-file anchors are no longer a blind spot, so zh pages must anchor any English fragment they use. The manual pre-rename grep survives only for anchors cited from TypeScript string literals. diff --git a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.zh.md b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.zh.md new file mode 100644 index 0000000000..ab08609d8b --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.zh.md @@ -0,0 +1,32 @@ +# Agent Note(agent 决策记录):verify-md-links 校验 fragment 锚点,关闭最后一类死链 + +Status: implemented + +[English](2026-08-09-md-fragment-anchor-gate.md) | 中文 + +## Problem + +`verify-md-links` 只证明相对链接的目标文件存在,从不检查 `#fragment`,文档标准以一条人工规则补偿:重命名标题前自己 grep 锚点。一次语料扫描发现 15 条链接的 fragment 在目标中没有对应锚点——三种衰变模式:链接写下后标题被改写(`#security-and-authority-are-explicit-non-goals` 对 note 现在的 `Security and authority are non-goals`)、契约搬迁到另一份属主文档(`tool-fs` 链到 seam README,而无超时规则现居 group README)、zh 侧链接其中文标题永远不会生成的英文 slug(`#deferred-work` 对 `## 推迟工作`)。这些都不触发任何 gate,且每条都把读者悄悄丢在目标页顶部。 + +## Decision + +`verify-md-links` 现在也解析 fragment。对每条目标为 Markdown 文件的相对链接——包括旧检查器完全跳过的同文件 `#anchor` 链接——fragment 必须命名目标中的真实锚点:标题的 GitHub slug(重复标题获得渲染器的 `-1`、`-2`……后缀)或显式 ``。指向非 Markdown 目标的 fragment(`file.ts#L10`)语义归渲染器所有,不在范围内;外部与根绝对 URL 同样不检查。锚点集合对任意存在的目标惰性收集,因此链入归档 note 与 vendor 文档的链接照常校验,而这些文件不会因此成为扫描源。 + +slug 函数是 GitHub 的算法,从 `gen-cordis-catalog` 的区块锚点渲染器复制而来,而非跨脚本/包边界共享;语料通过本 gate 即是两者保持同步的机制。中文侧沿用既有语料惯例(`docs/glossary.zh.md`、`docs/cordis-primer.zh.md`):链接保留英文 fragment,在中文标题前放置显式 ``,使两个语言侧暴露相同的锚点。 + +15 条坏 fragment 在同一变更中修复:陈旧 slug 重定向到当前标题,搬迁的无超时契约改链其属主 group README,四份中文文档补上显式锚点。`docs/AGENTS.md` 与 `dsh-doc-standards` skill 不再为 Markdown 链接开人工 grep 锚点的处方;从 TypeScript 字符串引用的锚点仍需人工 grep。 + +## Verification + +`scripts/verify-md-links.spec.ts` 证明各验收路径:slug 化(反引号、标点、重复后缀、显式 ``)、全部可解析的混合链接文档、死的同文件 fragment、死的跨文件 fragment、以及缺失目标仍报 `target` 而非 `anchor`。gate 在 doc-sync 中跑完整语料(`verify-md-links`,1613 个文件),且只有在 15 条修复之后才通过——语料本身就是每种衰变模式由红转绿的证据。 + +## Alternatives considered + +- **保留人工 grep 规则。** 它被证明守不住:15 条 fragment 在 gate 驱动的维护文化下仍然衰变,因为改写标题的 PR 从不会去看入链。可机械检查的不变式应进入被执行的 gate。 +- **让中文链接指向中文 slug 锚点。** GitHub 对 CJK 标题的 slug 没问题,但语料惯例已是显式 `` + 英文 fragment(glossary、primer),且它在剥离非 ASCII 的渲染器下也存活;引入第二种惯例会割裂语料。 +- **与 typert 生成器共享 `githubSlug`。** 为一个函数引入包构建耦合不值得;算法只有三行,语料 gate 本身能探测分歧(生成器产出而 gate 无法解析的锚点,在任何页面链接它的那一刻即失败)。 +- **同时校验 VitePress slug。** 发布站点的死链检查已在 `website:build` 中运行;生成区块正是为两种渲染器一致而携带显式锚点,手写标题若有分歧会在那里失败。 + +## Consequences + +重命名标题现在会在任何 Markdown 链接引用其锚点处使构建失败,而非把读者丢在页顶;作者须在同一变更中修复入链,与文件重命名的既有义务完全一致。同文件锚点不再是盲区,中文页面使用英文 fragment 时必须补锚点。人工的重命名前 grep 只对 TypeScript 字符串字面量中的锚点保留。 diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index 020768c200..49ab835752 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -29,7 +29,7 @@ Then check constraints that make placement expensive or wrong: - Paired docs (`pnpm run verify-translation-pairing --list`) cost a zh counterpart update and a `--write` re-record on every edit — prefer an unpaired home for content that will churn. - Generated catalogs are never hand-edited; if the fact belongs there, change the generator's source. -- Before renaming or moving any doc, grep for inbound references: `verify-md-links` catches Markdown links, `verify-doc-refs` catches `docs/*.md` citations in TypeScript comments, but nothing catches heading-anchor fragments — grep `#the-heading` across the repo yourself (one anchor is hardcoded in `scripts/gen-cordis-catalog.ts`). +- Before renaming or moving any doc, grep for inbound references: `verify-md-links` catches Markdown link targets AND `#fragment` anchors onto Markdown files (heading slugs and explicit ``), and `verify-doc-refs` catches `docs/*.md` citations in TypeScript comments; anchors cited from TypeScript strings (one lives in `scripts/gen-cordis-catalog.ts`) still need a manual grep. - A move is atomic: remove from the old home, add to the new home, and fix every inbound link in the same change. ## Audit the corpus diff --git a/docs/AGENTS.md b/docs/AGENTS.md index a5eca656db..8d019fce08 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -72,6 +72,6 @@ Hunt these in any doc; the [dsh-doc-standards](../.agents/skills/dsh-doc-standar ## Cross-reference with machine-checkable links, never free prose -Link repository references with relative Markdown paths, never bare filenames or Agent Note numbers. `verify-md-links` catches missing targets; the [cross-link Agent Note](../.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md) owns the rationale. +Link repository references with relative Markdown paths, never bare filenames or Agent Note numbers. `verify-md-links` rejects missing targets and dead `#fragment` anchors ([rationale](../.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md)). The gate checks file existence, not `#anchor` validity — verify anchors yourself when linking to one. diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 5bf2c4454c..8690c792be 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -3,7 +3,7 @@ # Agent Turn And Step Lifecycle -This sequence is the visual companion to [architecture.md](architecture.md#loop-lifecycle-session--turn--step). It keeps durable replay facts on `session/event` and live control/status on `agent/*`. +This sequence is the visual companion to [architecture.md](architecture.md#default-loop-lifecycle). It keeps durable replay facts on `session/event` and live control/status on `agent/*`. ```mermaid sequenceDiagram diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index f807656000..0945fee0a3 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: c65416f449d1783f398cdd81b0d6987de83a6141 -llm-streaming.zh.md: ce79ecca53edf7213853df118489817451baa7b0 +llm-streaming.md: 1705c45697df98b51ba9f69c8c91b3a8a18dea76 +llm-streaming.zh.md: 591326582c96023df5a184e19e3e4fc812187fb4 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index c65416f449..1705c45697 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -623,7 +623,7 @@ interface LlmCallConfigAdapterDefaults { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#model-content). ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index ce79ecca53..591326582c 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -631,7 +631,7 @@ interface LlmCallConfigAdapterDefaults { ## seam -`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 +`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#model-content)。 ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 3ae6f5427c..fd61b9bec2 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md README.md: c256b89288e3e384c1dd3e64629a06d7cfef31f6 -README.zh.md: 88d1c4ad0ced2f5a6440a1b64e34e738a842f938 +README.zh.md: 454d504be72c2120ec4aeaa1b22545e7d6ba2fee diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 88d1c4ad0c..454d504be7 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -31,6 +31,8 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 + + ## Profile profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则大声失败。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。 diff --git a/packages/core/scope/README.i18n.yaml b/packages/core/scope/README.i18n.yaml index c9212a3998..5db40dd188 100644 --- a/packages/core/scope/README.i18n.yaml +++ b/packages/core/scope/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/scope/README.md -README.md: 4f32573779a15e8c34b4936bfe75549dfc86d9f6 -README.zh.md: 16ec60a5489f909a46fd5f803dbf08490cd07988 +README.md: ecb442e39e40d5b97a07ccf8a71a190c4009ede8 +README.zh.md: f223d339129bcbef5c5f19ba6d0b453874944c4b diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 4f32573779..ecb442e39e 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -23,7 +23,7 @@ The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime asse ## Design contract -The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals. +The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals) for rationale and security non-goals. Scope-aware services define a concrete `ScopeLayer` that aggregates their heterogeneous tables and domain helpers. `ScopedLayers.effect()` accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. `notify` defaults to `true`; the supplied callback owns whether observer failures throw or are contained. `EntryValues` remains internal, the storage classes are imported from the package root rather than a `/store` subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the [shared scoped-layer storage Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md). diff --git a/packages/core/scope/README.zh.md b/packages/core/scope/README.zh.md index 16ec60a548..f223d33912 100644 --- a/packages/core/scope/README.zh.md +++ b/packages/core/scope/README.zh.md @@ -23,7 +23,7 @@ ## 设计契约 -注册上下文同时决定可见性和所有权,防止注册在一个作用域中可见、却随另一个作用域 dispose(资源释放)。作用域用于路由受信任的同进程插件;它们不是沙箱或权限边界。原理与明确排除的安全目标见 [agent 作用域 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals)。 +注册上下文同时决定可见性和所有权,防止注册在一个作用域中可见、却随另一个作用域 dispose(资源释放)。作用域用于路由受信任的同进程插件;它们不是沙箱或权限边界。原理与明确排除的安全目标见 [agent 作用域 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 感知作用域的服务会定义具体 `ScopeLayer`,聚合各自不同的表与领域辅助函数。`ScopedLayers.effect()` 接受一个返回同步撤销函数的同步动作,在可选通知前安装该撤销函数,并且只有在完整聚合为空时才回收精确作用域层。`notify` 默认为 `true`;由所提供的回调决定观测方失败是向外抛出还是在内部处理。`EntryValues` 保持内部可见;存储类从包根而非 `/store` 子路径导入;共享存储不定义注册表专属的筛选或迭代策略。详见[共享作用域层存储 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)。 diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index c94d1722dc..6d56bec297 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md README.md: e98c45a8829a945500ef5282d84e1904b31c68bf -README.zh.md: 5b9e2feaf82866a52cd8197ff5e800decdf3ee7e +README.zh.md: bf659ea9961a9bb796c1ee1045ac7dad22bb997f diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 5b9e2feaf8..bf659ea996 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -21,6 +21,8 @@ - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。 - `ctx.systemPrompt.assemble(context?: AssembleContext): Promise`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 seam 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。 + + ### 实时事件 `system-prompt/assemble` 是权威来源;替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发契约。 diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index b19e86ce84..19d71e4dff 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: f3f25f908a4ba8016d38f7777fe72691dba4afb1 -README.zh.md: 796ee8e1aa8de88f979d825df5e0b4f39ab7bcf1 +README.md: 97aa97c6ca7675ff5b891e62224a4d1d7780ef42 +README.zh.md: f402722c81e1c7be9f45202a7bc79bfc85a41512 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index f3f25f908a..97aa97c6ca 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -18,7 +18,7 @@ tools: ### Public API - `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber. -- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). +- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 796ee8e1aa..f402722c81 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -18,7 +18,7 @@ tools: ### 公开 API - `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。 -- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals)。 +- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。 - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。 - `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 182ac31809..f0971f5d17 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md README.md: 6051c07628c0214f9396d12554719c5614610826 -README.zh.md: c6f3901af925d4bd31d2dcfbf5f5f65de62dac81 +README.zh.md: 23f48080136b5d212bef34d931a74773c5da4fd3 diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index c6f3901af9..23f4808013 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -49,6 +49,8 @@ OPENAI_API_KEY: sk-… 外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。 + + ## 安全边界 文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的 Harness home 各层](../../boot/app-boot/README.md#profiles))——因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。 diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index e9000e3500..301816a56e 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md -README.md: 28880860dc6c89745eb21fbf732d04f596f9b07f -README.zh.md: 8af0aec51e71681211bbd5a4f582be8b8b0271b8 +README.md: 6f96970d8194c0992f9b955b95aec092185054b2 +README.zh.md: 2de8b02cd47f07af5a04a85694aec05214772a7f diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 28880860dc..6f96970d81 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -150,4 +150,4 @@ Append-only; newly visible content follows the reusable request prefix and does - **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam. - **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`. -- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../fs/README.md#no-io-deadline)). +- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../README.md#no-timeouts-on-file-io)). diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 8af0aec51e..2de8b02cd4 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -150,4 +150,4 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces - **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 bash 的 `glob` 与 `grep`,而不是扩展文件系统 seam。 - **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。 -- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../fs/README.md#no-io-deadline))。 +- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../README.md#no-timeouts-on-file-io))。 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1c4cce0fa3..cc26b6b0af 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -1154,7 +1154,7 @@ function renderLifecycle(): string { const maintenance = 'curated Mermaid sequence; exact event signatures live in the generated Cordis catalog' return [ ...generatedHeader('Agent Turn And Step Lifecycle'), - 'This sequence is the visual companion to [architecture.md](architecture.md#loop-lifecycle-session--turn--step). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.', + 'This sequence is the visual companion to [architecture.md](architecture.md#default-loop-lifecycle). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.', '', '```mermaid', 'sequenceDiagram', diff --git a/scripts/verify-md-links.spec.ts b/scripts/verify-md-links.spec.ts new file mode 100644 index 0000000000..4578583472 --- /dev/null +++ b/scripts/verify-md-links.spec.ts @@ -0,0 +1,83 @@ +/** + * Acceptance-path coverage for fragment validation in `verify-md-links`: a + * `#fragment` onto a Markdown target — same-file anchors included — must name + * a real heading slug or explicit ``, while non-Markdown fragments and + * external targets stay out of scope. + */ + +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { documentAnchors, findViolations, githubSlug } from './verify-md-links.ts' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function layout(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'md-links-')) + roots.push(root) + for (const [rel, content] of Object.entries(files)) { + mkdirSync(join(root, rel, '..'), { recursive: true }) + writeFileSync(join(root, rel), content) + } + return root +} + +function violationsIn(root: string, rel: string): { url: string; reason: string }[] { + const cache = new Map>() + const anchorsOf = (abs: string): Set => { + const hit = cache.get(abs) + if (hit) return hit + const anchors = documentAnchors(readFileSync(abs, 'utf8')) + cache.set(abs, anchors) + return anchors + } + return findViolations(join(root, rel), anchorsOf, root).map(({ url, reason }) => ({ url, reason })) +} + +describe('documentAnchors', () => { + it('slugs headings, suffixes repeats, and reads explicit anchors', () => { + const anchors = documentAnchors([ + '# My Doc', + '## Live `events` — mode!', + '## Repeat', + '## Repeat', + '', + '', + ].join('\n')) + expect(anchors).toEqual(new Set(['my-doc', 'live-events--mode', 'repeat', 'repeat-1', 'hand-anchor'])) + expect(githubSlug('Security and authority are non-goals')).toBe('security-and-authority-are-non-goals') + }) +}) + +describe('findViolations fragments', () => { + it('accepts resolving same-file and cross-file fragments, non-md fragments, and externals', () => { + const root = layout({ + 'a.md': '# A\n\n## Deferred work\n\n[self](#deferred-work) [b](b.md#part-two) [code](x.ts#L10) [ext](https://x.example/#frag)\n', + 'b.md': '# B\n\n## Part two\n', + 'x.ts': 'export {}\n', + }) + expect(violationsIn(root, 'a.md')).toEqual([]) + }) + + it('rejects a same-file fragment that names no heading or ', () => { + const root = layout({ 'a.md': '# A\n\n[gone](#deferred-work)\n' }) + expect(violationsIn(root, 'a.md')).toEqual([{ url: '#deferred-work', reason: 'anchor' }]) + }) + + it('rejects a cross-file fragment missing from the target document', () => { + const root = layout({ + 'a.md': '# A\n\n[stale](b.md#old-heading)\n', + 'b.md': '# B\n\n## New heading\n', + }) + expect(violationsIn(root, 'a.md')).toEqual([{ url: 'b.md#old-heading', reason: 'anchor' }]) + }) + + it('still rejects a missing target file, reported as target not anchor', () => { + const root = layout({ 'a.md': '# A\n\n[ghost](missing.md#anything)\n' }) + expect(violationsIn(root, 'a.md')).toEqual([{ url: 'missing.md#anything', reason: 'target' }]) + }) +}) diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index 969b0f5e44..0286f6d516 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -1,8 +1,10 @@ /** - * Verify that relative Markdown links, images, and definitions resolve. URL, - * root-absolute, and in-page targets are excluded; query strings and fragments - * do not affect resolution against the source file. The checker never rewrites, - * and symlinked instruction files are deduped. + * Verify that relative Markdown links, images, and definitions resolve — the + * target file must exist AND a `#fragment` onto a Markdown target (including + * a same-file `#anchor`) must name a real heading slug or explicit ``. + * URL and root-absolute targets are excluded; query strings do not affect + * resolution against the source file. The checker never rewrites, and + * symlinked instruction files are deduped. */ import { existsSync, readFileSync } from 'node:fs' @@ -28,21 +30,22 @@ const PATTERNS = [ 'skills/**/*.md', ] -/** A broken relative link: a target path that does not resolve to a file. */ +/** A broken relative link: a missing target path or a missing anchor on it. */ interface Violation { file: string /** 1-based line where the link/image/definition node starts. */ line: number url: string + /** What failed: the target file or the fragment onto it. */ + reason: 'target' | 'anchor' } /** * True for targets this gate must NOT check: scheme-qualified URLs (`https:`, - * `mailto:`, …), protocol-relative (`//host`), root-absolute (`/path`), and - * pure in-page anchors (`#frag`). Everything else is a relative path we own. + * `mailto:`, …), protocol-relative (`//host`), and root-absolute (`/path`). + * Pure in-page anchors (`#frag`) ARE checked, against the source file itself. */ -function isExternalOrAnchor(url: string): boolean { - if (url.startsWith('#')) return true +function isExternal(url: string): boolean { if (url.startsWith('//')) return true if (url.startsWith('/')) return true // A scheme like `https:` / `mailto:` — a colon before any slash, dot, or hash. @@ -69,22 +72,105 @@ function pathPart(url: string): string { } } -/** Find every broken relative cross-link in one Markdown file via its AST. */ -function findViolations(absPath: string): Violation[] { - const file = relative(root, absPath) +/** The percent-decoded `#fragment` of a link target, or null when it has none. */ +function fragmentPart(url: string): string | null { + const hash = url.indexOf('#') + if (hash === -1) return null + const raw = url.slice(hash + 1).replace(/\?.*$/, '') + try { + return decodeURIComponent(raw) + } catch { + // Same stance as pathPart: a malformed escape names no anchor anyone + // meant, so the raw text flows into the lookup and is reported missing. + return raw + } +} + +/** + * GitHub's heading-slug algorithm (lowercase; drop everything but letters, + * numbers, spaces, hyphens; spaces become hyphens) — the same rule + * `gen-cordis-catalog`'s region anchors are built from, kept in sync by the + * corpus passing this gate rather than by a shared import across the + * script/package boundary. + * @param heading - the rendered heading text. + * @returns the anchor GitHub assigns the first occurrence of the heading. + */ +export function githubSlug(heading: string): string { + return heading.toLowerCase().replace(/[^\p{L}\p{N} -]/gu, '').replaceAll(' ', '-') +} + +/** + * Every anchor one Markdown document exposes: each heading's GitHub slug + * (repeated headings get the renderer's `-1`, `-2`, … suffixes) plus every + * explicit ``. Lowercased for case-insensitive fragment matching. + * @param source - the document's full Markdown text. + * @returns the set of valid fragments for links into this document. + */ +export function documentAnchors(source: string): Set { + const anchors = new Set() + const seen = new Map() + const tree = parseMarkdown(source) + visitMarkdown(tree, (node: Nodes): void => { + if (node.type === 'heading') { + const text = source.slice(node.position?.start.offset ?? 0, node.position?.end.offset ?? 0) + .replace(/^#{1,6}\s+/, '') + .replace(/[`*_]/g, '') + const base = githubSlug(text) + const bump = seen.get(base) ?? 0 + seen.set(base, bump + 1) + anchors.add(bump === 0 ? base : `${base}-${bump}`) + } + }) + for (const match of source.matchAll(/ Set { + const cache = new Map>() + return (absPath) => { + const hit = cache.get(absPath) + if (hit) return hit + const anchors = documentAnchors(readFileSync(absPath, 'utf8')) + cache.set(absPath, anchors) + return anchors + } +} + +/** + * Find every broken relative cross-link in one Markdown file via its AST: a + * relative target that does not exist, or a fragment onto a Markdown file + * (same-file `#anchor` links included) that names no heading slug or explicit + * `` there. Fragments onto non-Markdown targets (`file.ts#L10`) carry + * renderer-owned semantics and are not judged. + * @param absPath - absolute path of the Markdown source to scan. + * @param anchorsOf - anchor lookup shared across files for cross-link checks. + * @param scanRoot - repository root violations are reported relative to. + * @returns one entry per broken link, in document order. + */ +export function findViolations( + absPath: string, + anchorsOf: (abs: string) => Set, + scanRoot: string = root, +): Violation[] { + const file = relative(scanRoot, absPath) const dir = dirname(absPath) const source = readFileSync(absPath, 'utf8') const tree = parseMarkdown(source) const out: Violation[] = [] const check = (url: string, node: Nodes): void => { - if (isExternalOrAnchor(url)) return + if (isExternal(url)) return const target = pathPart(url) - // A bare `#anchor` reduced to empty path is a same-file anchor — skip. - if (target === '') return - const resolved = resolve(dir, target) + const resolved = target === '' ? absPath : resolve(dir, target) if (!existsSync(resolved)) { - out.push({ file, line: node.position?.start.line ?? 0, url }) + out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'target' }) + return + } + const fragment = fragmentPart(url) + if (fragment === null || !resolved.endsWith('.md')) return + if (!anchorsOf(resolved).has(fragment.toLowerCase())) { + out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'anchor' }) } } @@ -96,18 +182,22 @@ function findViolations(absPath: string): Violation[] { return out } -// Archived notes remain valid link targets, but their historical outbound links are frozen. -const files = uniqueRepoFiles(root, PATTERNS, isArchivedAgentNotePath) -const all = files.flatMap(file => findViolations(file.abs)) -const checked = files.length +// Run only when invoked as a script, not when imported by the spec. +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + // Archived notes remain valid link targets, but their historical outbound links are frozen. + const files = uniqueRepoFiles(root, PATTERNS, isArchivedAgentNotePath) + const anchorsOf = anchorCache() + const all = files.flatMap(file => findViolations(file.abs, anchorsOf)) + const checked = files.length -if (all.length === 0) { - console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links resolve.`) - process.exit(0) -} + if (all.length === 0) { + console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links and fragments resolve.`) + process.exit(0) + } -console.error('verify-md-links: broken relative cross-links found (target does not exist):') -for (const v of all) { - console.error(` ${v.file}:${v.line} ${v.url}`) + console.error('verify-md-links: broken relative cross-links found:') + for (const v of all) { + console.error(` ${v.file}:${v.line} ${v.url} (${v.reason === 'target' ? 'target does not exist' : 'no such anchor in target'})`) + } + process.exit(1) } -process.exit(1)