diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml index abd332a610..34dcf42c4f 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.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 .agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md -2026-07-27-worktree-local-lefthook.md: 9d5e5583d6d8e3f15433f8b1a6f26f7b2c8cd854 -2026-07-27-worktree-local-lefthook.zh.md: dfca287ae3bbfc1414322d89da9cd93f78230d3c +2026-07-27-worktree-local-lefthook.md: d18f6c1bf8fe240759ad48f67ca6b231000eaf2c +2026-07-27-worktree-local-lefthook.zh.md: 42a1625a3b2ec7b00942dc46b0c9c64058ecd2fc diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md index 9d5e5583d6..d18f6c1bf8 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md @@ -12,13 +12,13 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal ## Decision -Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. Before the format bump, it refuses every existing `extensions.*` key in the common config or a conditional target because format 1 would activate that dormant repository extension. Before first enabling the worktree-config extension, it inspects the `config.worktree` file for the main worktree and every registered linked worktree and refuses dormant settings whose activation would change the current or a sibling worktree. The common repository config and every active or dormant worktree config must be regular files. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually. +Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. -The installer recognizes its hook directory with a private ownership marker and updates it idempotently. The marker records the absolute path last published to worktree config, so moving a checkout permits the installer to replace that exact stale owned value with the moved `$GIT_DIR/dsh-hooks` path and regenerate hooks; any other worktree-scoped value remains user-owned and is refused. Before invoking Lefthook, the marker and every existing generated hook must be an unaliased regular file, preventing a symlink or additional hard link from redirecting an overwrite outside the owned directory. It inspects the effective scope, origin, and value of `core.hooksPath`, then refuses an unowned directory, every command-scoped path, and every non-owned worktree-scoped path, including values loaded through `config.worktree` includes. It follows conditional includes with Git's parser and refuses a command- or worktree-scoped include whose target provides, or cannot safely be shown not to provide, a hook path; an inactive condition therefore cannot later hide a user-owned path behind the installer's direct value. The same risk in an inherited system, global, or common-repository include requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which explicitly opts only the current worktree into Lefthook while other worktrees retain the inherited path. Unrelated conditional includes remain valid. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. This opt-in does not attempt to chain arbitrary hook managers. +Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking. -Enabling worktree config removes the standard redundant `core.bare=false` value from the common config because false remains Git's default; an explicit `core.worktree` or `core.bare=true`, whether direct or loaded through an active common-config include, is refused for manual migration. Before enabling the extension, the installer follows common-config conditional includes and refuses a target that provides, or cannot safely be shown not to provide, either migration-sensitive key; unrelated conditional includes remain valid. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. If that rollback also fails, one diagnostic preserves both failures for manual recovery. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. +Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises the CI no-op, main and linked worktrees, removal independence, repeated and concurrent installs, checkout relocation, marker and hook alias refusal, stale and replaced lock ownership, the Git version boundary, dormant repository-extension and sibling-config refusal, common and worktree config file ownership, migration keys loaded through active and conditional common-config includes, scoped custom-path refusal and opt-in, active and inactive worktree includes, inherited conditional paths, command-environment isolation, legacy common-hook preservation, and successful and failed rollback after installation failure. +If Lefthook fails after changing `core.hooksPath`, the installer restores the previous worktree value; a rollback failure is reported alongside the installation failure. Existing files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten. Focused installer tests pin isolation, migration refusal, ownership and relocation, concurrent installation, custom paths, and rollback. ## Alternatives considered @@ -36,6 +36,6 @@ Enabling worktree config removes the standard redundant `core.bare=false` value Installing or removing one worktree no longer changes another worktree's active hooks, binary path, or generated hook bytes. Concurrent installs are serialized and repeated installation is idempotent, while the jobs and latency boundary owned by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md) stay unchanged. -The repository becomes a Git format-1 repository after the first installation and rejects clients older than Git 2.26. Custom worktree hook managers require an explicit integration choice; inherited hook paths can coexist across other worktrees, but opting the current worktree into Lefthook means those inherited hooks do not run there unless the contributor chains them through `lefthook.yml`. +The repository becomes a Git format-1 repository after the first installation. The installer requires Git 2.26 for `--show-scope`; the worktree-config extension itself predates that command. Custom worktree hook managers require an explicit integration choice; inherited hook paths can coexist across other worktrees, but opting the current worktree into Lefthook means those inherited hooks do not run there unless the contributor chains them through `lefthook.yml`. Legacy common hooks remain on disk for unupgraded worktrees. They can become stale, but removing them automatically would break a registered worktree whose branch has not adopted this installer. diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md index dfca287ae3..42a1625a3b 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md @@ -12,13 +12,13 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 ## 决策 -钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。提升格式版本之前,若共用配置或条件目标中存在任何 `extensions.*` 键,安装程序都会拒绝继续,因为格式 1 会激活这类尚未生效的仓库扩展。首次启用 worktree 配置扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中的 `config.worktree` 文件,并拒绝一经激活就会改变当前或其他 worktree 的尚未生效设置。共用仓库配置以及每个生效或尚未生效的 worktree 配置都必须是常规文件。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 +钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。 -安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。该标记会记录上次写入 worktree 配置的绝对路径,因此检出目录移动后,安装程序可以将这一确切的陈旧自有值替换为移动后的 `$GIT_DIR/dsh-hooks` 路径并重新生成钩子;其他 worktree 作用域值仍视为用户自有并会被拒绝。调用 Lefthook 前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件,以防符号链接或额外硬链接把覆盖操作重定向到自有目录外。安装程序会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 +升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空且尚未生效的 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须手动恢复,不会被自动破坏。 -启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` 或 `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。若回滚也失败,同一条诊断会保留两次失败,供手动恢复。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。 +每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。 -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖 CI 下不执行操作的行为、主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、检出目录移动、拒绝标记和钩子别名、陈旧锁与锁所有权被替换、Git 版本边界、拒绝尚未生效的仓库扩展和其他 worktree 配置、共用及 worktree 配置文件的所有权、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败后成功或失败的回滚。 +若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为。 ## 考虑过的替代方案 @@ -36,6 +36,6 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 安装或移除任一 worktree 不再改变其他 worktree 的生效钩子、二进制文件路径或生成的钩子字节。并发安装会串行执行,重复安装保持幂等;[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)所规定的任务与延迟边界保持不变。 -首次安装后,仓库会采用 Git 格式版本 1,并拒绝版本低于 Git 2.26 的客户端。自定义 worktree 钩子管理器需要明确选择集成方式;继承钩子路径可继续供其他 worktree 使用,但当前 worktree 显式启用 Lefthook 后,其中不会运行这些继承钩子,除非贡献者通过 `lefthook.yml` 将其串联起来。 +首次安装后,仓库会采用 Git 格式版本 1。安装程序需要 Git 2.26 来使用 `--show-scope`;worktree 配置扩展本身的出现早于该命令。自定义 worktree 钩子管理器需要明确选择集成方式;继承钩子路径可继续供其他 worktree 使用,但当前 worktree 显式启用 Lefthook 后,其中不会运行这些继承钩子,除非贡献者通过 `lefthook.yml` 将其串联起来。 旧的共用钩子会为尚未升级的 worktree 保留在磁盘上。它们可能逐渐陈旧,但自动删除这些钩子会破坏已注册但所在分支尚未采用本安装程序的 worktree。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8d7d2a2a1e..74d9dfbea5 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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/development.md -development.md: f1a853acfd1e89104b8013a5b5e9c4032979234f -development.zh.md: 493284e38ad68768b1159778d5dd50aecfe9ccd1 +development.md: 32339fa2af8c1b6005d9e0b8165d57966a4145ca +development.zh.md: c74a81346639c6f95568cbd86b401d134d5eb7fc diff --git a/docs/development.md b/docs/development.md index f1a853acfd..32339fa2af 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,11 @@ If hooks are missing because dependencies were restored from cache or `postinsta node scripts/install-lefthook.mjs ``` -The wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before upgrading a format-0 repository, existing `extensions.*` keys in the common config or a conditional target require manual audit and migration because format 1 activates them. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. A dormant `config.worktree` in any registered worktree also requires inspection and explicit migration or removal before the extension can be enabled without changing that worktree. The common repository config and every active or dormant worktree config must be regular files. The owned hook directory may contain only unaliased regular files; replace a reported symlink, hard link, or non-file entry before retrying. After moving the checkout, rerun the wrapper so its ownership marker can replace the exact stale path it installed and regenerate hooks at the new Git directory. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. If Lefthook installation and automatic hook-path rollback both fail, the diagnostic preserves both failures; inspect the worktree config and remove the new path manually before retrying. +The wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly. + +Before enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files. + +After moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract. Run typecheck once after a fresh clone: diff --git a/docs/development.zh.md b/docs/development.zh.md index 493284e38a..c74a813466 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -27,7 +27,11 @@ pnpm install node scripts/install-lefthook.mjs ``` -包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。升级格式版本为 0 的仓库之前,若共用配置或条件目标中已有 `extensions.*` 键,就需要手动审计和迁移,因为格式 1 会激活这些键。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。共用仓库配置以及每个生效或尚未生效的 worktree 配置都必须是常规文件。自有钩子目录只能包含不带别名的常规文件;请先替换诊断中报告的符号链接、硬链接或非文件条目,再重试。检出目录移动后,请重新运行包装脚本,使其所有权标记可以替换之前写入的确切陈旧路径,并在新的 Git 目录中重新生成钩子。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。若 Lefthook 安装和钩子路径自动回滚都失败,诊断会保留两次失败;请检查 worktree 配置并手动移除新路径,再重试。 +包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。 + +启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。 + +检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。 新克隆后请先运行一次类型检查: diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 04ea62837f..49c246df69 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -2,19 +2,17 @@ import { randomUUID } from 'node:crypto' import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' -import { dirname, isAbsolute, join, resolve } from 'node:path' +import { isAbsolute, join, resolve } from 'node:path' const MINIMUM_GIT = [2, 26, 0] const HOOKS_DIRECTORY = 'dsh-hooks' const OWNERSHIP_MARKER = '.dsh-lefthook-owned' -const LEGACY_OWNERSHIP_MARKER_CONTENT = 'deepseek-harness worktree-local lefthook hooks\n' const OWNERSHIP_MARKER_VERSION = 1 const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks' const INSTALL_LOCK = 'dsh-lefthook-install.lock' const INSTALL_LOCK_TIMEOUT_MS = 30_000 const INSTALL_LOCK_POLL_MS = 50 const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE' -const CONDITIONAL_INCLUDE_PATTERN = '^includeif\\..*\\.path$' const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.' function errorCode(error) { @@ -59,20 +57,15 @@ function stripGitLineTerminator(output) { : withoutLineFeed } -function fileConfigValues(root, configPath, key) { +function directFileConfigValues(root, configPath, key) { return nulValues(git( - ['config', '--file', configPath, '--null', '--get-all', key], + ['config', '--file', configPath, '--no-includes', '--null', '--get-all', key], root, { allowStatuses: [1] }, )) } -function fileConfigEntries(root, configPath, key) { - const fields = nulValues(git( - ['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key], - root, - { allowStatuses: [1] }, - )) +function parseFileConfigEntries(fields, key) { if (fields.length % 2 !== 0) { throw new Error(`git config returned invalid file entries for ${key}`) } @@ -83,15 +76,24 @@ function fileConfigEntries(root, configPath, key) { return entries } +function includedFileConfigEntries(root, configPath, key) { + const fields = nulValues(git( + ['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key], + root, + { allowStatuses: [1] }, + )) + return parseFileConfigEntries(fields, key) +} + function splitConfigNameValue(field, pattern) { const separator = field.indexOf('\n') if (separator < 0) throw new Error(`git config returned an invalid name and value for ${pattern}`) return { name: field.slice(0, separator), value: field.slice(separator + 1) } } -function fileConfigMatchingEntries(root, configPath, pattern) { +function directFileConfigMatchingEntries(root, configPath, pattern) { const fields = nulValues(git( - ['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-regexp', pattern], + ['config', '--file', configPath, '--no-includes', '--null', '--show-origin', '--get-regexp', pattern], root, { allowStatuses: [1] }, )) @@ -105,26 +107,6 @@ function fileConfigMatchingEntries(root, configPath, pattern) { return entries } -function scopedConfigMatchingEntries(root, pattern) { - const fields = nulValues(git( - ['config', '--includes', '--null', '--show-scope', '--show-origin', '--get-regexp', pattern], - root, - { allowStatuses: [1] }, - )) - if (fields.length % 3 !== 0) { - throw new Error(`git config returned invalid scoped entries for ${pattern}`) - } - const entries = [] - for (let index = 0; index < fields.length; index += 3) { - entries.push({ - scope: fields[index], - origin: fields[index + 1], - ...splitConfigNameValue(fields[index + 2], pattern), - }) - } - return entries -} - function effectiveConfigEntry(root, key) { const fields = nulValues(git( ['config', '--null', '--show-scope', '--show-origin', '--get', key], @@ -153,7 +135,7 @@ function assertSingle(values, key) { function worktreeConfigExtensionEnabled(root, commonConfigPath) { const extensionText = assertSingle( - fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), + directFileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), 'extensions.worktreeConfig', ) return extensionText === undefined @@ -162,7 +144,7 @@ function worktreeConfigExtensionEnabled(root, commonConfigPath) { } function hasDirectConfigEntries(root, configPath) { - return git(['config', '--file', configPath, '--null', '--list'], root).stdout !== '' + return git(['config', '--file', configPath, '--no-includes', '--null', '--list'], root).stdout !== '' } function registeredWorktreeConfigPaths(commonDirectory) { @@ -235,74 +217,8 @@ function assertSupportedGit(root) { } } -function conditionalIncludeTarget(entry, root) { - if (isAbsolute(entry.value)) return entry.value - const sourcePath = configOriginPath(entry.origin, root) - if (sourcePath === undefined) return undefined - if (entry.value.startsWith('~/')) { - const home = process.env.HOME - return home === undefined ? undefined : resolve(home, entry.value.slice(2)) - } - if (entry.value.startsWith('~') || entry.value.startsWith('%(')) return undefined - return resolve(dirname(sourcePath), entry.value) -} - -function inspectConditionalConfig(root, configPath, inspect, seen = new Set()) { - const identity = normalizedPath(configPath) - if (seen.has(identity)) return undefined - seen.add(identity) - if (!existsSync(configPath)) { - return { configPath, detail: 'the included config does not exist and cannot be inspected' } - } - try { - const subject = inspect(configPath) - if (subject !== undefined) return { configPath, subject } - for (const entry of fileConfigMatchingEntries(root, configPath, CONDITIONAL_INCLUDE_PATTERN)) { - const target = conditionalIncludeTarget(entry, root) - if (target === undefined) { - return { configPath, detail: `the nested include path ${JSON.stringify(entry.value)} cannot be resolved safely` } - } - const nested = inspectConditionalConfig(root, target, inspect, seen) - if (nested !== undefined) return nested - } - return undefined - } catch (error) { - return { - configPath, - detail: `the included config could not be inspected: ${error instanceof Error ? error.message : String(error)}`, - } - } -} - -function conditionalIncludeRisk(root, entry, inspect) { - const target = conditionalIncludeTarget(entry, root) - if (target === undefined) { - return { detail: `the include path ${JSON.stringify(entry.value)} cannot be resolved safely` } - } - return inspectConditionalConfig(root, target, inspect) -} - -function migrationConfigSubject(root, configPath, rejectRepositoryExtensions) { - if (rejectRepositoryExtensions) { - const extensionEntry = fileConfigMatchingEntries(root, configPath, REPOSITORY_EXTENSION_PATTERN)[0] - if (extensionEntry !== undefined) { - return `${extensionEntry.name} (${configSource(extensionEntry)})` - } - } - const worktreeEntry = fileConfigEntries(root, configPath, 'core.worktree')[0] - if (worktreeEntry !== undefined) return `core.worktree (${configSource(worktreeEntry)})` - const trueBareEntry = fileConfigEntries(root, configPath, 'core.bare') - .find(entry => parseGitBoolean(entry.value, 'core.bare')) - return trueBareEntry === undefined ? undefined : `core.bare=true (${configSource(trueBareEntry)})` -} - -function hooksPathConfigSubject(root, configPath) { - const entry = fileConfigEntries(root, configPath, 'core.hooksPath')[0] - return entry === undefined ? undefined : `core.hooksPath (${configSource(entry)})` -} - function planWorktreeConfigMigration(root, commonConfigPath) { - const versions = fileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion') + const versions = directFileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion') const versionText = assertSingle(versions, 'core.repositoryFormatVersion') const version = Number(versionText) if (!Number.isInteger(version) || version < 0) { @@ -310,7 +226,7 @@ function planWorktreeConfigMigration(root, commonConfigPath) { } if (version === 0) { - const extensionEntry = fileConfigMatchingEntries( + const extensionEntry = directFileConfigMatchingEntries( root, commonConfigPath, REPOSITORY_EXTENSION_PATTERN, @@ -325,42 +241,26 @@ function planWorktreeConfigMigration(root, commonConfigPath) { } const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath) - - if (!extensionEnabled) { - for (const entry of fileConfigMatchingEntries(root, commonConfigPath, CONDITIONAL_INCLUDE_PATTERN)) { - const risk = conditionalIncludeRisk( - root, - entry, - configPath => migrationConfigSubject(root, configPath, version === 0), - ) - if (risk !== undefined) { - const reason = risk.subject ?? risk.detail - throw new Error( - `cannot enable extensions.worktreeConfig while common conditional include ` - + `${entry.origin}: ${entry.name}=${JSON.stringify(entry.value)} may provide migration-sensitive config (${reason}); ` - + 'audit and migrate it, then enable the extension explicitly', - ) - } - } - } - - const worktreeEntry = fileConfigEntries(root, commonConfigPath, 'core.worktree')[0] - if (worktreeEntry !== undefined) { + const worktreeText = assertSingle( + directFileConfigValues(root, commonConfigPath, 'core.worktree'), + 'core.worktree', + ) + if (worktreeText !== undefined) { throw new Error( - `cannot enable extensions.worktreeConfig while core.worktree is in the common config (${configSource(worktreeEntry)}); ` + `cannot enable extensions.worktreeConfig while core.worktree is in the common config ` + + `(file:${commonConfigPath}: ${JSON.stringify(worktreeText)}); ` + 'move it to the main worktree config first', ) } - const bareEntries = fileConfigEntries(root, commonConfigPath, 'core.bare') - const trueBareEntry = bareEntries.find(entry => parseGitBoolean(entry.value, 'core.bare')) - if (trueBareEntry !== undefined) { + const directBareText = assertSingle(directFileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') + const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare') + if (directBare === true) { throw new Error( - `cannot enable extensions.worktreeConfig for a common config with core.bare=true (${configSource(trueBareEntry)})`, + `cannot enable extensions.worktreeConfig for a common config with core.bare=true ` + + `(file:${commonConfigPath}: ${JSON.stringify(directBareText)})`, ) } - const directBareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') - const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare') return { directBare, extensionEnabled, version } } @@ -487,8 +387,7 @@ function ownershipMarkerContent(hooksPath) { })}\n` } -function parseOwnershipMarker(content, hooksPath) { - if (content === LEGACY_OWNERSHIP_MARKER_CONTENT) return { hooksPath, legacy: true } +function parseOwnershipMarker(content) { let parsed try { parsed = JSON.parse(content) @@ -505,7 +404,7 @@ function parseOwnershipMarker(content, hooksPath) { ) { return undefined } - return { hooksPath: parsed.hooksPath, legacy: false } + return { hooksPath: parsed.hooksPath } } function inspectOwnedHooksDirectory(hooksPath) { @@ -520,7 +419,7 @@ function inspectOwnedHooksDirectory(hooksPath) { } const markerStat = lstatSync(markerPath) const marker = markerStat.isFile() && !markerStat.isSymbolicLink() && markerStat.nlink === 1 - ? parseOwnershipMarker(readFileSync(markerPath, 'utf8'), hooksPath) + ? parseOwnershipMarker(readFileSync(markerPath, 'utf8')) : undefined if (marker === undefined) { throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`) @@ -544,7 +443,7 @@ function ensureOwnedHooksDirectory(hooksPath) { mkdirSync(hooksPath, { mode: 0o700 }) const markerPath = join(hooksPath, OWNERSHIP_MARKER) writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { flag: 'wx', mode: 0o600 }) - return { markerPath, hooksPath, legacy: false } + return { markerPath, hooksPath } } function updateOwnershipMarker(markerPath, hooksPath) { @@ -597,52 +496,6 @@ function originIsFile(origin, root, configPath) { return originPath !== undefined && normalizedPath(originPath) === normalizedPath(configPath) } -function conditionalIncludeSource(entry) { - return `${entry.origin}: ${entry.name}=${JSON.stringify(entry.value)}` -} - -function conditionalIncludes(root, worktreeConfigPath) { - const entries = scopedConfigMatchingEntries(root, CONDITIONAL_INCLUDE_PATTERN) - entries.push(...fileConfigMatchingEntries(root, worktreeConfigPath, CONDITIONAL_INCLUDE_PATTERN) - .map(entry => ({ ...entry, scope: 'worktree' }))) - const unique = new Map() - for (const entry of entries) { - unique.set(`${entry.scope}\0${entry.origin}\0${entry.name}\0${entry.value}`, entry) - } - return [...unique.values()] -} - -function assertConditionalHooksPaths(root, worktreeConfigPath) { - for (const entry of conditionalIncludes(root, worktreeConfigPath)) { - const risk = conditionalIncludeRisk( - root, - entry, - configPath => hooksPathConfigSubject(root, configPath), - ) - if (risk === undefined) continue - const reason = risk.subject ?? risk.detail - if (entry.scope === 'command' || entry.scope === 'worktree') { - throw new Error( - `refusing ${entry.scope}-scoped conditional include ${conditionalIncludeSource(entry)}; ` - + `it may provide a user-owned core.hooksPath (${reason}) and cannot be overridden`, - ) - } - if (!['system', 'global', 'local'].includes(entry.scope)) { - throw new Error( - `refusing conditional include from unsupported ${entry.scope} scope ${conditionalIncludeSource(entry)}; ` - + `it may provide core.hooksPath (${reason})`, - ) - } - if (process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1') { - throw new Error( - `refusing to replace core.hooksPath that may be provided by inherited conditional include ` - + `${conditionalIncludeSource(entry)} (${reason}). Inspect that include and rerun with ` - + `${ALLOW_HOOKS_PATH_OVERRIDE}=1 only if it may remain active in other worktrees`, - ) - } - } -} - function refuseInheritedHooksPath(entry) { throw new Error( `refusing to replace user-owned core.hooksPath (${configSource(entry)}). ` @@ -696,7 +549,7 @@ async function main() { commonConfigPath, worktreeConfigPath, ) - const worktreeEntries = fileConfigEntries(root, worktreeConfigPath, 'core.hooksPath') + const worktreeEntries = includedFileConfigEntries(root, worktreeConfigPath, 'core.hooksPath') const includedWorktreeEntry = worktreeEntries.find( entry => !originIsFile(entry.origin, root, worktreeConfigPath), ) @@ -734,8 +587,6 @@ async function main() { } } } - assertConditionalHooksPaths(root, worktreeConfigPath) - const migration = planWorktreeConfigMigration(root, commonConfigPath) ownedHooksDirectory = ensureOwnedHooksDirectory(hooksPath) if ( diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 0ce0bde993..7e30c887ec 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -384,6 +384,19 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) }) + it('refuses direct core.worktree before enabling worktree config', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.worktree', fixture.main]) + + const result = await runInstaller(fixture, fixture.linked) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('core.worktree is in the common config') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + it.skipIf(process.platform === 'win32')('refuses a symlinked common repository config before writing through it', async () => { const fixture = createFixture() const commonConfig = join(commonDirectory(fixture), 'config') @@ -538,9 +551,9 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) }) - it('refuses migration keys loaded through active or conditional common-config includes', async () => { - for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) { - for (const key of ['core.worktree', 'core.bare', 'extensions.dshunknown']) { + for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) { + for (const key of ['core.worktree', 'core.bare', 'extensions.dshunknown']) { + it(`ignores ${key} loaded through ${includeKey}`, async () => { const fixture = createFixture() const commonConfig = join(commonDirectory(fixture), 'config') const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`) @@ -550,25 +563,27 @@ describe('worktree-local Lefthook installer', () => { const result = await runInstaller(fixture, fixture.linked) - expect(result.status).toBe(1) - expect(result.stderr).toContain(key) - expect(result.stderr).toContain(includedConfig) - expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) - expect(existsSync(join(hooksPath(fixture, fixture.linked), 'pre-commit'))).toBe(false) - } + expect(result.status, result.stderr).toBe(0) + expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe( + hooksPath(fixture, fixture.linked), + ) + expect(existsSync(join(hooksPath(fixture, fixture.linked), 'pre-commit'))).toBe(true) + }) } - }) + } - it('allows a conditional common-config include unrelated to migration or hooks', async () => { + it('ignores an inactive global includeIf that provides a hook path for another repository', async () => { const fixture = createFixture() - const commonConfig = join(commonDirectory(fixture), 'config') - const includedConfig = join(fixture.container, 'conditional-identity.gitconfig') - git(fixture, fixture.main, ['config', '--file', includedConfig, 'user.email', 'conditional@example.test']) + const globalConfig = fixture.env.GIT_CONFIG_GLOBAL + if (globalConfig === undefined) throw new Error('fixture global config path is missing') + const includedConfig = join(fixture.container, 'other-repository.gitconfig') + const includedHooks = join(fixture.container, 'other-repository-hooks') + git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) git(fixture, fixture.main, [ 'config', '--file', - commonConfig, - 'includeIf.onbranch:conditional.path', + globalConfig, + `includeIf.gitdir:${join(fixture.container, 'other')}/.path`, includedConfig, ]) @@ -598,24 +613,6 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) }) - it('never overrides a hook path behind a command-scoped conditional include', async () => { - const fixture = createFixture() - const includedConfig = join(fixture.container, 'command-conditional.gitconfig') - const includedHooks = join(fixture.container, 'command-conditional-hooks') - git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) - - const result = await runInstaller(fixture, fixture.main, { - DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: 'includeIf.onbranch:conditional.path', - GIT_CONFIG_VALUE_0: includedConfig, - }) - - expect(result.status).toBe(1) - expect(result.stderr).toContain('command-scoped conditional include') - expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) - }) - it('does not pass unrelated command-scoped Git config to Lefthook', async () => { const fixture = createFixture() @@ -654,86 +651,6 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) }) - it('refuses an inactive conditional worktree include that can later provide a hook path', async () => { - const fixture = createFixture() - const commonConfig = join(commonDirectory(fixture), 'config') - const worktreeConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree') - const includedConfig = join(fixture.container, 'conditional-worktree.gitconfig') - const includedHooks = join(fixture.container, 'conditional-hooks') - const sentinel = join(includedHooks, 'pre-commit') - write(sentinel, '#!/bin/sh\n# conditional-worktree sentinel\n', 0o755) - git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) - git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1']) - git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true']) - git(fixture, fixture.main, [ - 'config', - '--file', - worktreeConfig, - 'includeIf.onbranch:conditional.path', - includedConfig, - ]) - - const result = await runInstaller(fixture, fixture.linked) - - expect(result.status).toBe(1) - expect(result.stderr).toContain('worktree-scoped conditional include') - expect(result.stderr).toContain('includeif.onbranch:conditional.path') - expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1) - expect(existsSync(hooksPath(fixture, fixture.linked))).toBe(false) - - git(fixture, fixture.linked, ['switch', '-c', 'conditional']) - expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(includedHooks) - expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# conditional-worktree sentinel\n') - }) - - it('requires opt-in for inherited conditional includes that can later provide a hook path', async () => { - for (const scope of ['local', 'global']) { - const fixture = createFixture() - const commonConfig = join(commonDirectory(fixture), 'config') - const conditionalOwner = scope === 'local' - ? commonConfig - : fixture.env.GIT_CONFIG_GLOBAL - if (conditionalOwner === undefined) throw new Error('fixture global config path is missing') - const includedConfig = join(fixture.container, `${scope}-conditional.gitconfig`) - const includedHooks = join(fixture.container, `${scope}-conditional-hooks`) - git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) - git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1']) - git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true']) - git(fixture, fixture.main, [ - 'config', - '--file', - conditionalOwner, - 'includeIf.onbranch:conditional.path', - includedConfig, - ]) - - const refused = await runInstaller(fixture, fixture.linked) - - expect(refused.status).toBe(1) - expect(refused.stderr).toContain('inherited conditional include') - expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1') - expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1) - - const optedIn = await runInstaller(fixture, fixture.linked, { - DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', - }) - expect(optedIn.status, optedIn.stderr).toBe(0) - - git(fixture, fixture.linked, ['switch', '-c', 'conditional']) - expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked)) - - const repeatedRefusal = await runInstaller(fixture, fixture.linked) - expect(repeatedRefusal.status).toBe(1) - expect(repeatedRefusal.stderr).toContain('inherited conditional include') - expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked)) - - const repeatedOptIn = await runInstaller(fixture, fixture.linked, { - DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', - }) - expect(repeatedOptIn.status, repeatedOptIn.stderr).toBe(0) - } - }) - it('restores the previous hook lookup when Lefthook installation fails', async () => { const fixture = createFixture() const common = commonDirectory(fixture) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 9903a7ffdc..e95c086c91 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before upgrading a format-0 repository, existing `extensions.*` keys in the common config or a conditional target require manual audit and migration because format 1 activates them. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. A dormant `config.worktree` in any registered worktree also requires inspection and explicit migration or removal before the extension can be enabled without changing that worktree. The common repository config and every active or dormant worktree config must be regular files. The owned hook directory may contain only unaliased regular files; replace a reported symlink, hard link, or non-file entry before retrying. After moving the checkout, rerun the wrapper so its ownership marker can replace the exact stale path it installed and regenerate hooks at the new Git directory. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. If Lefthook installation and automatic hook-path rollback both fail, the diagnostic preserves both failures; inspect the worktree config and remove the new path manually before retrying.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。升级格式版本为 0 的仓库之前,若共用配置或条件目标中已有 `extensions.*` 键,就需要手动审计和迁移,因为格式 1 会激活这些键。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。共用仓库配置以及每个生效或尚未生效的 worktree 配置都必须是常规文件。自有钩子目录只能包含不带别名的常规文件;请先替换诊断中报告的符号链接、硬链接或非文件条目,再重试。检出目录移动后,请重新运行包装脚本,使其所有权标记可以替换之前写入的确切陈旧路径,并在新的 Git 目录中重新生成钩子。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。若 Lefthook 安装和钩子路径自动回滚都失败,诊断会保留两次失败;请检查 worktree 配置并手动移除新路径,再重试。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user",