From 61703d224bf5adfc3357734ccb82689e5c3adc04 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:53:50 +0800 Subject: [PATCH 01/13] feat(dev-infra): add explicit change scope report --- ...-27-explicit-change-scope-report.i18n.yaml | 6 + ...2026-07-27-explicit-change-scope-report.md | 37 +++ ...6-07-27-explicit-change-scope-report.zh.md | 37 +++ .agents/skills/dsh-code-review/SKILL.md | 2 +- .agents/skills/dsh-doc-standards/SKILL.md | 2 +- .agents/skills/dsh-pre-push-checks/SKILL.md | 7 +- package.json | 1 + scripts/change-scope.spec.ts | 224 ++++++++++++++++ scripts/change-scope.ts | 252 ++++++++++++++++++ 9 files changed, 562 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md create mode 100644 .agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md create mode 100644 scripts/change-scope.spec.ts create mode 100644 scripts/change-scope.ts diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml new file mode 100644 index 0000000000..60fa2d83e6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.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-07-27-explicit-change-scope-report.md +2026-07-27-explicit-change-scope-report.md: a58bfad840c979fa2290346308471d2c5aede3bd +2026-07-27-explicit-change-scope-report.zh.md: ce1bd94a30589d5bdfb0056e3f47a99982a65da2 diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md new file mode 100644 index 0000000000..a58bfad840 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md @@ -0,0 +1,37 @@ +# Agent Note: Report an explicit repository change scope + +Status: implemented + +English | [中文](2026-07-27-explicit-change-scope-report.zh.md) + +## Problem + +The [pre-push workflow](../../../skills/dsh-pre-push-checks/SKILL.md) needs the diff against the actual base, but constructing `origin/` fails for a new worktree branch that tracks `origin/master` before its first push and misstates a stacked branch whose PR targets another feature branch. The [code-review](../../../skills/dsh-code-review/SKILL.md) and [documentation-audit](../../../skills/dsh-doc-standards/SKILL.md) workflows need the same current-base judgment. + +An incorrect range undermines evidence selection because it can omit affected paths. A three-dot committed diff also says nothing about Git's separate staged, unstaged, and untracked layers. + +## Decision + +The root `change-scope` command requires `--base `, accepts `--head ` with `HEAD` as the default, and offers a versioned `--json` form. It resolves both inputs to commits with ambiguity detection and requires one merge base before writing output. The report records the repository root, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. + +Committed paths compare the resolved merge base with the resolved head. Dirty path sets always describe the current worktree and index, even when `--head` names another commit. Diff configuration cannot hide submodules or invoke external diff or text-conversion drivers, and rename detection is disabled so both sides of a rename remain visible. + +The command never guesses or fetches a base, queries a hosting provider, or selects tests. Each calling workflow verifies current remote or stack state, supplies the base explicitly, and uses the factual report as input to semantic review or evidence selection. + +Focused temporary-repository tests cover a fresh branch tracking `origin/master` without a same-name remote, its post-push upstream, a stacked non-master base, every dirty layer, invalid, ambiguous, and non-commit refs, deterministic human/JSON parity, and unchanged refs, index, config, and status after reporting. + +## Alternatives considered + +**Keep an ad hoc diff command plus a prose fallback.** This avoids a repository script but leaves normal new-worktree and stacked-base topologies inconsistent across workflows, and it omits dirty layers. + +**Infer the base from the configured upstream.** An upstream may be `origin/master` before the first push, the same feature branch after a push, or a head branch whose PR targets another feature branch. No inference is correct for every topology. + +**Query GitHub for the base inside the command.** This couples a local read-only report to one forge and to network credentials, yet still cannot resolve a branch with no PR. + +**Generate required tests from changed paths.** Paths cannot establish behavior reached through configuration, dynamic loading, subprocesses, workers, built artifacts, or providers. Evidence selection remains judgment under the pre-push workflow. + +## Consequences + +The explicit input makes an incorrect base possible but visible: both input refs and all three resolved commit IDs appear in either output form. Callers pay the small cost of verifying and fetching the live base before running the command. + +The repository owns one Git-topology helper and focused tests. In return, pre-push selection, code review, and documentation audit share a deterministic, read-only account of committed and local changes without importing forge or policy concerns. diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md new file mode 100644 index 0000000000..ce1bd94a30 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 显式报告仓库变更范围 + +Status: implemented + +[English](2026-07-27-explicit-change-scope-report.md) | 中文 + +## 问题 + +[pre-push 工作流](../../../skills/dsh-pre-push-checks/SKILL.md)需要取得相对于实际基准的 diff,但按 `origin/` 构造引用存在两类问题:对于第一次推送前跟踪 `origin/master`、尚无同名远端分支的新 worktree 分支,该引用无法解析;对于 PR(Pull Request)以另一功能分支为基准的堆叠分支,该引用会错误描述基准。[代码评审](../../../skills/dsh-code-review/SKILL.md)与[文档审计](../../../skills/dsh-doc-standards/SKILL.md)工作流同样需要判断当前基准。 + +错误的范围可能遗漏受影响的路径,从而削弱证据选择。三点范围产生的已提交 diff 也完全无法说明 Git 中彼此独立的已暂存、未暂存与未跟踪层。 + +## 决策 + +根目录的 `change-scope` 命令要求提供 `--base `,接受可选的 `--head `(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会输出结果。报告记录仓库根目录、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。 + +已提交路径由解析后的合并基点与头部之间的比较得出。即使 `--head` 指定其他 commit,各类未提交路径集合仍始终描述当前 worktree 与索引。diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。 + +该命令从不猜测或获取基准,不查询代码托管提供方,也不选择测试。调用该命令的每个工作流都会验证当前远端或堆叠状态、显式提供基准,并将这份事实报告作为语义评审或证据选择的输入。 + +聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;堆叠分支以非 master 分支为基准;所有未提交改动层;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。 + +## 考虑过的替代方案 + +**继续使用临时拼装的 diff 命令,辅以文字化回退说明。** 这种方式无需添加仓库脚本,但不同工作流对常见的新 worktree 与堆叠基准拓扑仍会作出不一致处理,而且无法涵盖未提交改动层。 + +**根据配置的上游推断基准。** 第一次推送前,上游可能是 `origin/master`;推送后,它可能是同一功能分支;也可能是一个头部分支,而其 PR 以另一功能分支为基准。没有一种推断能够适用于所有拓扑。 + +**在命令内查询 GitHub 以确定基准。** 这会把本地只读报告绑定到单一代码托管平台与网络凭证,却仍然无法解析尚无 PR 的分支。 + +**根据变更路径生成必需的测试。** 变更路径无法揭示经由配置、动态加载、子进程、worker、构建产物或提供方触达的行为。pre-push 工作流仍须通过判断来选择证据。 + +## 结果 + +显式输入仍可能指定错误的基准,但这种错误是可见的:两种输出格式都会显示输入引用与解析出的三个 commit ID。调用方需要付出少量成本,在运行该命令前验证实时基准并从远端获取它。 + +仓库需要维护一个 Git 拓扑辅助工具及相应的聚焦测试。由此,pre-push 证据选择、代码评审与文档审计可以共享一份确定且只读的已提交及本地变更说明,而不必混入代码托管平台或策略职责。 diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 47890519f2..d9dd441374 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -5,7 +5,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — # Reviewing a DeepSeek-Harness PR -**This skill is guidance, not a complete checklist.** Read the diff against the PR's current base and enough surrounding code to understand the design, then verify suspected defects before reporting them. Re-establish that base after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits. +**This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm run change-scope --base --head ` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits. ## Sources of truth diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index 44de289f7f..3f817fa464 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -26,7 +26,7 @@ Run the placement test in the standard's taxonomy table, then check the constrai ## Auditing the corpus -The audit is a hunt for the standard's slop checklist, cheapest probes first. Establish the PR's current base first; after a retarget or base merge, repeat the audit for prose introduced by the new base rather than relying on the earlier result. +The audit is a hunt for the standard's slop checklist, cheapest probes first. Verify and fetch the PR's live base, then run `pnpm run change-scope --base ` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and repeat the audit for prose introduced by the new base rather than relying on the earlier result. 1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' ':(exclude)vendor/**' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers. 2. Hunt narrated history: `rg -n "no longer|used to|previously|was moved|renamed" --glob '*.md' --glob '*.ts' --glob '!vendor/**'` and keep only contrasts against a live alternative. Keep the vendor exclusion last so include globs cannot override it. diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 31f82eed94..b00b28f6c9 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -16,14 +16,13 @@ git status --short --branch git rev-parse --show-toplevel ``` -2. Inspect the diff against its actual base. +2. Verify the live PR base or stack parent, fetch that ref, and inspect the complete scope against it. ```sh -git diff --stat -git diff --name-only origin/$(git branch --show-current)...HEAD +pnpm run change-scope --base ``` -If the branch has no upstream or that range is not meaningful for the stack, compare with the PR base branch. After merging a changed base, reassess which behavior the combined diff can affect and rerun only checks invalidated by the merge. +The command never guesses or fetches a base. Supply the ref verified from current remote or stack state; use `--head ` when inspecting a commit other than `HEAD`, and `--json` when another tool consumes the report. Its committed paths are relative to the resolved merge base, while staged, unstaged, and untracked paths describe the current worktree. After merging a changed base, rerun the report, reassess which behavior the combined scope can affect, and rerun only checks invalidated by the merge. ## Select relevant evidence diff --git a/package.json b/package.json index d74a780925..acf31f7d23 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "build": "tsc -b && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", + "change-scope": "tsx scripts/change-scope.ts", "typecheck": "tsc -b", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/scripts/change-scope.spec.ts b/scripts/change-scope.spec.ts new file mode 100644 index 0000000000..9361038e43 --- /dev/null +++ b/scripts/change-scope.spec.ts @@ -0,0 +1,224 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { writeChangeScope } from './change-scope.ts' + +interface Report { + formatVersion: number + repository: { root: string; branch: string | null; upstream: string | null } + input: { base: string; head: string } + resolved: { baseSha: string; headSha: string; mergeBaseSha: string } + paths: { committed: string[]; staged: string[]; unstaged: string[]; untracked: string[] } +} + +interface Fixture { + container: string + root: string + origin: string +} + +const fixtureRoots: string[] = [] + +afterEach(() => { + for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function git(cwd: string, args: string[], input?: string): string { + return execFileSync('git', ['-C', cwd, ...args], { + encoding: 'utf8', + env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, + input, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim() +} + +function write(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +function fixture(): Fixture { + const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-')) + fixtureRoots.push(container) + const origin = join(container, 'origin.git') + const root = join(container, 'worktree') + const hooks = join(container, 'hooks') + mkdirSync(hooks) + git(container, ['init', '--bare', '--initial-branch=master', origin]) + git(container, ['init', '--initial-branch=master', root]) + git(root, ['config', 'user.email', 'change-scope@example.com']) + git(root, ['config', 'user.name', 'Change Scope Tests']) + git(root, ['config', 'commit.gpgsign', 'false']) + git(root, ['config', 'core.hooksPath', hooks]) + write(join(root, 'README.md'), '# Fixture\n') + git(root, ['add', 'README.md']) + git(root, ['commit', '-m', 'initial']) + git(root, ['remote', 'add', 'origin', origin]) + git(root, ['push', '--set-upstream', 'origin', 'master']) + return { container, root, origin } +} + +function commit(root: string, path: string, content: string): string { + write(join(root, path), content) + git(root, ['add', '--', path]) + git(root, ['commit', '-m', `add ${path}`]) + return git(root, ['rev-parse', 'HEAD']) +} + +function invoke(root: string, args: string[]): string { + const output: string[] = [] + writeChangeScope(args, root, chunk => output.push(chunk)) + expect(output).toHaveLength(1) + return output[0] as string +} + +function jsonReport(root: string, base: string, head?: string): Report { + const args = ['--base', base, '--json'] + if (head !== undefined) args.push('--head', head) + return JSON.parse(invoke(root, args)) as Report +} + +function formatHumanFromJson(report: Report): string { + const value = (input: string | null): string => JSON.stringify(input) ?? 'null' + const paths = (label: string, entries: string[]): string[] => [ + `${label} (${entries.length}):`, + ...(entries.length === 0 ? [' (none)'] : entries.map(entry => ` - ${value(entry)}`)), + ] + return [ + `Format version: ${report.formatVersion}`, + `Repository root: ${value(report.repository.root)}`, + `Branch: ${value(report.repository.branch)}`, + `Upstream: ${value(report.repository.upstream)}`, + `Base ref: ${value(report.input.base)}`, + `Head ref: ${value(report.input.head)}`, + `Base commit: ${report.resolved.baseSha}`, + `Head commit: ${report.resolved.headSha}`, + `Merge base: ${report.resolved.mergeBaseSha}`, + ...paths('Committed paths', report.paths.committed), + ...paths('Staged paths', report.paths.staged), + ...paths('Unstaged paths', report.paths.unstaged), + ...paths('Untracked paths', report.paths.untracked), + '', + ].join('\n') +} + +function repositoryState(root: string): Record { + const status = git(root, ['status', '--porcelain=v2', '--branch', '-z']) + return { + status, + head: git(root, ['rev-parse', 'HEAD']), + refs: git(root, ['for-each-ref', '--format=%(refname) %(objectname)']), + index: readFileSync(join(root, '.git/index')).toString('base64'), + config: readFileSync(join(root, '.git/config')).toString('base64'), + } +} + +describe('change-scope', () => { + it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => { + const { root } = fixture() + git(root, ['switch', '-c', 'feature']) + git(root, ['branch', '--set-upstream-to=origin/master']) + const headSha = commit(root, 'feature.txt', 'feature\n') + + const fresh = jsonReport(root, 'origin/master') + expect(fresh.repository).toEqual({ root: realpathSync(root), branch: 'feature', upstream: 'origin/master' }) + expect(fresh.resolved).toEqual({ + baseSha: git(root, ['rev-parse', 'origin/master']), + headSha, + mergeBaseSha: git(root, ['rev-parse', 'origin/master']), + }) + expect(fresh.paths).toEqual({ committed: ['feature.txt'], staged: [], unstaged: [], untracked: [] }) + expect(git(root, ['for-each-ref', '--format=%(refname)', 'refs/remotes/origin/feature'])).toBe('') + + git(root, ['push', '--set-upstream', 'origin', 'feature']) + const pushed = jsonReport(root, 'origin/master') + expect(pushed.repository.upstream).toBe('origin/feature') + expect(pushed.paths.committed).toEqual(['feature.txt']) + }) + + it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => { + const { root } = fixture() + git(root, ['switch', '-c', 'foundation']) + const baseSha = commit(root, 'foundation.txt', 'foundation\n') + git(root, ['switch', '-c', 'topic']) + const headSha = commit(root, 'topic.txt', 'topic\n') + commit(root, 'later.txt', 'later\n') + write(join(root, 'current-worktree.txt'), 'current worktree\n') + + const report = jsonReport(root, 'foundation', headSha) + expect(report.input).toEqual({ base: 'foundation', head: headSha }) + expect(report.resolved).toEqual({ baseSha, headSha, mergeBaseSha: baseSha }) + expect(report.paths.committed).toEqual(['topic.txt']) + expect(report.paths.untracked).toEqual(['current-worktree.txt']) + }) + + it('keeps committed, staged, unstaged, and untracked paths independent and does not mutate state', () => { + const { root } = fixture() + commit(root, 'unstaged.txt', 'before\n') + const baseSha = git(root, ['rev-parse', 'HEAD']) + commit(root, 'committed.txt', 'committed\n') + write(join(root, 'staged.txt'), 'staged\n') + write(join(root, 'mixed.txt'), 'staged part\n') + git(root, ['add', 'staged.txt', 'mixed.txt']) + write(join(root, 'mixed.txt'), 'staged part\nunstaged part\n') + write(join(root, 'unstaged.txt'), 'unstaged\n') + write(join(root, 'untracked.txt'), 'untracked\n') + const before = repositoryState(root) + + const report = jsonReport(root, baseSha) + + expect(report.paths).toEqual({ + committed: ['committed.txt'], + staged: ['mixed.txt', 'staged.txt'], + unstaged: ['mixed.txt', 'unstaged.txt'], + untracked: ['untracked.txt'], + }) + expect(repositoryState(root)).toEqual(before) + }) + + it('rejects missing, ambiguous, and non-commit refs before writing output', () => { + const { root } = fixture() + git(root, ['branch', 'collision']) + git(root, ['tag', 'collision']) + write(join(root, 'blob.txt'), 'blob\n') + const blobSha = git(root, ['hash-object', '-w', 'blob.txt']) + git(root, ['tag', 'blob-ref', blobSha]) + + for (const { args, message } of [ + { args: ['--base', 'missing'], message: /base ref .* does not resolve to a commit/u }, + { args: ['--base', 'collision'], message: /base ref .* is ambiguous/u }, + { args: ['--base', 'blob-ref'], message: /base ref .* does not resolve to a commit/u }, + { args: ['--base', 'HEAD', '--head', 'missing'], message: /head ref .* does not resolve to a commit/u }, + ]) { + const output: string[] = [] + expect(() => { + writeChangeScope(args, root, (chunk) => { + output.push(chunk) + }) + }).toThrow(message) + expect(output).toEqual([]) + } + }) + + it('renders deterministic human and JSON forms with the same facts', () => { + const { root } = fixture() + git(root, ['switch', '-c', 'format']) + commit(root, 'zeta.txt', 'zeta\n') + commit(root, 'alpha.txt', 'alpha\n') + + const json = invoke(root, ['--base', 'origin/master', '--json']) + const repeatedJson = invoke(root, ['--base', 'origin/master', '--json']) + const human = invoke(root, ['--base', 'origin/master']) + const repeatedHuman = invoke(root, ['--base', 'origin/master']) + const report = JSON.parse(json) as Report + + expect(json).toBe(repeatedJson) + expect(report.formatVersion).toBe(1) + expect(report.paths.committed).toEqual(['alpha.txt', 'zeta.txt']) + expect(human).toBe(repeatedHuman) + expect(human).toBe(formatHumanFromJson(report)) + }) +}) diff --git a/scripts/change-scope.ts b/scripts/change-scope.ts new file mode 100644 index 0000000000..340f00ef69 --- /dev/null +++ b/scripts/change-scope.ts @@ -0,0 +1,252 @@ +/** Report the explicit committed and worktree scope of a repository change. */ + +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' +import { parseArgs } from 'node:util' + +const FORMAT_VERSION = 1 +const MAX_GIT_OUTPUT = 64 * 1024 * 1024 + +interface ChangeScopeReport { + formatVersion: typeof FORMAT_VERSION + repository: { + root: string + branch: string | null + upstream: string | null + } + input: { + base: string + head: string + } + resolved: { + baseSha: string + headSha: string + mergeBaseSha: string + } + paths: { + committed: string[] + staged: string[] + unstaged: string[] + untracked: string[] + } +} + +interface GitCommandResult { + status: number | null + stdout: string + stderr: string + error: Error | undefined +} + +interface ChangeScopeOptions { + base: string + head: string + json: boolean +} + +function executeGit(cwd: string, args: string[]): GitCommandResult { + const result = spawnSync('git', ['-C', cwd, ...args], { + encoding: 'utf8', + env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, + maxBuffer: MAX_GIT_OUTPUT, + }) + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + error: result.error, + } +} + +function failureDetail(result: GitCommandResult): string { + return result.error?.message ?? (result.stderr.trim() || `Git exited with status ${String(result.status)}`) +} + +function requireGit(cwd: string, args: string[], context: string): string { + const result = executeGit(cwd, args) + if (result.status !== 0) throw new Error(`${context}: ${failureDetail(result)}`) + return result.stdout +} + +function parseOptions(args: string[]): ChangeScopeOptions { + const { values } = parseArgs({ + args, + allowPositionals: false, + options: { + base: { type: 'string' }, + head: { type: 'string', default: 'HEAD' }, + json: { type: 'boolean', default: false }, + }, + strict: true, + }) + if (values.base === undefined) throw new Error('missing required --base ') + return { base: values.base, head: values.head, json: values.json } +} + +function resolveCommit(root: string, label: 'base' | 'head', ref: string): string { + const result = executeGit(root, [ + '-c', + 'core.warnAmbiguousRefs=true', + 'rev-parse', + '--verify', + '--end-of-options', + `${ref}^{commit}`, + ]) + if (/\bambiguous\b/iu.test(result.stderr)) { + throw new Error(`${label} ref ${JSON.stringify(ref)} is ambiguous; use a fully qualified ref or commit ID`) + } + if (result.status !== 0) { + throw new Error(`${label} ref ${JSON.stringify(ref)} does not resolve to a commit: ${failureDetail(result)}`) + } + const commits = result.stdout.trim().split(/\r?\n/u).filter(Boolean) + if (commits.length !== 1) { + throw new Error(`${label} ref ${JSON.stringify(ref)} did not resolve to exactly one commit`) + } + return commits[0] as string +} + +function resolveMergeBase(root: string, baseSha: string, headSha: string): string { + const result = executeGit(root, ['merge-base', '--all', baseSha, headSha]) + if (result.status !== 0) { + throw new Error(`base and head do not have a merge base: ${failureDetail(result)}`) + } + const mergeBases = result.stdout.trim().split(/\r?\n/u).filter(Boolean) + if (mergeBases.length !== 1) { + throw new Error(`base and head do not have a unique merge base; found ${mergeBases.length}`) + } + return mergeBases[0] as string +} + +function currentBranch(root: string): string | null { + const result = executeGit(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']) + if (result.status === 1) return null + if (result.status !== 0) throw new Error(`cannot inspect the current branch: ${failureDetail(result)}`) + return result.stdout.trim() +} + +function configuredUpstream(root: string, branch: string | null): string | null { + if (branch === null) return null + const output = requireGit( + root, + ['for-each-ref', '--count=1', '--format=%(upstream:short)', `refs/heads/${branch}`], + 'cannot inspect the configured upstream', + ).trim() + return output === '' ? null : output +} + +function comparePaths(left: string, right: string): number { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + +function parsePathSet(output: string): string[] { + return [...new Set(output.split('\0').filter(Boolean))].sort(comparePaths) +} + +function diffPaths(root: string, args: string[], context: string): string[] { + return parsePathSet(requireGit(root, [ + 'diff', + '--no-ext-diff', + '--no-textconv', + '--no-renames', + '--ignore-submodules=none', + '--name-only', + '-z', + ...args, + '--', + ], context)) +} + +function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeReport { + const root = requireGit(cwd, ['rev-parse', '--show-toplevel'], 'cannot locate a Git worktree').trim() + const baseSha = resolveCommit(root, 'base', options.base) + const headSha = resolveCommit(root, 'head', options.head) + const mergeBaseSha = resolveMergeBase(root, baseSha, headSha) + const branch = currentBranch(root) + return { + formatVersion: FORMAT_VERSION, + repository: { + root, + branch, + upstream: configuredUpstream(root, branch), + }, + input: { + base: options.base, + head: options.head, + }, + resolved: { + baseSha, + headSha, + mergeBaseSha, + }, + paths: { + committed: diffPaths(root, [mergeBaseSha, headSha], 'cannot inspect committed paths'), + staged: diffPaths(root, ['--cached'], 'cannot inspect staged paths'), + unstaged: diffPaths(root, [], 'cannot inspect unstaged paths'), + untracked: parsePathSet(requireGit( + root, + ['ls-files', '--others', '--exclude-standard', '-z', '--'], + 'cannot inspect untracked paths', + )), + }, + } +} + +function formatValue(value: string | null): string { + return JSON.stringify(value) +} + +function formatPaths(label: string, paths: string[]): string[] { + return [ + `${label} (${paths.length}):`, + ...(paths.length === 0 ? [' (none)'] : paths.map(path => ` - ${formatValue(path)}`)), + ] +} + +function formatHuman(report: ChangeScopeReport): string { + return [ + `Format version: ${report.formatVersion}`, + `Repository root: ${formatValue(report.repository.root)}`, + `Branch: ${formatValue(report.repository.branch)}`, + `Upstream: ${formatValue(report.repository.upstream)}`, + `Base ref: ${formatValue(report.input.base)}`, + `Head ref: ${formatValue(report.input.head)}`, + `Base commit: ${report.resolved.baseSha}`, + `Head commit: ${report.resolved.headSha}`, + `Merge base: ${report.resolved.mergeBaseSha}`, + ...formatPaths('Committed paths', report.paths.committed), + ...formatPaths('Staged paths', report.paths.staged), + ...formatPaths('Unstaged paths', report.paths.unstaged), + ...formatPaths('Untracked paths', report.paths.untracked), + ].join('\n') +} + +/** + * Validate arguments, collect one complete report, then invoke the writer once. + * @param args - Command-line arguments after the script path. + * @param cwd - Directory whose containing Git worktree is inspected. + * @param write - Destination called once only after every Git query succeeds. + * @returns Nothing. + */ +export function writeChangeScope( + args: string[], + cwd: string, + write: (output: string) => void, +): void { + const options = parseOptions(args) + const report = collectReport(options, cwd) + write(`${options.json ? JSON.stringify(report, null, 2) : formatHuman(report)}\n`) +} + +const entryPath = process.argv[1] +if (entryPath !== undefined && resolve(entryPath) === fileURLToPath(import.meta.url)) { + try { + writeChangeScope(process.argv.slice(2), process.cwd(), output => process.stdout.write(output)) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`change-scope: ${message}\n`) + process.exitCode = 1 + } +} From 3a69e7a2d874185bdd4530fd95e52662543ae9ed Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:02:29 +0800 Subject: [PATCH 02/13] fix(dev-infra): isolate Lefthook per worktree --- .../2026-07-27-worktree-local-lefthook.md | 39 +++ docs/development.md | 8 +- lefthook.yml | 2 +- scripts/install-lefthook.mjs | 293 ++++++++++++++++- scripts/install-lefthook.spec.ts | 305 ++++++++++++++++++ 5 files changed, 627 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md create mode 100644 scripts/install-lefthook.spec.ts 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 new file mode 100644 index 0000000000..04db203037 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md @@ -0,0 +1,39 @@ +# Agent Note: Make Lefthook installation worktree-local + +Status: implemented + +English | [中文](2026-07-27-worktree-local-lefthook.zh.md) + +## Problem + +Every `pnpm install` runs the root [`postinstall`](../../../../package.json), whose [`install-lefthook.mjs`](../../../../scripts/install-lefthook.mjs) invokes `lefthook install --force`. Linked Git worktrees otherwise share the common repository's default hooks directory, so an install in any worktree can rewrite hooks used by every other worktree. + +Lefthook-generated hooks prefer an absolute binary path captured from the installing worktree before trying their current-worktree fallback. Shared hooks can therefore run another worktree's pinned binary until that worktree disappears, while concurrent installs write the same files. + +## Decision + +Hook installation is worktree-scoped. The installer requires Git 2.20 or newer, 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 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. + +The installer recognizes its hook directory with a private ownership marker and updates it idempotently. It refuses an unowned directory or a worktree-specific custom `core.hooksPath`. An inherited global or common-repository hook path is preserved by default; `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` explicitly lets only the current worktree override it, so worktrees without that override continue using the inherited path. This opt-in does not attempt to chain arbitrary hook managers. + +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` is refused for manual migration. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. + +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, the Git version boundary, custom-path refusal and opt-in, legacy common-hook preservation, and failed-install rollback. + +## Alternatives considered + +**Keep the shared generated hooks and rely on their current-worktree fallback.** The captured absolute path wins while its worktree exists, so the fallback does not provide version or lifecycle isolation. + +**Point every worktree at one checked-in `.githooks` directory.** A relative tracked directory removes generated absolute paths, but changing the shared `core.hooksPath` can disable hooks in older worktrees whose branches do not contain that directory and still couples every worktree to one shared configuration value. + +**Build a general hook-manager chaining layer.** Ordering, argument forwarding, failure semantics, and upgrades become repository-owned behavior unrelated to Lefthook isolation. The installer instead refuses worktree-specific custom paths and makes the narrower inherited-path override explicit. + +**Stop installing hooks automatically.** Manual setup avoids shared writes but makes the repository's cheap commit and push checks optional by accident, especially in short-lived agent worktrees. + +## Consequences + +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.20. 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/docs/development.md b/docs/development.md index fd7f39ae7b..530a5ea886 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,7 +8,7 @@ This onboarding guide helps project contributors get started with the local envi - 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). - Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack. -- Git. +- Git 2.20 or newer; hook setup enables Git's worktree-specific configuration extension. - Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests. ## First-time setup @@ -19,14 +19,16 @@ Install dependencies from the repo root: pnpm install ``` -The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`; the wrapper script uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands. +The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. The wrapper 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). If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually: ```sh -pnpm exec lefthook install --force +node scripts/install-lefthook.mjs ``` +The wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited global or 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`; a worktree-specific custom path is never overwritten and must be integrated or removed explicitly. + Run typecheck once after a fresh clone: ```sh diff --git a/lefthook.yml b/lefthook.yml index cf2e6bb11d..4a64ea804d 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,6 +1,6 @@ # Git hooks (lefthook). Keep these local checkpoints fast; CI owns the full # repository-wide gate matrix. -# Install: `pnpm exec lefthook install` (runs automatically via postinstall). +# Install: `node scripts/install-lefthook.mjs` (runs automatically via postinstall). pre-commit: jobs: diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 9256a9b462..3dc19c9dca 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -1,21 +1,282 @@ #!/usr/bin/env node -import { existsSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' -import { join } from 'node:path' +import { isAbsolute, join, resolve } from 'node:path' -const git = spawnSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' }) -if (git.status !== 0) process.exit(0) +const MINIMUM_GIT = [2, 20, 0] +const HOOKS_DIRECTORY = 'dsh-hooks' +const OWNERSHIP_MARKER = '.dsh-lefthook-owned' +const OWNERSHIP_MARKER_CONTENT = 'deepseek-harness worktree-local lefthook hooks\n' +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 isWindows = process.platform === 'win32' -const lefthook = join(process.cwd(), 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook') -if (!existsSync(lefthook)) process.exit(0) +function errorCode(error) { + return typeof error === 'object' && error !== null && 'code' in error + ? error.code + : undefined +} -// On Windows the bin shim is a `.cmd` file, and recent Node (CVE-2024-27980) -// refuses to launch `.cmd`/`.bat` via spawn without `shell: true` — it returns -// `EINVAL` with a null status, which would otherwise fail postinstall. Quote -// the path because a shell re-parses the command line and the path may contain -// spaces. POSIX needs no shell: the extensionless shim is directly executable. -const result = isWindows - ? spawnSync(`"${lefthook}"`, ['install', '--force'], { stdio: 'inherit', shell: true }) - : spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' }) -process.exit(result.status ?? 1) +function commandFailure(command, args, result) { + const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '' + const detail = result.error?.message ?? (stderr || `exit status ${String(result.status)}`) + return new Error(`${command} ${args.join(' ')} failed: ${detail}`) +} + +function capture(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: 'utf8', + env: process.env, + }) + if (result.status !== 0 && !options.allowStatuses?.includes(result.status)) { + throw commandFailure(command, args, result) + } + return result +} + +function git(args, root, options = {}) { + return capture('git', args, { ...options, cwd: root }) +} + +function nulValues(result) { + if (result.status !== 0) return [] + if (result.stdout === '') return [''] + const output = result.stdout.endsWith('\0') ? result.stdout.slice(0, -1) : result.stdout + return output.split('\0') +} + +function fileConfigValues(root, configPath, key) { + return nulValues(git( + ['config', '--file', configPath, '--null', '--get-all', key], + root, + { allowStatuses: [1] }, + )) +} + +function effectiveConfigValue(root, key) { + const values = nulValues(git( + ['config', '--null', '--get', key], + root, + { allowStatuses: [1] }, + )) + if (values.length > 1) throw new Error(`git config returned multiple effective values for ${key}`) + return values[0] +} + +function parseGitBoolean(value, key) { + const normalized = value.toLowerCase() + if (normalized === '' || normalized === 'true' || normalized === 'yes' || normalized === 'on' || normalized === '1') return true + if (normalized === 'false' || normalized === 'no' || normalized === 'off' || normalized === '0') return false + throw new Error(`invalid Boolean value for ${key}: ${JSON.stringify(value)}`) +} + +function assertSingle(values, key) { + if (values.length > 1) throw new Error(`multiple ${key} values are not supported`) + return values[0] +} + +function assertSupportedGit(root) { + const version = git(['--version'], root).stdout.trim() + const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(version) + if (match === null) throw new Error(`cannot determine Git version from ${JSON.stringify(version)}`) + const actual = [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)] + for (let index = 0; index < MINIMUM_GIT.length; index += 1) { + if (actual[index] > MINIMUM_GIT[index]) return + if (actual[index] < MINIMUM_GIT[index]) { + throw new Error(`Git 2.20 or newer is required for worktree-local hooks; found ${version}`) + } + } +} + +function ensureWorktreeConfig(root, commonConfigPath) { + const versions = fileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion') + const versionText = assertSingle(versions, 'core.repositoryFormatVersion') + const version = Number(versionText) + if (!Number.isInteger(version) || version < 0) { + throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`) + } + + const worktrees = fileConfigValues(root, commonConfigPath, 'core.worktree') + if (worktrees.length > 0) { + throw new Error('cannot enable extensions.worktreeConfig while core.worktree is in the common config; move it to the main worktree config first') + } + + const bareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') + const bare = bareText === undefined ? undefined : parseGitBoolean(bareText, 'core.bare') + if (bare === true) { + throw new Error('cannot enable extensions.worktreeConfig for a common config with core.bare=true') + } + + const extensionText = assertSingle( + fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), + 'extensions.worktreeConfig', + ) + const extensionEnabled = extensionText === undefined + ? false + : parseGitBoolean(extensionText, 'extensions.worktreeConfig') + + if (version === 0) { + git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root) + } + if (!extensionEnabled) { + git(['config', '--file', commonConfigPath, 'extensions.worktreeConfig', 'true'], root) + } + if (bare === false) { + git(['config', '--file', commonConfigPath, '--unset-all', 'core.bare'], root) + } +} + +function lockOwnerIsAlive(lockPath) { + let owner + try { + owner = Number(readFileSync(lockPath, 'utf8').trim()) + } catch (error) { + if (errorCode(error) === 'ENOENT') return false + throw error + } + if (!Number.isSafeInteger(owner) || owner <= 0) return true + try { + process.kill(owner, 0) + return true + } catch (error) { + if (errorCode(error) === 'ESRCH') return false + if (errorCode(error) === 'EPERM') return true + throw error + } +} + +function removeStaleLock(lockPath) { + try { + unlinkSync(lockPath) + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + // Another waiting installer removed the same stale lock first. + } +} + +async function acquireInstallLock(commonDirectory) { + const lockPath = join(commonDirectory, INSTALL_LOCK) + const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS + while (true) { + try { + writeFileSync(lockPath, `${String(process.pid)}\n`, { flag: 'wx', mode: 0o600 }) + return () => removeStaleLock(lockPath) + } catch (error) { + if (errorCode(error) !== 'EEXIST') throw error + if (!lockOwnerIsAlive(lockPath)) { + removeStaleLock(lockPath) + continue + } + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`) + } + await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS)) + } + } +} + +function ensureOwnedHooksDirectory(hooksPath) { + const markerPath = join(hooksPath, OWNERSHIP_MARKER) + if (!existsSync(hooksPath)) { + mkdirSync(hooksPath, { mode: 0o700 }) + writeFileSync(markerPath, OWNERSHIP_MARKER_CONTENT, { flag: 'wx', mode: 0o600 }) + return + } + const hooksStat = lstatSync(hooksPath) + if (!hooksStat.isDirectory() || hooksStat.isSymbolicLink()) { + throw new Error(`refusing to use non-directory or symlinked hooks path ${hooksPath}`) + } + if (!existsSync(markerPath)) { + throw new Error(`refusing to overwrite unowned hooks directory ${hooksPath}`) + } + const markerStat = lstatSync(markerPath) + if (!markerStat.isFile() || markerStat.isSymbolicLink() || readFileSync(markerPath, 'utf8') !== OWNERSHIP_MARKER_CONTENT) { + throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`) + } +} + +function runLefthook(root, lefthook) { + const args = ['install', '--force'] + // Node refuses to spawn Windows `.cmd` shims directly; the quoted path is + // re-parsed by cmd.exe, while POSIX can execute its extensionless shim. + const result = process.platform === 'win32' + ? spawnSync(`"${lefthook}"`, args, { cwd: root, stdio: 'inherit', shell: true }) + : spawnSync(lefthook, args, { cwd: root, stdio: 'inherit' }) + if (result.status !== 0) throw commandFailure(lefthook, args, result) +} + +function refuseCustomHooksPath(root, hooksPath) { + const origin = git( + ['config', '--show-origin', '--get', 'core.hooksPath'], + root, + { allowStatuses: [1] }, + ).stdout.trim() + const source = origin === '' ? hooksPath : origin + throw new Error( + `refusing to replace user-owned core.hooksPath (${source}). ` + + `Chain those hooks through lefthook.yml, or, if this inherited path may remain active only in other worktrees, ` + + `rerun with ${ALLOW_HOOKS_PATH_OVERRIDE}=1`, + ) +} + +async function main() { + const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) + if (probe.status !== 0) return + const root = probe.stdout.trim() + const isWindows = process.platform === 'win32' + const lefthook = join(root, 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook') + if (!existsSync(lefthook)) return + + assertSupportedGit(root) + const gitDirectory = git(['rev-parse', '--absolute-git-dir'], root).stdout.trim() + const commonOutput = git(['rev-parse', '--git-common-dir'], root).stdout.trim() + const commonDirectory = isAbsolute(commonOutput) ? commonOutput : resolve(root, commonOutput) + const commonConfigPath = join(commonDirectory, 'config') + const worktreeConfigPath = join(gitDirectory, 'config.worktree') + const hooksPath = join(gitDirectory, HOOKS_DIRECTORY) + const releaseLock = await acquireInstallLock(commonDirectory) + + try { + const worktreePath = assertSingle( + fileConfigValues(root, worktreeConfigPath, 'core.hooksPath'), + 'worktree core.hooksPath', + ) + if (worktreePath !== undefined && worktreePath !== hooksPath) refuseCustomHooksPath(root, worktreePath) + + const effectivePath = effectiveConfigValue(root, 'core.hooksPath') + const effectivePathIsOwned = effectivePath === hooksPath && worktreePath === hooksPath + if ( + effectivePath !== undefined + && !effectivePathIsOwned + && process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1' + ) { + refuseCustomHooksPath(root, effectivePath) + } + + ensureOwnedHooksDirectory(hooksPath) + ensureWorktreeConfig(root, commonConfigPath) + + let pathChanged = false + try { + git(['config', '--worktree', 'core.hooksPath', hooksPath], root) + pathChanged = worktreePath === undefined + runLefthook(root, lefthook) + } catch (error) { + if (pathChanged) { + git(['config', '--worktree', '--unset-all', 'core.hooksPath'], root) + } + throw error + } + } finally { + releaseLock() + } +} + +try { + await main() +} catch (error) { + console.error(`[install-lefthook] ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 +} diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts new file mode 100644 index 0000000000..81f1be2897 --- /dev/null +++ b/scripts/install-lefthook.spec.ts @@ -0,0 +1,305 @@ +import { spawn, spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url)) +const fixtures: string[] = [] + +interface Fixture { + container: string + env: NodeJS.ProcessEnv + linked: string + main: string +} + +interface CommandResult { + status: number | null + stderr: string + stdout: string +} + +afterEach(() => { + for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true }) +}) + +function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult { + const result = spawnSync(command, args, { cwd, encoding: 'utf8', env }) + return { status: result.status, stderr: result.stderr, stdout: result.stdout } +} + +function gitResult(fixture: Fixture, cwd: string, args: string[]): CommandResult { + return commandResult('git', args, cwd, fixture.env) +} + +function git(fixture: Fixture, cwd: string, args: string[]): string { + const result = gitResult(fixture, cwd, args) + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`) + } + return result.stdout.trim() +} + +function write(path: string, content: string, mode?: number): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content, mode === undefined ? undefined : { mode }) +} + +function fakeLefthookSource(): string { + return `#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { join } from 'node:path' + +if (process.argv.slice(2).join(' ') !== 'install --force') process.exit(64) +const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim() +const hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim() +mkdirSync(hooksPath, { recursive: true }) +const running = join(hooksPath, '.fake-lefthook-running') +try { + writeFileSync(running, String(process.pid), { flag: 'wx' }) +} catch { + process.exit(91) +} +const delay = Number(process.env.DSH_TEST_LEFTHOOK_DELAY_MS ?? 0) +if (delay > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay) +const shouldFail = process.env.DSH_TEST_LEFTHOOK_FAIL === '1' +if (!shouldFail) { + const binary = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook') + const config = readFileSync(join(root, 'lefthook.yml'), 'utf8').trim() + const hook = \`#!/bin/sh\\n# root=\${root}\\n# binary=\${binary}\\n# config=\${config}\\nexit 0\\n\` + for (const name of ['pre-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 }) +} +if (existsSync(running)) unlinkSync(running) +if (shouldFail) process.exit(77) +` +} + +function installFakeLefthook(root: string): void { + const binDirectory = join(root, 'node_modules/.bin') + mkdirSync(binDirectory, { recursive: true }) + writeFileSync(join(binDirectory, 'fake-lefthook.mjs'), fakeLefthookSource()) + if (process.platform === 'win32') { + writeFileSync( + join(binDirectory, 'lefthook.cmd'), + `@echo off\r\n"${process.execPath}" "%~dp0\\fake-lefthook.mjs" %*\r\n`, + ) + return + } + const shim = join(binDirectory, 'lefthook') + writeFileSync(shim, `#!/bin/sh\nexec "${process.execPath}" "$(dirname "$0")/fake-lefthook.mjs" "$@"\n`) + chmodSync(shim, 0o755) +} + +function createFixture(): Fixture { + const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-')) + fixtures.push(container) + const main = join(container, 'main') + const linked = join(container, 'linked') + const env: NodeJS.ProcessEnv = { + ...process.env, + GIT_AUTHOR_EMAIL: 'hooks@example.test', + GIT_AUTHOR_NAME: 'Hooks Test', + GIT_COMMITTER_EMAIL: 'hooks@example.test', + GIT_COMMITTER_NAME: 'Hooks Test', + GIT_CONFIG_GLOBAL: join(container, 'global.gitconfig'), + GIT_CONFIG_NOSYSTEM: '1', + HOME: container, + XDG_CONFIG_HOME: join(container, '.config'), + } + const fixture = { container, env, linked, main } + mkdirSync(main) + git(fixture, container, ['init', main]) + write(join(main, 'README.md'), '# fixture\n') + git(fixture, main, ['add', 'README.md']) + git(fixture, main, ['commit', '-m', 'fixture']) + git(fixture, main, ['worktree', 'add', '-b', 'linked', linked]) + write(join(main, 'lefthook.yml'), 'main-worktree-config\n') + write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n') + installFakeLefthook(main) + installFakeLefthook(linked) + return fixture +} + +function gitDirectory(fixture: Fixture, root: string): string { + return git(fixture, root, ['rev-parse', '--absolute-git-dir']) +} + +function commonDirectory(fixture: Fixture): string { + const output = git(fixture, fixture.main, ['rev-parse', '--git-common-dir']) + return isAbsolute(output) ? output : resolve(fixture.main, output) +} + +function hooksPath(fixture: Fixture, root: string): string { + return join(gitDirectory(fixture, root), 'dsh-hooks') +} + +function runInstaller( + fixture: Fixture, + root: string, + extraEnv: NodeJS.ProcessEnv = {}, +): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn(process.execPath, [installer], { + cwd: root, + env: { ...fixture.env, ...extraEnv }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + child.on('error', reject) + child.on('close', (status) => { resolveResult({ status, stderr, stdout }) }) + }) +} + +describe('worktree-local Lefthook installer', () => { + it('isolates main and linked worktrees without changing legacy common hooks', async () => { + const fixture = createFixture() + const common = commonDirectory(fixture) + const legacyHook = join(common, 'hooks/pre-commit') + write(legacyHook, '#!/bin/sh\n# legacy hook\n', 0o755) + + const mainInstall = await runInstaller(fixture, fixture.main) + const linkedInstall = await runInstaller(fixture, fixture.linked) + expect(mainInstall.status, mainInstall.stderr).toBe(0) + expect(linkedInstall.status, linkedInstall.stderr).toBe(0) + + const mainHooks = hooksPath(fixture, fixture.main) + const linkedHooks = hooksPath(fixture, fixture.linked) + expect(mainHooks).not.toBe(linkedHooks) + expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks) + expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks) + + const mainHook = readFileSync(join(mainHooks, 'pre-commit'), 'utf8') + const linkedHook = readFileSync(join(linkedHooks, 'pre-commit'), 'utf8') + const canonicalMain = git(fixture, fixture.main, ['rev-parse', '--show-toplevel']) + const canonicalLinked = git(fixture, fixture.linked, ['rev-parse', '--show-toplevel']) + expect(mainHook).toContain(`# root=${canonicalMain}`) + expect(mainHook).toContain('# config=main-worktree-config') + expect(mainHook).not.toContain(canonicalLinked) + expect(linkedHook).toContain(`# root=${canonicalLinked}`) + expect(linkedHook).toContain('# config=linked-worktree-config') + expect(linkedHook).not.toContain(canonicalMain) + expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n') + + const commonConfig = join(common, 'config') + expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion'])).toBe('1') + expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'extensions.worktreeConfig'])).toBe('true') + expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1) + + const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8') + git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked]) + expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval) + expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n') + }) + + it('serializes concurrent installs and keeps repeated output stable', async () => { + const fixture = createFixture() + const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' } + const first = await Promise.all([ + runInstaller(fixture, fixture.main, delayed), + runInstaller(fixture, fixture.linked, delayed), + ]) + for (const result of first) expect(result.status, result.stderr).toBe(0) + + const mainHookPath = join(hooksPath(fixture, fixture.main), 'pre-push') + const initialHook = readFileSync(mainHookPath, 'utf8') + const repeated = await Promise.all([ + runInstaller(fixture, fixture.main, delayed), + runInstaller(fixture, fixture.main, delayed), + ]) + for (const result of repeated) expect(result.status, result.stderr).toBe(0) + expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook) + expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false) + expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false) + }) + + it('preserves user-owned hook paths unless an inherited value is explicitly overridden', async () => { + const fixture = createFixture() + const customHook = join(fixture.main, 'custom-hooks/pre-commit') + write(customHook, '#!/bin/sh\n# custom hook\n', 0o755) + git(fixture, fixture.main, ['config', 'core.hooksPath', 'custom-hooks']) + + const refused = await runInstaller(fixture, fixture.main) + expect(refused.status).toBe(1) + expect(refused.stderr).toContain('refusing to replace user-owned core.hooksPath') + expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1') + expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks') + expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + + const optedIn = await runInstaller(fixture, fixture.main, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + }) + expect(optedIn.status, optedIn.stderr).toBe(0) + expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.main)) + expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks') + expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1) + expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n') + + git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', 'linked-custom-hooks']) + const explicitWorktreePath = await runInstaller(fixture, fixture.linked, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + }) + expect(explicitWorktreePath.status).toBe(1) + expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks') + }) + + it('restores the previous hook lookup when Lefthook installation fails', async () => { + const fixture = createFixture() + const common = commonDirectory(fixture) + const legacyHook = join(common, 'hooks/pre-push') + write(legacyHook, '#!/bin/sh\n# legacy pre-push\n', 0o755) + + const result = await runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_FAIL: '1' }) + expect(result.status).toBe(1) + expect(result.stderr).toContain('exit status 77') + expect(gitResult(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1) + expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n') + }) + + it('refuses an unowned directory at the reserved worktree hook path', async () => { + const fixture = createFixture() + const reservedHook = join(hooksPath(fixture, fixture.main), 'pre-commit') + write(reservedHook, '#!/bin/sh\n# user content\n', 0o755) + + const result = await runInstaller(fixture, fixture.main) + expect(result.status).toBe(1) + expect(result.stderr).toContain('refusing to overwrite unowned hooks directory') + expect(readFileSync(reservedHook, 'utf8')).toBe('#!/bin/sh\n# user content\n') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + }) + + it.skipIf(process.platform === 'win32')('rejects Git without worktree-config support before mutation', async () => { + const fixture = createFixture() + const realGit = commandResult('which', ['git'], fixture.main, fixture.env).stdout.trim() + const fakeBin = join(fixture.container, 'fake-bin') + const fakeGit = join(fakeBin, 'git') + write( + fakeGit, + `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.19.0"; exit 0; fi\nexec "${realGit}" "$@"\n`, + 0o755, + ) + + const result = await runInstaller(fixture, fixture.main, { + PATH: `${fakeBin}:${fixture.env.PATH ?? ''}`, + }) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Git 2.20 or newer is required') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) +}) From 10b82b78ce03cc9e7df00aac49865eb43c32db6f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:07:45 +0800 Subject: [PATCH 03/13] fix(dev-infra): preserve worktree path whitespace --- ...2026-07-27-explicit-change-scope-report.i18n.yaml | 4 ++-- .../2026-07-27-explicit-change-scope-report.md | 4 ++-- .../2026-07-27-explicit-change-scope-report.zh.md | 4 ++-- scripts/change-scope.spec.ts | 12 ++++++++++-- scripts/change-scope.ts | 11 ++++++++++- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml index 60fa2d83e6..991099cd5b 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.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-explicit-change-scope-report.md -2026-07-27-explicit-change-scope-report.md: a58bfad840c979fa2290346308471d2c5aede3bd -2026-07-27-explicit-change-scope-report.zh.md: ce1bd94a30589d5bdfb0056e3f47a99982a65da2 +2026-07-27-explicit-change-scope-report.md: 2be148d4fe5503cbdfb3bc15c32f3846dc7f413f +2026-07-27-explicit-change-scope-report.zh.md: 33ecddd966ec56759f13a603d95de35d3f3601ec diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md index a58bfad840..2be148d4fe 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md @@ -12,13 +12,13 @@ An incorrect range undermines evidence selection because it can omit affected pa ## Decision -The root `change-scope` command requires `--base `, accepts `--head ` with `HEAD` as the default, and offers a versioned `--json` form. It resolves both inputs to commits with ambiguity detection and requires one merge base before writing output. The report records the repository root, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. +The root `change-scope` command requires `--base `, accepts `--head ` with `HEAD` as the default, and offers a versioned `--json` form. It resolves both inputs to commits with ambiguity detection and requires one merge base before writing output. The report records the repository root without normalizing legal path whitespace, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. Committed paths compare the resolved merge base with the resolved head. Dirty path sets always describe the current worktree and index, even when `--head` names another commit. Diff configuration cannot hide submodules or invoke external diff or text-conversion drivers, and rename detection is disabled so both sides of a rename remain visible. The command never guesses or fetches a base, queries a hosting provider, or selects tests. Each calling workflow verifies current remote or stack state, supplies the base explicitly, and uses the factual report as input to semantic review or evidence selection. -Focused temporary-repository tests cover a fresh branch tracking `origin/master` without a same-name remote, its post-push upstream, a stacked non-master base, every dirty layer, invalid, ambiguous, and non-commit refs, deterministic human/JSON parity, and unchanged refs, index, config, and status after reporting. +Focused temporary-repository tests cover a fresh branch tracking `origin/master` without a same-name remote, its post-push upstream, a worktree path ending in legal whitespace, a stacked non-master base, every dirty layer, invalid, ambiguous, and non-commit refs, deterministic human/JSON parity, and unchanged refs, index, config, and status after reporting. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md index ce1bd94a30..33ecddd966 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -根目录的 `change-scope` 命令要求提供 `--base `,接受可选的 `--head `(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会输出结果。报告记录仓库根目录、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。 +根目录的 `change-scope` 命令要求提供 `--base `,接受可选的 `--head `(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会输出结果。报告记录仓库根目录(不对路径中的合法空白字符作规范化处理)、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。 已提交路径由解析后的合并基点与头部之间的比较得出。即使 `--head` 指定其他 commit,各类未提交路径集合仍始终描述当前 worktree 与索引。diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。 该命令从不猜测或获取基准,不查询代码托管提供方,也不选择测试。调用该命令的每个工作流都会验证当前远端或堆叠状态、显式提供基准,并将这份事实报告作为语义评审或证据选择的输入。 -聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;堆叠分支以非 master 分支为基准;所有未提交改动层;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。 +聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;以合法空白字符结尾的 worktree 路径;堆叠分支以非 master 分支为基准;所有未提交改动层;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。 ## 考虑过的替代方案 diff --git a/scripts/change-scope.spec.ts b/scripts/change-scope.spec.ts index 9361038e43..69a23715e7 100644 --- a/scripts/change-scope.spec.ts +++ b/scripts/change-scope.spec.ts @@ -40,11 +40,11 @@ function write(path: string, content: string): void { writeFileSync(path, content) } -function fixture(): Fixture { +function fixture(worktreeName = 'worktree'): Fixture { const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-')) fixtureRoots.push(container) const origin = join(container, 'origin.git') - const root = join(container, 'worktree') + const root = join(container, worktreeName) const hooks = join(container, 'hooks') mkdirSync(hooks) git(container, ['init', '--bare', '--initial-branch=master', origin]) @@ -139,6 +139,14 @@ describe('change-scope', () => { expect(pushed.paths.committed).toEqual(['feature.txt']) }) + it.skipIf(process.platform === 'win32')('preserves trailing spaces in the worktree path', () => { + const { root } = fixture('worktree ') + const report = jsonReport(root, 'HEAD') + + expect(report.repository.root).toBe(realpathSync(root)) + expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] }) + }) + it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => { const { root } = fixture() git(root, ['switch', '-c', 'foundation']) diff --git a/scripts/change-scope.ts b/scripts/change-scope.ts index 340f00ef69..cbb3f1e185 100644 --- a/scripts/change-scope.ts +++ b/scripts/change-scope.ts @@ -159,8 +159,17 @@ function diffPaths(root: string, args: string[], context: string): string[] { ], context)) } +function stripGitLineTerminator(output: string): string { + const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output + return process.platform === 'win32' && withoutLineFeed.endsWith('\r') + ? withoutLineFeed.slice(0, -1) + : withoutLineFeed +} + function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeReport { - const root = requireGit(cwd, ['rev-parse', '--show-toplevel'], 'cannot locate a Git worktree').trim() + const root = stripGitLineTerminator( + requireGit(cwd, ['rev-parse', '--show-toplevel'], 'cannot locate a Git worktree'), + ) const baseSha = resolveCommit(root, 'base', options.base) const headSha = resolveCommit(root, 'head', options.head) const mergeBaseSha = resolveMergeBase(root, baseSha, headSha) From b5c8b8083915ecda9e5a7ca9d88ad28169b61b09 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:54:49 +0800 Subject: [PATCH 04/13] fix(dev-infra): reject non-UTF-8 scope paths --- ...-27-explicit-change-scope-report.i18n.yaml | 4 +- ...2026-07-27-explicit-change-scope-report.md | 6 +- ...6-07-27-explicit-change-scope-report.zh.md | 6 +- scripts/change-scope.spec.ts | 38 +++++++++++- scripts/change-scope.ts | 60 ++++++++++++++++--- 5 files changed, 100 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml index 991099cd5b..05c5c3ab35 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.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-explicit-change-scope-report.md -2026-07-27-explicit-change-scope-report.md: 2be148d4fe5503cbdfb3bc15c32f3846dc7f413f -2026-07-27-explicit-change-scope-report.zh.md: 33ecddd966ec56759f13a603d95de35d3f3601ec +2026-07-27-explicit-change-scope-report.md: aa6398368d5f161ed852aa92c1d9d35dd2564a5e +2026-07-27-explicit-change-scope-report.zh.md: 042258122d43e5da81cd470e1cf5af09dfeb2fba diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md index 2be148d4fe..aa6398368d 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md @@ -12,13 +12,13 @@ An incorrect range undermines evidence selection because it can omit affected pa ## Decision -The root `change-scope` command requires `--base `, accepts `--head ` with `HEAD` as the default, and offers a versioned `--json` form. It resolves both inputs to commits with ambiguity detection and requires one merge base before writing output. The report records the repository root without normalizing legal path whitespace, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. +The root `change-scope` command requires `--base `, accepts `--head ` with `HEAD` as the default, and offers a versioned `--json` form. It resolves both inputs to commits with ambiguity detection and requires one merge base before writing output. The report records the repository root without normalizing legal path whitespace, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. Path records are split at raw NUL bytes and decoded as strict UTF-8; an invalid record aborts the report before output instead of substituting characters or collapsing distinct paths. Committed paths compare the resolved merge base with the resolved head. Dirty path sets always describe the current worktree and index, even when `--head` names another commit. Diff configuration cannot hide submodules or invoke external diff or text-conversion drivers, and rename detection is disabled so both sides of a rename remain visible. The command never guesses or fetches a base, queries a hosting provider, or selects tests. Each calling workflow verifies current remote or stack state, supplies the base explicitly, and uses the factual report as input to semantic review or evidence selection. -Focused temporary-repository tests cover a fresh branch tracking `origin/master` without a same-name remote, its post-push upstream, a worktree path ending in legal whitespace, a stacked non-master base, every dirty layer, invalid, ambiguous, and non-commit refs, deterministic human/JSON parity, and unchanged refs, index, config, and status after reporting. +Focused temporary-repository tests cover a fresh branch tracking `origin/master` without a same-name remote, its post-push upstream, a worktree path ending in legal whitespace, a stacked non-master base, every dirty layer, distinct non-UTF-8 POSIX paths failing without partial output, invalid, ambiguous, and non-commit refs, deterministic human/JSON parity, and unchanged refs, index, config, and status after reporting. ## Alternatives considered @@ -34,4 +34,6 @@ Focused temporary-repository tests cover a fresh branch tracking `origin/master` The explicit input makes an incorrect base possible but visible: both input refs and all three resolved commit IDs appear in either output form. Callers pay the small cost of verifying and fetching the live base before running the command. +The string schema deliberately cannot represent non-UTF-8 path bytes. A repository containing them must rename those paths before it can produce a report, preserving exact scope instead of returning a lossy one. + The repository owns one Git-topology helper and focused tests. In return, pre-push selection, code review, and documentation audit share a deterministic, read-only account of committed and local changes without importing forge or policy concerns. diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md index 33ecddd966..042258122d 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -根目录的 `change-scope` 命令要求提供 `--base `,接受可选的 `--head `(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会输出结果。报告记录仓库根目录(不对路径中的合法空白字符作规范化处理)、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。 +根目录的 `change-scope` 命令要求提供 `--base `,接受可选的 `--head `(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会输出结果。报告记录仓库根目录(不对路径中的合法空白字符作规范化处理)、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。路径记录先按原始 NUL 字节切分,再逐条以严格 UTF-8 解码;遇到无效记录时,命令会在写出任何结果前失败,不会用替换字符代替无效字节或把不同路径合并为一条。 已提交路径由解析后的合并基点与头部之间的比较得出。即使 `--head` 指定其他 commit,各类未提交路径集合仍始终描述当前 worktree 与索引。diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。 该命令从不猜测或获取基准,不查询代码托管提供方,也不选择测试。调用该命令的每个工作流都会验证当前远端或堆叠状态、显式提供基准,并将这份事实报告作为语义评审或证据选择的输入。 -聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;以合法空白字符结尾的 worktree 路径;堆叠分支以非 master 分支为基准;所有未提交改动层;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。 +聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;以合法空白字符结尾的 worktree 路径;堆叠分支以非 master 分支为基准;所有未提交改动层;两个互异的非 UTF-8 POSIX 路径会使报告失败且不产生部分输出;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。 ## 考虑过的替代方案 @@ -34,4 +34,6 @@ Status: implemented 显式输入仍可能指定错误的基准,但这种错误是可见的:两种输出格式都会显示输入引用与解析出的三个 commit ID。调用方需要付出少量成本,在运行该命令前验证实时基准并从远端获取它。 +字符串 schema 有意不表示非 UTF-8 路径字节。含有这类路径的仓库必须先重命名这些路径才能生成报告,以此保持范围精确,而非返回有损结果。 + 仓库需要维护一个 Git 拓扑辅助工具及相应的聚焦测试。由此,pre-push 证据选择、代码评审与文档审计可以共享一份确定且只读的已提交及本地变更说明,而不必混入代码托管平台或策略职责。 diff --git a/scripts/change-scope.spec.ts b/scripts/change-scope.spec.ts index 69a23715e7..01829c96dd 100644 --- a/scripts/change-scope.spec.ts +++ b/scripts/change-scope.spec.ts @@ -26,7 +26,7 @@ afterEach(() => { for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -function git(cwd: string, args: string[], input?: string): string { +function git(cwd: string, args: string[], input?: string | Buffer): string { return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, @@ -35,6 +35,14 @@ function git(cwd: string, args: string[], input?: string): string { }).trim() } +function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer { + return execFileSync('git', ['-C', cwd, ...args], { + env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, + input, + stdio: ['pipe', 'pipe', 'pipe'], + }) +} + function write(path: string, content: string): void { mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, content) @@ -187,6 +195,34 @@ describe('change-scope', () => { expect(repositoryState(root)).toEqual(before) }) + it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths without partial output', () => { + const { root } = fixture() + const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content') + const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii') + const firstPath = Buffer.from([0x80]) + const secondPath = Buffer.from([0x81]) + gitBytes(root, ['update-index', '-z', '--index-info'], Buffer.concat([ + entry, + firstPath, + Buffer.from([0]), + entry, + secondPath, + Buffer.from([0]), + ])) + expect(gitBytes(root, ['diff', '--cached', '--name-only', '-z', '--'])).toEqual(Buffer.concat([ + firstPath, + Buffer.from([0]), + secondPath, + Buffer.from([0]), + ])) + const output: string[] = [] + + expect(() => { + writeChangeScope(['--base', 'HEAD', '--json'], root, chunk => output.push(chunk)) + }).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8') + expect(output).toEqual([]) + }) + it('rejects missing, ambiguous, and non-commit refs before writing output', () => { const { root } = fixture() git(root, ['branch', 'collision']) diff --git a/scripts/change-scope.ts b/scripts/change-scope.ts index cbb3f1e185..743346b99e 100644 --- a/scripts/change-scope.ts +++ b/scripts/change-scope.ts @@ -3,10 +3,11 @@ import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { resolve } from 'node:path' -import { parseArgs } from 'node:util' +import { parseArgs, TextDecoder } from 'node:util' const FORMAT_VERSION = 1 const MAX_GIT_OUTPUT = 64 * 1024 * 1024 +const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }) interface ChangeScopeReport { formatVersion: typeof FORMAT_VERSION @@ -39,6 +40,13 @@ interface GitCommandResult { error: Error | undefined } +interface GitBytesCommandResult { + status: number | null + stdout: Buffer + stderr: Buffer + error: Error | undefined +} + interface ChangeScopeOptions { base: string head: string @@ -59,6 +67,19 @@ function executeGit(cwd: string, args: string[]): GitCommandResult { } } +function executeGitBytes(cwd: string, args: string[]): GitBytesCommandResult { + const result = spawnSync('git', ['-C', cwd, ...args], { + env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, + maxBuffer: MAX_GIT_OUTPUT, + }) + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + error: result.error, + } +} + function failureDetail(result: GitCommandResult): string { return result.error?.message ?? (result.stderr.trim() || `Git exited with status ${String(result.status)}`) } @@ -69,6 +90,16 @@ function requireGit(cwd: string, args: string[], context: string): string { return result.stdout } +function requireGitBytes(cwd: string, args: string[], context: string): Buffer { + const result = executeGitBytes(cwd, args) + if (result.status !== 0) { + const detail = result.error?.message + ?? (result.stderr.toString('utf8').trim() || `Git exited with status ${String(result.status)}`) + throw new Error(`${context}: ${detail}`) + } + return result.stdout +} + function parseOptions(args: string[]): ChangeScopeOptions { const { values } = parseArgs({ args, @@ -141,12 +172,27 @@ function comparePaths(left: string, right: string): number { return 0 } -function parsePathSet(output: string): string[] { - return [...new Set(output.split('\0').filter(Boolean))].sort(comparePaths) +function parsePathSet(output: Buffer, context: string): string[] { + const paths: string[] = [] + let start = 0 + let record = 0 + for (let end = 0; end < output.length; end += 1) { + if (output[end] !== 0) continue + if (end > start) { + record += 1 + try { + paths.push(UTF8_DECODER.decode(output.subarray(start, end))) + } catch { + throw new Error(`${context}: Git path ${record} is not valid UTF-8`) + } + } + start = end + 1 + } + return [...new Set(paths)].sort(comparePaths) } function diffPaths(root: string, args: string[], context: string): string[] { - return parsePathSet(requireGit(root, [ + return parsePathSet(requireGitBytes(root, [ 'diff', '--no-ext-diff', '--no-textconv', @@ -156,7 +202,7 @@ function diffPaths(root: string, args: string[], context: string): string[] { '-z', ...args, '--', - ], context)) + ], context), context) } function stripGitLineTerminator(output: string): string { @@ -194,11 +240,11 @@ function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeRep committed: diffPaths(root, [mergeBaseSha, headSha], 'cannot inspect committed paths'), staged: diffPaths(root, ['--cached'], 'cannot inspect staged paths'), unstaged: diffPaths(root, [], 'cannot inspect unstaged paths'), - untracked: parsePathSet(requireGit( + untracked: parsePathSet(requireGitBytes( root, ['ls-files', '--others', '--exclude-standard', '-z', '--'], 'cannot inspect untracked paths', - )), + ), 'cannot inspect untracked paths'), }, } } From 3feaef6ecbab64c52507621a19e730b373d8c847 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:58:19 +0800 Subject: [PATCH 05/13] fix(dev-infra): harden worktree hook migration --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 6 + .../2026-07-27-worktree-local-lefthook.md | 10 +- .../2026-07-27-worktree-local-lefthook.zh.md | 39 ++ docs/development.i18n.yaml | 4 +- docs/development.md | 6 +- docs/development.zh.md | 8 +- scripts/install-lefthook.mjs | 459 +++++++++++++++--- scripts/install-lefthook.spec.ts | 295 ++++++++++- 8 files changed, 749 insertions(+), 78 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md 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 new file mode 100644 index 0000000000..301bf88490 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.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-07-27-worktree-local-lefthook.md +2026-07-27-worktree-local-lefthook.md: 95860efca5309464e82d6d58c8320b3a390ae14f +2026-07-27-worktree-local-lefthook.zh.md: ea1639d2e45c61ba6b041eb5800f2b983a271e4b 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 04db203037..95860efca5 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. The installer requires Git 2.20 or newer, 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 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. +Hook installation is worktree-scoped. The installer 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 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. -The installer recognizes its hook directory with a private ownership marker and updates it idempotently. It refuses an unowned directory or a worktree-specific custom `core.hooksPath`. An inherited global or common-repository hook path is preserved by default; `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` explicitly lets only the current worktree override it, so worktrees without that override continue using the inherited path. This opt-in does not attempt to chain arbitrary hook managers. +The installer recognizes its hook directory with a private ownership marker and updates it idempotently. 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. -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` is refused for manual migration. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. +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. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, the Git version boundary, custom-path refusal and opt-in, legacy common-hook preservation, and failed-install rollback. +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, 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 failed-install rollback. ## Alternatives considered @@ -34,6 +34,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.20. 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 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`. 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 new file mode 100644 index 0000000000..ea1639d2e4 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 让 Lefthook 安装限定于各 worktree + +Status: implemented + +[English](2026-07-27-worktree-local-lefthook.md) | 中文 + +## 问题 + +每次运行 `pnpm install` 都会执行根目录的 [`postinstall`](../../../../package.json),其中的 [`install-lefthook.mjs`](../../../../scripts/install-lefthook.mjs) 会调用 `lefthook install --force`。若无额外配置,关联的 Git worktree 共用同一仓库的默认钩子目录,因此在任一 worktree 中安装都可能改写其他所有 worktree 使用的钩子。 + +Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝对二进制文件路径,之后才尝试当前 worktree 的回退路径。因此,共享钩子会一直运行另一个 worktree 固定版本的二进制文件,直到该 worktree 消失;并发安装还会写入同一组文件。 + +## 决策 + +钩子安装以 worktree 为作用域。为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 + +安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。它会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 + +启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` 或 `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。 + +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。 + +## 考虑过的替代方案 + +**保留共享的生成钩子,并依赖其当前 worktree 回退路径。** 只要对应 worktree 仍存在,记录的绝对路径就会优先生效,因此回退路径无法提供版本或生命周期隔离。 + +**让每个 worktree 都指向同一个纳入版本控制的 `.githooks` 目录。** 使用受版本控制的相对目录可以消除生成的绝对路径,但更改共享的 `core.hooksPath` 可能会禁用旧 worktree 中的钩子,因为其分支并不包含该目录;同时,每个 worktree 仍然耦合于同一个共享配置值。 + +**构建通用的钩子管理器串联层。** 执行顺序、参数转发、失败语义和升级都会成为仓库自行负责的行为,却与 Lefthook 隔离无关。因此,安装程序会拒绝 worktree 专属的自定义路径,只将范围更窄的继承路径覆盖设为显式操作。 + +**停止自动安装钩子。** 手动设置可以避免共享写入,却会使仓库中低成本的提交与推送检查意外变成可选项,短期存在、由 agent(智能体)使用的 worktree 尤其容易受到影响。 + +## 后果 + +安装或移除任一 worktree 不再改变其他 worktree 的生效钩子、二进制文件路径或生成的钩子字节。并发安装会串行执行,重复安装保持幂等;[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)所规定的任务与延迟边界保持不变。 + +首次安装后,仓库会采用 Git 格式版本 1,并拒绝版本低于 Git 2.26 的客户端。自定义 worktree 钩子管理器需要明确选择集成方式;继承钩子路径可继续供其他 worktree 使用,但当前 worktree 显式启用 Lefthook 后,其中不会运行这些继承钩子,除非贡献者通过 `lefthook.yml` 将其串联起来。 + +旧的共用钩子会为尚未升级的 worktree 保留在磁盘上。它们可能逐渐陈旧,但自动删除这些钩子会破坏已注册但所在分支尚未采用本安装程序的 worktree。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8d19bd9880..5046d75245 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: fd7f39ae7b5aac2d44572979ca8c8f1d2df0de6f -development.zh.md: 7dd6209bad75d605e0056d2465a35b08aa091780 +development.md: 6c927c46b0a25f84295796354e7f49eb9eb7b2e9 +development.zh.md: f29d08df18ca9bead7f4c9bf3cf7f749630b1b84 diff --git a/docs/development.md b/docs/development.md index 530a5ea886..6c927c46b0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,7 +8,7 @@ This onboarding guide helps project contributors get started with the local envi - 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). - Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack. -- Git 2.20 or newer; hook setup enables Git's worktree-specific configuration extension. +- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension. - Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests. ## First-time setup @@ -19,7 +19,7 @@ Install dependencies from the repo root: pnpm install ``` -The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. The wrapper 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). +The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. The wrapper 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). If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually: @@ -27,7 +27,7 @@ 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 global or 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`; a worktree-specific custom path is never overwritten and must be integrated or removed explicitly. +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 enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. Run typecheck once after a fresh clone: diff --git a/docs/development.zh.md b/docs/development.zh.md index 7dd6209bad..f29d08df18 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -8,7 +8,7 @@ - Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。 - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。 -- Git。 +- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。 - 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。 ## 首次搭建 @@ -19,14 +19,16 @@ pnpm install ``` -安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败。 +安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。 如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装: ```sh -pnpm exec lefthook install --force +node scripts/install-lefthook.mjs ``` +包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。 + 新克隆后请先运行一次类型检查: ```sh diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 3dc19c9dca..6dd88e5ca8 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node +import { randomUUID } from 'node:crypto' import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' -import { isAbsolute, join, resolve } from 'node:path' +import { dirname, isAbsolute, join, resolve } from 'node:path' -const MINIMUM_GIT = [2, 20, 0] +const MINIMUM_GIT = [2, 26, 0] const HOOKS_DIRECTORY = 'dsh-hooks' const OWNERSHIP_MARKER = '.dsh-lefthook-owned' const OWNERSHIP_MARKER_CONTENT = 'deepseek-harness worktree-local lefthook hooks\n' @@ -11,6 +12,7 @@ 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$' function errorCode(error) { return typeof error === 'object' && error !== null && 'code' in error @@ -47,6 +49,13 @@ function nulValues(result) { return output.split('\0') } +function stripGitLineTerminator(output) { + const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output + return process.platform === 'win32' && withoutLineFeed.endsWith('\r') + ? withoutLineFeed.slice(0, -1) + : withoutLineFeed +} + function fileConfigValues(root, configPath, key) { return nulValues(git( ['config', '--file', configPath, '--null', '--get-all', key], @@ -55,14 +64,76 @@ function fileConfigValues(root, configPath, key) { )) } -function effectiveConfigValue(root, key) { - const values = nulValues(git( - ['config', '--null', '--get', key], +function fileConfigEntries(root, configPath, key) { + const fields = nulValues(git( + ['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key], root, { allowStatuses: [1] }, )) - if (values.length > 1) throw new Error(`git config returned multiple effective values for ${key}`) - return values[0] + if (fields.length % 2 !== 0) { + throw new Error(`git config returned invalid file entries for ${key}`) + } + const entries = [] + for (let index = 0; index < fields.length; index += 2) { + entries.push({ origin: fields[index], value: fields[index + 1] }) + } + return entries +} + +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) { + const fields = nulValues(git( + ['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-regexp', pattern], + root, + { allowStatuses: [1] }, + )) + if (fields.length % 2 !== 0) { + throw new Error(`git config returned invalid matching file entries for ${pattern}`) + } + const entries = [] + for (let index = 0; index < fields.length; index += 2) { + entries.push({ origin: fields[index], ...splitConfigNameValue(fields[index + 1], 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], + root, + { allowStatuses: [1] }, + )) + if (fields.length === 0) return undefined + if (fields.length !== 3) { + throw new Error(`git config returned an invalid scoped value for ${key}`) + } + const [scope, origin, value] = fields + return { origin, scope, value } } function parseGitBoolean(value, key) { @@ -85,11 +156,71 @@ function assertSupportedGit(root) { for (let index = 0; index < MINIMUM_GIT.length; index += 1) { if (actual[index] > MINIMUM_GIT[index]) return if (actual[index] < MINIMUM_GIT[index]) { - throw new Error(`Git 2.20 or newer is required for worktree-local hooks; found ${version}`) + throw new Error(`Git 2.26 or newer is required for worktree-local hooks; found ${version}`) } } } +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) { + 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 ensureWorktreeConfig(root, commonConfigPath) { const versions = fileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion') const versionText = assertSingle(versions, 'core.repositoryFormatVersion') @@ -98,17 +229,6 @@ function ensureWorktreeConfig(root, commonConfigPath) { throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`) } - const worktrees = fileConfigValues(root, commonConfigPath, 'core.worktree') - if (worktrees.length > 0) { - throw new Error('cannot enable extensions.worktreeConfig while core.worktree is in the common config; move it to the main worktree config first') - } - - const bareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') - const bare = bareText === undefined ? undefined : parseGitBoolean(bareText, 'core.bare') - if (bare === true) { - throw new Error('cannot enable extensions.worktreeConfig for a common config with core.bare=true') - } - const extensionText = assertSingle( fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), 'extensions.worktreeConfig', @@ -117,26 +237,79 @@ function ensureWorktreeConfig(root, commonConfigPath) { ? false : parseGitBoolean(extensionText, 'extensions.worktreeConfig') + if (!extensionEnabled) { + for (const entry of fileConfigMatchingEntries(root, commonConfigPath, CONDITIONAL_INCLUDE_PATTERN)) { + const risk = conditionalIncludeRisk( + root, + entry, + configPath => migrationConfigSubject(root, configPath), + ) + 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) { + throw new Error( + `cannot enable extensions.worktreeConfig while core.worktree is in the common config (${configSource(worktreeEntry)}); ` + + '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) { + throw new Error( + `cannot enable extensions.worktreeConfig for a common config with core.bare=true (${configSource(trueBareEntry)})`, + ) + } + const directBareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') + const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare') + if (version === 0) { git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root) } if (!extensionEnabled) { git(['config', '--file', commonConfigPath, 'extensions.worktreeConfig', 'true'], root) } - if (bare === false) { + if (directBare === false) { git(['config', '--file', commonConfigPath, '--unset-all', 'core.bare'], root) } } -function lockOwnerIsAlive(lockPath) { - let owner +function readInstallLock(lockPath) { try { - owner = Number(readFileSync(lockPath, 'utf8').trim()) + return readFileSync(lockPath, 'utf8') } catch (error) { - if (errorCode(error) === 'ENOENT') return false + if (errorCode(error) === 'ENOENT') return undefined throw error } - if (!Number.isSafeInteger(owner) || owner <= 0) return true +} + +function installLockStat(lockPath) { + try { + return lstatSync(lockPath) + } catch (error) { + if (errorCode(error) === 'ENOENT') return undefined + throw error + } +} + +function parseInstallLock(record) { + const match = /^([1-9]\d*) ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\n$/i.exec(record) + if (match === null) return undefined + const owner = Number(match[1]) + return Number.isSafeInteger(owner) ? owner : undefined +} + +function lockOwnerIsAlive(owner) { try { process.kill(owner, 0) return true @@ -147,28 +320,63 @@ function lockOwnerIsAlive(lockPath) { } } -function removeStaleLock(lockPath) { +function manualLockRecoveryError(lockPath, condition) { + return new Error( + `${condition} Lefthook installer lock ${JSON.stringify(lockPath)}. ` + + 'Confirm no Lefthook installer is running, remove it manually, and retry.', + ) +} + +function lockOwnershipChangedError(lockPath) { + return new Error(`Lefthook installer lock ownership changed for ${lockPath}; refusing to remove it`) +} + +function releaseInstallLock(lockPath, ownedRecord, ownedStat) { + const currentStat = installLockStat(lockPath) + if ( + currentStat === undefined + || !currentStat.isFile() + || currentStat.isSymbolicLink() + || currentStat.dev !== ownedStat.dev + || currentStat.ino !== ownedStat.ino + || readInstallLock(lockPath) !== ownedRecord + ) { + throw lockOwnershipChangedError(lockPath) + } try { unlinkSync(lockPath) } catch (error) { - if (errorCode(error) !== 'ENOENT') throw error - // Another waiting installer removed the same stale lock first. + if (errorCode(error) === 'ENOENT') { + throw lockOwnershipChangedError(lockPath) + } + throw error } } async function acquireInstallLock(commonDirectory) { const lockPath = join(commonDirectory, INSTALL_LOCK) const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS + const ownedRecord = `${String(process.pid)} ${randomUUID()}\n` while (true) { try { - writeFileSync(lockPath, `${String(process.pid)}\n`, { flag: 'wx', mode: 0o600 }) - return () => removeStaleLock(lockPath) + writeFileSync(lockPath, ownedRecord, { flag: 'wx', mode: 0o600 }) + const ownedStat = installLockStat(lockPath) + if (ownedStat === undefined || !ownedStat.isFile() || ownedStat.isSymbolicLink()) { + throw lockOwnershipChangedError(lockPath) + } + return () => releaseInstallLock(lockPath, ownedRecord, ownedStat) } catch (error) { if (errorCode(error) !== 'EEXIST') throw error - if (!lockOwnerIsAlive(lockPath)) { - removeStaleLock(lockPath) - continue + const existingStat = installLockStat(lockPath) + if (existingStat === undefined) continue + if (!existingStat.isFile() || existingStat.isSymbolicLink()) { + throw manualLockRecoveryError(lockPath, 'invalid') } + const existingRecord = readInstallLock(lockPath) + if (existingRecord === undefined) continue + const owner = parseInstallLock(existingRecord) + if (owner === undefined) throw manualLockRecoveryError(lockPath, 'invalid') + if (!lockOwnerIsAlive(owner)) throw manualLockRecoveryError(lockPath, 'stale') if (Date.now() >= deadline) { throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`) } @@ -197,63 +405,176 @@ function ensureOwnedHooksDirectory(hooksPath) { } } +function environmentWithoutCommandGitConfig() { + const env = { ...process.env } + for (const key of Object.keys(env)) { + const normalized = key.toUpperCase() + if ( + normalized === 'GIT_CONFIG_PARAMETERS' + || normalized === 'GIT_CONFIG_COUNT' + || /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(normalized) + ) { + delete env[key] + } + } + return env +} + function runLefthook(root, lefthook) { const args = ['install', '--force'] + const env = environmentWithoutCommandGitConfig() // Node refuses to spawn Windows `.cmd` shims directly; the quoted path is // re-parsed by cmd.exe, while POSIX can execute its extensionless shim. const result = process.platform === 'win32' - ? spawnSync(`"${lefthook}"`, args, { cwd: root, stdio: 'inherit', shell: true }) - : spawnSync(lefthook, args, { cwd: root, stdio: 'inherit' }) + ? spawnSync(`"${lefthook}"`, args, { cwd: root, env, stdio: 'inherit', shell: true }) + : spawnSync(lefthook, args, { cwd: root, env, stdio: 'inherit' }) if (result.status !== 0) throw commandFailure(lefthook, args, result) } -function refuseCustomHooksPath(root, hooksPath) { - const origin = git( - ['config', '--show-origin', '--get', 'core.hooksPath'], - root, - { allowStatuses: [1] }, - ).stdout.trim() - const source = origin === '' ? hooksPath : origin +function configSource(entry) { + return `${entry.origin}: ${JSON.stringify(entry.value)}` +} + +function normalizedPath(path) { + const normalized = resolve(path) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function configOriginPath(origin, root) { + if (!origin.startsWith('file:')) return undefined + const originPath = origin.slice('file:'.length) + return isAbsolute(originPath) ? originPath : resolve(root, originPath) +} + +function originIsFile(origin, root, configPath) { + const originPath = configOriginPath(origin, root) + 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 (${source}). ` + `refusing to replace user-owned core.hooksPath (${configSource(entry)}). ` + `Chain those hooks through lefthook.yml, or, if this inherited path may remain active only in other worktrees, ` + `rerun with ${ALLOW_HOOKS_PATH_OVERRIDE}=1`, ) } +function refuseScopedHooksPath(entry) { + if (entry.scope === 'command') { + throw new Error( + `refusing to replace command-scoped core.hooksPath (${configSource(entry)}); ` + + `${ALLOW_HOOKS_PATH_OVERRIDE} cannot override transient command configuration`, + ) + } + if (entry.scope === 'worktree') { + throw new Error( + `refusing to replace worktree-scoped core.hooksPath (${configSource(entry)}); ` + + 'a worktree-specific custom path must be integrated or removed explicitly', + ) + } + throw new Error( + `refusing to replace core.hooksPath from unsupported ${entry.scope} scope (${configSource(entry)})`, + ) +} + async function main() { const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) if (probe.status !== 0) return - const root = probe.stdout.trim() + const root = stripGitLineTerminator(probe.stdout) const isWindows = process.platform === 'win32' const lefthook = join(root, 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook') if (!existsSync(lefthook)) return assertSupportedGit(root) - const gitDirectory = git(['rev-parse', '--absolute-git-dir'], root).stdout.trim() - const commonOutput = git(['rev-parse', '--git-common-dir'], root).stdout.trim() + const gitDirectory = stripGitLineTerminator(git(['rev-parse', '--absolute-git-dir'], root).stdout) + const commonOutput = stripGitLineTerminator(git(['rev-parse', '--git-common-dir'], root).stdout) const commonDirectory = isAbsolute(commonOutput) ? commonOutput : resolve(root, commonOutput) const commonConfigPath = join(commonDirectory, 'config') const worktreeConfigPath = join(gitDirectory, 'config.worktree') const hooksPath = join(gitDirectory, HOOKS_DIRECTORY) const releaseLock = await acquireInstallLock(commonDirectory) + let installationError try { + const worktreeEntries = fileConfigEntries(root, worktreeConfigPath, 'core.hooksPath') + const includedWorktreeEntry = worktreeEntries.find( + entry => !originIsFile(entry.origin, root, worktreeConfigPath), + ) + if (includedWorktreeEntry !== undefined) { + refuseScopedHooksPath({ ...includedWorktreeEntry, scope: 'worktree' }) + } const worktreePath = assertSingle( - fileConfigValues(root, worktreeConfigPath, 'core.hooksPath'), + worktreeEntries.map(entry => entry.value), 'worktree core.hooksPath', ) - if (worktreePath !== undefined && worktreePath !== hooksPath) refuseCustomHooksPath(root, worktreePath) - - const effectivePath = effectiveConfigValue(root, 'core.hooksPath') - const effectivePathIsOwned = effectivePath === hooksPath && worktreePath === hooksPath - if ( - effectivePath !== undefined - && !effectivePathIsOwned - && process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1' - ) { - refuseCustomHooksPath(root, effectivePath) + if (worktreePath !== undefined && worktreePath !== hooksPath) { + refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath }) } + const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath') + if (effectiveEntry !== undefined) { + const effectivePathIsOwned = effectiveEntry.scope === 'worktree' + && effectiveEntry.value === hooksPath + && worktreePath === hooksPath + && originIsFile(effectiveEntry.origin, root, worktreeConfigPath) + if (!effectivePathIsOwned) { + if (effectiveEntry.scope === 'command' || effectiveEntry.scope === 'worktree') { + refuseScopedHooksPath(effectiveEntry) + } + if (!['system', 'global', 'local'].includes(effectiveEntry.scope)) { + refuseScopedHooksPath(effectiveEntry) + } + if (process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1') { + refuseInheritedHooksPath(effectiveEntry) + } + } + } + assertConditionalHooksPaths(root, worktreeConfigPath) ensureOwnedHooksDirectory(hooksPath) ensureWorktreeConfig(root, commonConfigPath) @@ -262,6 +583,15 @@ async function main() { try { git(['config', '--worktree', 'core.hooksPath', hooksPath], root) pathChanged = worktreePath === undefined + const installedEntry = effectiveConfigEntry(root, 'core.hooksPath') + if ( + installedEntry === undefined + || installedEntry.scope !== 'worktree' + || installedEntry.value !== hooksPath + || !originIsFile(installedEntry.origin, root, worktreeConfigPath) + ) { + throw new Error('new worktree-local core.hooksPath did not become the effective direct worktree value') + } runLefthook(root, lefthook) } catch (error) { if (pathChanged) { @@ -269,8 +599,21 @@ async function main() { } throw error } + } catch (error) { + installationError = error + throw error } finally { - releaseLock() + try { + releaseLock() + } catch (releaseError) { + if (installationError !== undefined) { + throw new AggregateError( + [installationError, releaseError], + `Lefthook installation failed: ${String(installationError)}; installer lock release also failed: ${String(releaseError)}`, + ) + } + throw releaseError + } } } diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 81f1be2897..b54a7ab864 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -62,7 +62,17 @@ import { execFileSync } from 'node:child_process' import { join } from 'node:path' if (process.argv.slice(2).join(' ') !== 'install --force') process.exit(64) -const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim() +const rootOutput = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) +const root = rootOutput.endsWith('\\n') ? rootOutput.slice(0, -1) : rootOutput +const forbiddenConfigKey = process.env.DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY +if (forbiddenConfigKey !== undefined) { + try { + execFileSync('git', ['config', '--get', forbiddenConfigKey], { encoding: 'utf8' }) + process.exit(92) + } catch (error) { + if (error === null || typeof error !== 'object' || !('status' in error) || error.status !== 1) throw error + } +} const hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim() mkdirSync(hooksPath, { recursive: true }) const running = join(hooksPath, '.fake-lefthook-running') @@ -101,11 +111,11 @@ function installFakeLefthook(root: string): void { chmodSync(shim, 0o755) } -function createFixture(): Fixture { +function createFixture(names: { main?: string; linked?: string } = {}): Fixture { const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-')) fixtures.push(container) - const main = join(container, 'main') - const linked = join(container, 'linked') + const main = join(container, names.main ?? 'main') + const linked = join(container, names.linked ?? 'linked') const env: NodeJS.ProcessEnv = { ...process.env, GIT_AUTHOR_EMAIL: 'hooks@example.test', @@ -144,6 +154,18 @@ function hooksPath(fixture: Fixture, root: string): string { return join(gitDirectory(fixture, root), 'dsh-hooks') } +function installLockPath(fixture: Fixture): string { + return join(commonDirectory(fixture), 'dsh-lefthook-install.lock') +} + +async function waitForPath(path: string): Promise { + const deadline = Date.now() + 5_000 + while (!existsSync(path)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`) + await new Promise(resolveWait => setTimeout(resolveWait, 10)) + } +} + function runInstaller( fixture: Fixture, root: string, @@ -226,6 +248,69 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false) }) + it('leaves stale installer locks for explicit recovery', async () => { + const fixture = createFixture() + const lockPath = installLockPath(fixture) + const completed = spawnSync(process.execPath, ['-e', '']) + expect(completed.status).toBe(0) + const staleRecord = `${String(completed.pid)} 00000000-0000-4000-8000-000000000000\n` + writeFileSync(lockPath, staleRecord) + + const results = await Promise.all(Array.from( + { length: 4 }, + () => runInstaller(fixture, fixture.main), + )) + + for (const result of results) { + expect(result.status).toBe(1) + expect(result.stderr).toContain('stale Lefthook installer lock') + expect(result.stderr).toContain('remove it manually') + } + expect(readFileSync(lockPath, 'utf8')).toBe(staleRecord) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + }) + + it('leaves invalid installer locks for explicit recovery', async () => { + const fixture = createFixture() + const lockPath = installLockPath(fixture) + const invalidRecord = 'not an installer lock\n' + writeFileSync(lockPath, invalidRecord) + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('invalid Lefthook installer lock') + expect(result.stderr).toContain('remove it manually') + expect(readFileSync(lockPath, 'utf8')).toBe(invalidRecord) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it('does not release an installer lock whose ownership changed', async () => { + const fixture = createFixture() + const lockPath = installLockPath(fixture) + const runningPath = join(hooksPath(fixture, fixture.main), '.fake-lefthook-running') + const install = runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_DELAY_MS: '250' }) + await waitForPath(runningPath) + const replacementRecord = 'replacement owner\n' + writeFileSync(lockPath, replacementRecord) + + const result = await install + expect(result.status).toBe(1) + expect(result.stderr).toContain('installer lock ownership changed') + expect(readFileSync(lockPath, 'utf8')).toBe(replacementRecord) + }) + + it.skipIf(process.platform === 'win32')('preserves trailing spaces in worktree paths', async () => { + const fixture = createFixture({ main: 'main ', linked: 'linked ' }) + + for (const root of [fixture.main, fixture.linked]) { + const result = await runInstaller(fixture, root) + expect(result.status, result.stderr).toBe(0) + expect(git(fixture, root, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, root)) + } + }) + it('preserves user-owned hook paths unless an inherited value is explicitly overridden', async () => { const fixture = createFixture() const customHook = join(fixture.main, 'custom-hooks/pre-commit') @@ -257,6 +342,202 @@ describe('worktree-local Lefthook installer', () => { expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks') }) + 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']) { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`) + const value = key === 'core.worktree' ? fixture.main : 'true' + git(fixture, fixture.main, ['config', '--file', includedConfig, key, value]) + git(fixture, fixture.main, ['config', '--file', commonConfig, includeKey, includedConfig]) + + 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) + } + } + }) + + it('allows a conditional common-config include unrelated to migration or hooks', 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']) + git(fixture, fixture.main, [ + 'config', + '--file', + commonConfig, + 'includeIf.onbranch:conditional.path', + includedConfig, + ]) + + const result = await runInstaller(fixture, fixture.linked) + + expect(result.status, result.stderr).toBe(0) + expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked)) + }) + + it('never overrides a command-scoped hook path', async () => { + const fixture = createFixture() + const commandHooks = join(fixture.container, 'command-hooks') + const sentinel = join(commandHooks, 'pre-commit') + write(sentinel, '#!/bin/sh\n# command-scope sentinel\n', 0o755) + + const result = await runInstaller(fixture, fixture.main, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.hooksPath', + GIT_CONFIG_VALUE_0: commandHooks, + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('command-scoped core.hooksPath') + expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# command-scope sentinel\n') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1) + 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() + + const result = await runInstaller(fixture, fixture.main, { + DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY: 'dsh.testSentinel', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'dsh.testSentinel', + GIT_CONFIG_VALUE_0: 'must-not-reach-lefthook', + }) + + expect(result.status, result.stderr).toBe(0) + expect(existsSync(join(hooksPath(fixture, fixture.main), 'pre-commit'))).toBe(true) + }) + + it('never overrides a hook path included by worktree config', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree') + const includedConfig = join(fixture.container, 'included-worktree.gitconfig') + const includedHooks = join(fixture.container, 'included-hooks') + const sentinel = join(includedHooks, 'pre-commit') + write(sentinel, '#!/bin/sh\n# included-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, 'include.path', includedConfig]) + + const result = await runInstaller(fixture, fixture.main, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('worktree-scoped core.hooksPath') + expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe(includedHooks) + expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# included-worktree sentinel\n') + 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) @@ -283,14 +564,14 @@ describe('worktree-local Lefthook installer', () => { expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) }) - it.skipIf(process.platform === 'win32')('rejects Git without worktree-config support before mutation', async () => { + it.skipIf(process.platform === 'win32')('rejects Git without config-scope support before mutation', async () => { const fixture = createFixture() const realGit = commandResult('which', ['git'], fixture.main, fixture.env).stdout.trim() const fakeBin = join(fixture.container, 'fake-bin') const fakeGit = join(fakeBin, 'git') write( fakeGit, - `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.19.0"; exit 0; fi\nexec "${realGit}" "$@"\n`, + `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.25.0"; exit 0; fi\nexec "${realGit}" "$@"\n`, 0o755, ) @@ -298,7 +579,7 @@ describe('worktree-local Lefthook installer', () => { PATH: `${fakeBin}:${fixture.env.PATH ?? ''}`, }) expect(result.status).toBe(1) - expect(result.stderr).toContain('Git 2.20 or newer is required') + expect(result.stderr).toContain('Git 2.26 or newer is required') expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) }) From d47801a6e3847f6974a75a25cbe080e3d1fc2ce4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:21:01 +0800 Subject: [PATCH 06/13] fix(dev-infra): preserve exact scope refs --- scripts/change-scope.spec.ts | 14 ++++++++++++++ scripts/change-scope.ts | 6 +++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/change-scope.spec.ts b/scripts/change-scope.spec.ts index 01829c96dd..db3a80dae7 100644 --- a/scripts/change-scope.spec.ts +++ b/scripts/change-scope.spec.ts @@ -155,6 +155,20 @@ describe('change-scope', () => { expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] }) }) + it('preserves legal Unicode edge whitespace in branch and upstream names', () => { + const { root } = fixture() + const branch = '\u00a0topic\u3000' + const upstreamBranch = '\u3000upstream\u00a0' + git(root, ['switch', '-c', branch]) + git(root, ['push', 'origin', `HEAD:refs/heads/${upstreamBranch}`]) + git(root, ['branch', '--set-upstream-to', `origin/${upstreamBranch}`]) + + const report = jsonReport(root, 'origin/master') + + expect(report.repository.branch).toBe(branch) + expect(report.repository.upstream).toBe(`origin/${upstreamBranch}`) + }) + it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => { const { root } = fixture() git(root, ['switch', '-c', 'foundation']) diff --git a/scripts/change-scope.ts b/scripts/change-scope.ts index 743346b99e..fdd1c8566d 100644 --- a/scripts/change-scope.ts +++ b/scripts/change-scope.ts @@ -153,16 +153,16 @@ function currentBranch(root: string): string | null { const result = executeGit(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']) if (result.status === 1) return null if (result.status !== 0) throw new Error(`cannot inspect the current branch: ${failureDetail(result)}`) - return result.stdout.trim() + return stripGitLineTerminator(result.stdout) } function configuredUpstream(root: string, branch: string | null): string | null { if (branch === null) return null - const output = requireGit( + const output = stripGitLineTerminator(requireGit( root, ['for-each-ref', '--count=1', '--format=%(upstream:short)', `refs/heads/${branch}`], 'cannot inspect the configured upstream', - ).trim() + )) return output === '' ? null : output } From 19b879496602309077a78342a421db70f1966bca Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:31:12 +0800 Subject: [PATCH 07/13] fix(dev-infra): guard dormant worktree configs --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 4 +- .../2026-07-27-worktree-local-lefthook.md | 4 +- .../2026-07-27-worktree-local-lefthook.zh.md | 4 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/install-lefthook.mjs | 67 ++++++++++++++++--- scripts/install-lefthook.spec.ts | 18 +++++ 8 files changed, 87 insertions(+), 18 deletions(-) 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 301bf88490..3ca9da8947 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: 95860efca5309464e82d6d58c8320b3a390ae14f -2026-07-27-worktree-local-lefthook.zh.md: ea1639d2e45c61ba6b041eb5800f2b983a271e4b +2026-07-27-worktree-local-lefthook.md: 2e15f2d242cbcdd56452f3c8ac59bc1e330bc7c0 +2026-07-27-worktree-local-lefthook.zh.md: 742d48511f6fd46202ac9c0ead476105d9b97370 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 95860efca5..2e15f2d242 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. The installer 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 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. The installer 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 first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. 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. The installer recognizes its hook directory with a private ownership marker and updates it idempotently. 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. 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. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, 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 failed-install rollback. +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, dormant sibling-config refusal, 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 failed-install rollback. ## Alternatives considered 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 ea1639d2e4..742d48511f 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 为作用域。为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 +钩子安装以 worktree 为作用域。为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。首次启用这一仓库级扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中尚未生效的 `config.worktree` 文件,并拒绝任何一经激活就会改变当前或其他 worktree 的设置。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。它会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` 或 `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。 -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。 +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、拒绝激活其他 worktree 中尚未生效的配置、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。 ## 考虑过的替代方案 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 5046d75245..f21ed31162 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: 6c927c46b0a25f84295796354e7f49eb9eb7b2e9 -development.zh.md: f29d08df18ca9bead7f4c9bf3cf7f749630b1b84 +development.md: 16a0d7210f660d2f9dfe423609913ec6f92a8d75 +development.zh.md: 98b9f7ecd74634ef1e09e3f967913a5f6bf44295 diff --git a/docs/development.md b/docs/development.md index 6c927c46b0..16a0d7210f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,7 @@ 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 enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. +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 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. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. Run typecheck once after a fresh clone: diff --git a/docs/development.zh.md b/docs/development.zh.md index f29d08df18..98b9f7ecd7 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -27,7 +27,7 @@ pnpm install node scripts/install-lefthook.mjs ``` -包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。 +包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。 新克隆后请先运行一次类型检查: diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 6dd88e5ca8..6fbb078660 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { randomUUID } from 'node:crypto' -import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +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' @@ -148,6 +148,57 @@ function assertSingle(values, key) { return values[0] } +function worktreeConfigExtensionEnabled(root, commonConfigPath) { + const extensionText = assertSingle( + fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), + 'extensions.worktreeConfig', + ) + return extensionText === undefined + ? false + : parseGitBoolean(extensionText, 'extensions.worktreeConfig') +} + +function hasDirectConfigEntries(root, configPath) { + return git(['config', '--file', configPath, '--null', '--list'], root).stdout !== '' +} + +function registeredWorktreeConfigPaths(commonDirectory) { + const paths = [join(commonDirectory, 'config.worktree')] + const linkedDirectory = join(commonDirectory, 'worktrees') + try { + const entries = readdirSync(linkedDirectory, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + for (const entry of entries) { + paths.push(join(linkedDirectory, entry.name, 'config.worktree')) + } + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + } + return paths +} + +function assertDormantWorktreeConfigs(root, commonDirectory, commonConfigPath, currentConfigPath) { + if (worktreeConfigExtensionEnabled(root, commonConfigPath)) return + for (const configPath of registeredWorktreeConfigPaths(commonDirectory)) { + if (!existsSync(configPath)) continue + const configStat = lstatSync(configPath) + if (!configStat.isFile() || configStat.isSymbolicLink()) { + throw new Error( + `cannot enable extensions.worktreeConfig while dormant worktree config ${JSON.stringify(configPath)} ` + + 'is not a regular file; inspect it and enable the extension explicitly, or remove it, before retrying', + ) + } + if (!hasDirectConfigEntries(root, configPath)) continue + const isCurrent = normalizedPath(configPath) === normalizedPath(currentConfigPath) + const owner = isCurrent ? 'current' : 'sibling' + throw new Error( + `cannot enable extensions.worktreeConfig while ${owner} dormant worktree config ` + + `${JSON.stringify(configPath)} contains user-owned settings that enabling the extension would activate; ` + + 'inspect and migrate those settings, then enable the extension explicitly or remove them before retrying', + ) + } +} + function assertSupportedGit(root) { const version = git(['--version'], root).stdout.trim() const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(version) @@ -229,13 +280,7 @@ function ensureWorktreeConfig(root, commonConfigPath) { throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`) } - const extensionText = assertSingle( - fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), - 'extensions.worktreeConfig', - ) - const extensionEnabled = extensionText === undefined - ? false - : parseGitBoolean(extensionText, 'extensions.worktreeConfig') + const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath) if (!extensionEnabled) { for (const entry of fileConfigMatchingEntries(root, commonConfigPath, CONDITIONAL_INCLUDE_PATTERN)) { @@ -576,6 +621,12 @@ async function main() { } assertConditionalHooksPaths(root, worktreeConfigPath) + assertDormantWorktreeConfigs( + root, + commonDirectory, + commonConfigPath, + worktreeConfigPath, + ) ensureOwnedHooksDirectory(hooksPath) ensureWorktreeConfig(root, commonConfigPath) diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index b54a7ab864..72272c7a32 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -342,6 +342,24 @@ describe('worktree-local Lefthook installer', () => { expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks') }) + it('refuses to activate a sibling worktree dormant hook path', async () => { + const fixture = createFixture() + const linkedConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree') + const linkedHooks = join(fixture.linked, 'custom-hooks') + git(fixture, fixture.main, ['config', '--file', linkedConfig, 'core.hooksPath', linkedHooks]) + expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1) + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('sibling dormant worktree config') + expect(result.stderr).toContain(linkedConfig) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1) + expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks) + 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']) { From 1f8a3dd7b15797e8482b2836e1e46637486a24c1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:54:04 +0800 Subject: [PATCH 08/13] fix(dev-infra): skip hook setup in CI --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 4 ++-- .../2026-07-27-worktree-local-lefthook.md | 6 ++++-- .../2026-07-27-worktree-local-lefthook.zh.md | 6 ++++-- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/install-lefthook.mjs | 1 + scripts/install-lefthook.spec.ts | 21 +++++++++++++++++++ 8 files changed, 36 insertions(+), 10 deletions(-) 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 3ca9da8947..b66050ca21 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: 2e15f2d242cbcdd56452f3c8ac59bc1e330bc7c0 -2026-07-27-worktree-local-lefthook.zh.md: 742d48511f6fd46202ac9c0ead476105d9b97370 +2026-07-27-worktree-local-lefthook.md: 8bfa6da3de33baf247acb9cb58c8d28abd3c501e +2026-07-27-worktree-local-lefthook.zh.md: 19f5163ddd855a9a9ca9940da01b93b6ee533e6a 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 2e15f2d242..8bfa6da3de 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. The installer 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 first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. 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`, 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 first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. 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. The installer recognizes its hook directory with a private ownership marker and updates it idempotently. 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. 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. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, dormant sibling-config refusal, 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 failed-install rollback. +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises the CI no-op, main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, dormant sibling-config refusal, 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 failed-install rollback. ## Alternatives considered @@ -28,6 +28,8 @@ Enabling worktree config removes the standard redundant `core.bare=false` value **Build a general hook-manager chaining layer.** Ordering, argument forwarding, failure semantics, and upgrades become repository-owned behavior unrelated to Lefthook isolation. The installer instead refuses worktree-specific custom paths and makes the narrower inherited-path override explicit. +**Whitelist provider-specific CI credential-include paths.** Contributor hooks are unused in CI, so path exemptions would couple installer safety to provider checkout internals and weaken strict validation for contributor installs. The CI no-op avoids repository mutation without any exemptions. + **Stop installing hooks automatically.** Manual setup avoids shared writes but makes the repository's cheap commit and push checks optional by accident, especially in short-lived agent worktrees. ## Consequences 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 742d48511f..19f5163ddd 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 为作用域。为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。首次启用这一仓库级扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中尚未生效的 `config.worktree` 文件,并拒绝任何一经激活就会改变当前或其他 worktree 的设置。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 +钩子安装以 worktree 为作用域。当 `CI=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。首次启用这一仓库级扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中尚未生效的 `config.worktree` 文件,并拒绝任何一经激活就会改变当前或其他 worktree 的设置。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。它会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` 或 `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。 -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、拒绝激活其他 worktree 中尚未生效的配置、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。 +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖 CI 下不执行操作的行为、主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、拒绝激活其他 worktree 中尚未生效的配置、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。 ## 考虑过的替代方案 @@ -28,6 +28,8 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 **构建通用的钩子管理器串联层。** 执行顺序、参数转发、失败语义和升级都会成为仓库自行负责的行为,却与 Lefthook 隔离无关。因此,安装程序会拒绝 worktree 专属的自定义路径,只将范围更窄的继承路径覆盖设为显式操作。 +**将特定 CI 提供商的凭据 include 路径加入白名单。** CI 不使用贡献者钩子,因此路径豁免会使安装程序的安全性耦合于提供商的检出目录内部结构,并削弱贡献者安装时的严格验证。CI 无操作方案无需任何豁免即可避免修改仓库。 + **停止自动安装钩子。** 手动设置可以避免共享写入,却会使仓库中低成本的提交与推送检查意外变成可选项,短期存在、由 agent(智能体)使用的 worktree 尤其容易受到影响。 ## 后果 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index f21ed31162..80e412e583 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: 16a0d7210f660d2f9dfe423609913ec6f92a8d75 -development.zh.md: 98b9f7ecd74634ef1e09e3f967913a5f6bf44295 +development.md: bd956f5bef4339c2732187ec68302b58a771976f +development.zh.md: 63e226f971102d4989ba57a144a73de9e388612f diff --git a/docs/development.md b/docs/development.md index 16a0d7210f..bd956f5bef 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,7 +19,7 @@ Install dependencies from the repo root: pnpm install ``` -The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. The wrapper 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). +The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=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). If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually: diff --git a/docs/development.zh.md b/docs/development.zh.md index 98b9f7ecd7..63e226f971 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -19,7 +19,7 @@ pnpm install ``` -安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。 +安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=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)。 如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装: diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 6fbb078660..2c2176da96 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -569,6 +569,7 @@ function refuseScopedHooksPath(entry) { } async function main() { + if (process.env.CI === 'true') return const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) if (probe.status !== 0) return const root = stripGitLineTerminator(probe.stdout) diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 72272c7a32..defd7106ce 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -118,6 +118,7 @@ function createFixture(names: { main?: string; linked?: string } = {}): Fixture const linked = join(container, names.linked ?? 'linked') const env: NodeJS.ProcessEnv = { ...process.env, + CI: 'false', GIT_AUTHOR_EMAIL: 'hooks@example.test', GIT_AUTHOR_NAME: 'Hooks Test', GIT_COMMITTER_EMAIL: 'hooks@example.test', @@ -187,6 +188,26 @@ function runInstaller( } describe('worktree-local Lefthook installer', () => { + it('skips hook installation when CI is true', async () => { + const fixture = createFixture() + const common = commonDirectory(fixture) + const missingInclude = join(fixture.container, 'missing-ci-credentials.gitconfig') + git(fixture, fixture.main, [ + 'config', + '--local', + 'includeIf.gitdir:/github/workspace/.git.path', + missingInclude, + ]) + + const result = await runInstaller(fixture, fixture.main, { CI: 'true' }) + + expect(result.status, result.stderr).toBe(0) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0') + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + expect(existsSync(join(common, 'config.worktree'))).toBe(false) + }) + it('isolates main and linked worktrees without changing legacy common hooks', async () => { const fixture = createFixture() const common = commonDirectory(fixture) From 74c1a7644046b44a3da05dd3479603a457516cec Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:00:36 +0800 Subject: [PATCH 09/13] fix(dev-infra): detect GitHub Actions installs --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 4 +- .../2026-07-27-worktree-local-lefthook.md | 2 +- .../2026-07-27-worktree-local-lefthook.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/install-lefthook.mjs | 2 +- scripts/install-lefthook.spec.ts | 40 +++++++++++-------- 8 files changed, 32 insertions(+), 26 deletions(-) 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 b66050ca21..c7e7827c5b 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: 8bfa6da3de33baf247acb9cb58c8d28abd3c501e -2026-07-27-worktree-local-lefthook.zh.md: 19f5163ddd855a9a9ca9940da01b93b6ee533e6a +2026-07-27-worktree-local-lefthook.md: f35fe4a91063bca6f29d61932e414d7d4843d2f0 +2026-07-27-worktree-local-lefthook.zh.md: c82e81f5a96f122c961234174fd123259f92ab9e 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 8bfa6da3de..f35fe4a910 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,7 +12,7 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal ## Decision -Hook installation is worktree-scoped. With `CI=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 first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. 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`. Before first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. 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. The installer recognizes its hook directory with a private ownership marker and updates it idempotently. 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. 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 19f5163ddd..c82e81f5a9 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,7 +12,7 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 ## 决策 -钩子安装以 worktree 为作用域。当 `CI=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。首次启用这一仓库级扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中尚未生效的 `config.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 与每个已注册关联 worktree 中尚未生效的 `config.worktree` 文件,并拒绝任何一经激活就会改变当前或其他 worktree 的设置。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。它会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 80e412e583..d67eb0595d 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: bd956f5bef4339c2732187ec68302b58a771976f -development.zh.md: 63e226f971102d4989ba57a144a73de9e388612f +development.md: dfe99362aa9b881645c69b2bab74180280b4f1b3 +development.zh.md: 10d9129b288d1540b27a9ddc94f1f4acdd3f5f9e diff --git a/docs/development.md b/docs/development.md index bd956f5bef..dfe99362aa 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,7 +19,7 @@ Install dependencies from the repo root: pnpm install ``` -The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=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). +The 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). If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually: diff --git a/docs/development.zh.md b/docs/development.zh.md index 63e226f971..10d9129b28 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -19,7 +19,7 @@ pnpm install ``` -安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=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)。 +安装过程同时会运行根目录的 `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)。 如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装: diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 2c2176da96..275d322179 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -569,7 +569,7 @@ function refuseScopedHooksPath(entry) { } async function main() { - if (process.env.CI === 'true') return + if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) if (probe.status !== 0) return const root = stripGitLineTerminator(probe.stdout) diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index defd7106ce..1b75385523 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -119,6 +119,7 @@ function createFixture(names: { main?: string; linked?: string } = {}): Fixture const env: NodeJS.ProcessEnv = { ...process.env, CI: 'false', + GITHUB_ACTIONS: 'false', GIT_AUTHOR_EMAIL: 'hooks@example.test', GIT_AUTHOR_NAME: 'Hooks Test', GIT_COMMITTER_EMAIL: 'hooks@example.test', @@ -188,25 +189,30 @@ function runInstaller( } describe('worktree-local Lefthook installer', () => { - it('skips hook installation when CI is true', async () => { - const fixture = createFixture() - const common = commonDirectory(fixture) - const missingInclude = join(fixture.container, 'missing-ci-credentials.gitconfig') - git(fixture, fixture.main, [ - 'config', - '--local', - 'includeIf.gitdir:/github/workspace/.git.path', - missingInclude, - ]) + for (const [label, extraEnv] of [ + ['CI', { CI: 'true' }], + ['GitHub Actions', { GITHUB_ACTIONS: 'true' }], + ] satisfies [string, NodeJS.ProcessEnv][]) { + it(`skips hook installation when ${label} marks an automated job`, async () => { + const fixture = createFixture() + const common = commonDirectory(fixture) + const missingInclude = join(fixture.container, 'missing-ci-credentials.gitconfig') + git(fixture, fixture.main, [ + 'config', + '--local', + 'includeIf.gitdir:/github/workspace/.git.path', + missingInclude, + ]) - const result = await runInstaller(fixture, fixture.main, { CI: 'true' }) + const result = await runInstaller(fixture, fixture.main, extraEnv) - expect(result.status, result.stderr).toBe(0) - expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) - expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0') - expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) - expect(existsSync(join(common, 'config.worktree'))).toBe(false) - }) + expect(result.status, result.stderr).toBe(0) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0') + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + expect(existsSync(join(common, 'config.worktree'))).toBe(false) + }) + } it('isolates main and linked worktrees without changing legacy common hooks', async () => { const fixture = createFixture() From 81b56a0d97aa3eaaf5367e2c4691618036b4aeea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:07:12 +0800 Subject: [PATCH 10/13] test(dev-infra): refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index cb25d0f061..25a52b2d11 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.\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`; the wrapper script uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\npnpm exec lefthook install --force\n```\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 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 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. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command.\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。\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。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\npnpm exec lefthook install --force\n```\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`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 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" }, { "role": "user", From f7729b6f53363d2ee6c30b94df93aacfc134f443 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:54:44 +0800 Subject: [PATCH 11/13] fix(dev-infra): harden worktree hook ownership --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 4 +- .../2026-07-27-worktree-local-lefthook.md | 8 +- .../2026-07-27-worktree-local-lefthook.zh.md | 8 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/install-lefthook.mjs | 189 +++++++++++++++--- scripts/install-lefthook.spec.ts | 168 +++++++++++++++- .../request-response.expected.json | 4 +- 9 files changed, 342 insertions(+), 47 deletions(-) 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 c7e7827c5b..abd332a610 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: f35fe4a91063bca6f29d61932e414d7d4843d2f0 -2026-07-27-worktree-local-lefthook.zh.md: c82e81f5a96f122c961234174fd123259f92ab9e +2026-07-27-worktree-local-lefthook.md: 9d5e5583d6d8e3f15433f8b1a6f26f7b2c8cd854 +2026-07-27-worktree-local-lefthook.zh.md: dfca287ae3bbfc1414322d89da9cd93f78230d3c 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 f35fe4a910..9d5e5583d6 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 first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. 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`. 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. -The installer recognizes its hook directory with a private ownership marker and updates it idempotently. 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. +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. -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. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. +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. -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises the CI no-op, main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, dormant sibling-config refusal, 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 failed-install rollback. +[`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. ## Alternatives considered 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 c82e81f5a9..dfca287ae3 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` 的绝对路径。首次启用这一仓库级扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中尚未生效的 `config.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` 的绝对路径。提升格式版本之前,若共用配置或条件目标中存在任何 `extensions.*` 键,安装程序都会拒绝继续,因为格式 1 会激活这类尚未生效的仓库扩展。首次启用 worktree 配置扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中的 `config.worktree` 文件,并拒绝一经激活就会改变当前或其他 worktree 的尚未生效设置。共用仓库配置以及每个生效或尚未生效的 worktree 配置都必须是常规文件。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 -安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。它会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 +安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。该标记会记录上次写入 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 配置。这项显式选择不会尝试串联任意钩子管理器。 -启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` 或 `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。 +启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` 或 `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。若回滚也失败,同一条诊断会保留两次失败,供手动恢复。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。 -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖 CI 下不执行操作的行为、主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、拒绝激活其他 worktree 中尚未生效的配置、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。 +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖 CI 下不执行操作的行为、主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、检出目录移动、拒绝标记和钩子别名、陈旧锁与锁所有权被替换、Git 版本边界、拒绝尚未生效的仓库扩展和其他 worktree 配置、共用及 worktree 配置文件的所有权、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败后成功或失败的回滚。 ## 考虑过的替代方案 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index d67eb0595d..8d7d2a2a1e 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: dfe99362aa9b881645c69b2bab74180280b4f1b3 -development.zh.md: 10d9129b288d1540b27a9ddc94f1f4acdd3f5f9e +development.md: f1a853acfd1e89104b8013a5b5e9c4032979234f +development.zh.md: 493284e38ad68768b1159778d5dd50aecfe9ccd1 diff --git a/docs/development.md b/docs/development.md index dfe99362aa..f1a853acfd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,7 @@ 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 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. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. +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. Run typecheck once after a fresh clone: diff --git a/docs/development.zh.md b/docs/development.zh.md index 10d9129b28..493284e38a 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -27,7 +27,7 @@ pnpm install node scripts/install-lefthook.mjs ``` -包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。 +包装脚本拒绝替换现有且由用户自行管理的 `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 配置并手动移除新路径,再重试。 新克隆后请先运行一次类型检查: diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 275d322179..04ea62837f 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -7,12 +7,15 @@ import { dirname, isAbsolute, join, resolve } from 'node:path' const MINIMUM_GIT = [2, 26, 0] const HOOKS_DIRECTORY = 'dsh-hooks' const OWNERSHIP_MARKER = '.dsh-lefthook-owned' -const OWNERSHIP_MARKER_CONTENT = 'deepseek-harness worktree-local lefthook hooks\n' +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) { return typeof error === 'object' && error !== null && 'code' in error @@ -177,17 +180,37 @@ function registeredWorktreeConfigPaths(commonDirectory) { return paths } -function assertDormantWorktreeConfigs(root, commonDirectory, commonConfigPath, currentConfigPath) { - if (worktreeConfigExtensionEnabled(root, commonConfigPath)) return +function lstatIfPresent(path) { + try { + return lstatSync(path) + } catch (error) { + if (errorCode(error) === 'ENOENT') return undefined + throw error + } +} + +function assertCommonConfigFile(commonConfigPath) { + const configStat = lstatIfPresent(commonConfigPath) + if (configStat === undefined || !configStat.isFile() || configStat.isSymbolicLink()) { + throw new Error( + `refusing common repository config ${JSON.stringify(commonConfigPath)} because it is not a regular file`, + ) + } +} + +function assertWorktreeConfigFiles(root, commonDirectory, commonConfigPath, currentConfigPath) { + const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath) for (const configPath of registeredWorktreeConfigPaths(commonDirectory)) { - if (!existsSync(configPath)) continue - const configStat = lstatSync(configPath) + const configStat = lstatIfPresent(configPath) + if (configStat === undefined) continue if (!configStat.isFile() || configStat.isSymbolicLink()) { + const state = extensionEnabled ? 'active' : 'dormant' throw new Error( - `cannot enable extensions.worktreeConfig while dormant worktree config ${JSON.stringify(configPath)} ` - + 'is not a regular file; inspect it and enable the extension explicitly, or remove it, before retrying', + `refusing ${state} worktree config ${JSON.stringify(configPath)} because it is not a regular file; ` + + 'replace it with a regular worktree config or remove it before retrying', ) } + if (extensionEnabled) continue if (!hasDirectConfigEntries(root, configPath)) continue const isCurrent = normalizedPath(configPath) === normalizedPath(currentConfigPath) const owner = isCurrent ? 'current' : 'sibling' @@ -259,7 +282,13 @@ function conditionalIncludeRisk(root, entry, inspect) { return inspectConditionalConfig(root, target, inspect) } -function migrationConfigSubject(root, configPath) { +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') @@ -272,7 +301,7 @@ function hooksPathConfigSubject(root, configPath) { return entry === undefined ? undefined : `core.hooksPath (${configSource(entry)})` } -function ensureWorktreeConfig(root, commonConfigPath) { +function planWorktreeConfigMigration(root, commonConfigPath) { const versions = fileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion') const versionText = assertSingle(versions, 'core.repositoryFormatVersion') const version = Number(versionText) @@ -280,6 +309,21 @@ function ensureWorktreeConfig(root, commonConfigPath) { throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`) } + if (version === 0) { + const extensionEntry = fileConfigMatchingEntries( + root, + commonConfigPath, + REPOSITORY_EXTENSION_PATTERN, + )[0] + if (extensionEntry !== undefined) { + throw new Error( + `cannot upgrade core.repositoryFormatVersion from 0 while dormant repository extension ` + + `${extensionEntry.name} is configured (${configSource(extensionEntry)}); ` + + 'audit and migrate it, then set repository format 1 explicitly before retrying', + ) + } + } + const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath) if (!extensionEnabled) { @@ -287,7 +331,7 @@ function ensureWorktreeConfig(root, commonConfigPath) { const risk = conditionalIncludeRisk( root, entry, - configPath => migrationConfigSubject(root, configPath), + configPath => migrationConfigSubject(root, configPath, version === 0), ) if (risk !== undefined) { const reason = risk.subject ?? risk.detail @@ -318,6 +362,11 @@ function ensureWorktreeConfig(root, commonConfigPath) { const directBareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare') + return { directBare, extensionEnabled, version } +} + +function applyWorktreeConfigMigration(root, commonConfigPath, migration) { + const { directBare, extensionEnabled, version } = migration if (version === 0) { git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root) } @@ -430,13 +479,38 @@ async function acquireInstallLock(commonDirectory) { } } -function ensureOwnedHooksDirectory(hooksPath) { - const markerPath = join(hooksPath, OWNERSHIP_MARKER) - if (!existsSync(hooksPath)) { - mkdirSync(hooksPath, { mode: 0o700 }) - writeFileSync(markerPath, OWNERSHIP_MARKER_CONTENT, { flag: 'wx', mode: 0o600 }) - return +function ownershipMarkerContent(hooksPath) { + return `${JSON.stringify({ + version: OWNERSHIP_MARKER_VERSION, + owner: OWNERSHIP_MARKER_OWNER, + hooksPath, + })}\n` +} + +function parseOwnershipMarker(content, hooksPath) { + if (content === LEGACY_OWNERSHIP_MARKER_CONTENT) return { hooksPath, legacy: true } + let parsed + try { + parsed = JSON.parse(content) + } catch { + return undefined } + if ( + typeof parsed !== 'object' + || parsed === null + || parsed.version !== OWNERSHIP_MARKER_VERSION + || parsed.owner !== OWNERSHIP_MARKER_OWNER + || typeof parsed.hooksPath !== 'string' + || !isAbsolute(parsed.hooksPath) + ) { + return undefined + } + return { hooksPath: parsed.hooksPath, legacy: false } +} + +function inspectOwnedHooksDirectory(hooksPath) { + const markerPath = join(hooksPath, OWNERSHIP_MARKER) + if (!existsSync(hooksPath)) return undefined const hooksStat = lstatSync(hooksPath) if (!hooksStat.isDirectory() || hooksStat.isSymbolicLink()) { throw new Error(`refusing to use non-directory or symlinked hooks path ${hooksPath}`) @@ -445,9 +519,36 @@ function ensureOwnedHooksDirectory(hooksPath) { throw new Error(`refusing to overwrite unowned hooks directory ${hooksPath}`) } const markerStat = lstatSync(markerPath) - if (!markerStat.isFile() || markerStat.isSymbolicLink() || readFileSync(markerPath, 'utf8') !== OWNERSHIP_MARKER_CONTENT) { + const marker = markerStat.isFile() && !markerStat.isSymbolicLink() && markerStat.nlink === 1 + ? parseOwnershipMarker(readFileSync(markerPath, 'utf8'), hooksPath) + : undefined + if (marker === undefined) { throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`) } + for (const name of readdirSync(hooksPath)) { + if (name === OWNERSHIP_MARKER) continue + const entryPath = join(hooksPath, name) + const entryStat = lstatSync(entryPath) + if (!entryStat.isFile() || entryStat.isSymbolicLink() || entryStat.nlink !== 1) { + throw new Error( + `refusing to overwrite non-regular or multiply linked hook entry ${JSON.stringify(entryPath)}`, + ) + } + } + return { markerPath, ...marker } +} + +function ensureOwnedHooksDirectory(hooksPath) { + const inspected = inspectOwnedHooksDirectory(hooksPath) + if (inspected !== undefined) return inspected + mkdirSync(hooksPath, { mode: 0o700 }) + const markerPath = join(hooksPath, OWNERSHIP_MARKER) + writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { flag: 'wx', mode: 0o600 }) + return { markerPath, hooksPath, legacy: false } +} + +function updateOwnershipMarker(markerPath, hooksPath) { + writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { mode: 0o600 }) } function environmentWithoutCommandGitConfig() { @@ -588,6 +689,13 @@ async function main() { let installationError try { + assertCommonConfigFile(commonConfigPath) + assertWorktreeConfigFiles( + root, + commonDirectory, + commonConfigPath, + worktreeConfigPath, + ) const worktreeEntries = fileConfigEntries(root, worktreeConfigPath, 'core.hooksPath') const includedWorktreeEntry = worktreeEntries.find( entry => !originIsFile(entry.origin, root, worktreeConfigPath), @@ -599,14 +707,20 @@ async function main() { worktreeEntries.map(entry => entry.value), 'worktree core.hooksPath', ) + let ownedHooksDirectory if (worktreePath !== undefined && worktreePath !== hooksPath) { - refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath }) + ownedHooksDirectory = inspectOwnedHooksDirectory(hooksPath) + if (ownedHooksDirectory === undefined || ownedHooksDirectory.hooksPath !== worktreePath) { + refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath }) + } } + const directWorktreePathIsOwned = worktreePath !== undefined + && (worktreePath === hooksPath || ownedHooksDirectory?.hooksPath === worktreePath) const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath') if (effectiveEntry !== undefined) { const effectivePathIsOwned = effectiveEntry.scope === 'worktree' - && effectiveEntry.value === hooksPath - && worktreePath === hooksPath + && effectiveEntry.value === worktreePath + && directWorktreePathIsOwned && originIsFile(effectiveEntry.origin, root, worktreeConfigPath) if (!effectivePathIsOwned) { if (effectiveEntry.scope === 'command' || effectiveEntry.scope === 'worktree') { @@ -622,19 +736,21 @@ async function main() { } assertConditionalHooksPaths(root, worktreeConfigPath) - assertDormantWorktreeConfigs( - root, - commonDirectory, - commonConfigPath, - worktreeConfigPath, - ) - ensureOwnedHooksDirectory(hooksPath) - ensureWorktreeConfig(root, commonConfigPath) + const migration = planWorktreeConfigMigration(root, commonConfigPath) + ownedHooksDirectory = ensureOwnedHooksDirectory(hooksPath) + if ( + worktreePath !== undefined + && worktreePath !== hooksPath + && ownedHooksDirectory.hooksPath !== worktreePath + ) { + throw new Error(`hooks directory ownership changed while relocating ${JSON.stringify(worktreePath)}`) + } + applyWorktreeConfigMigration(root, commonConfigPath, migration) let pathChanged = false try { git(['config', '--worktree', 'core.hooksPath', hooksPath], root) - pathChanged = worktreePath === undefined + pathChanged = worktreePath !== hooksPath const installedEntry = effectiveConfigEntry(root, 'core.hooksPath') if ( installedEntry === undefined @@ -645,9 +761,22 @@ async function main() { throw new Error('new worktree-local core.hooksPath did not become the effective direct worktree value') } runLefthook(root, lefthook) + updateOwnershipMarker(ownedHooksDirectory.markerPath, hooksPath) } catch (error) { if (pathChanged) { - git(['config', '--worktree', '--unset-all', 'core.hooksPath'], root) + try { + if (worktreePath === undefined) { + git(['config', '--worktree', '--unset-all', 'core.hooksPath'], root) + } else { + git(['config', '--worktree', 'core.hooksPath', worktreePath], root) + } + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `Lefthook installation failed: ${String(error)}; ` + + `worktree hook rollback also failed: ${String(rollbackError)}`, + ) + } } throw error } diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 1b75385523..0ce0bde993 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -2,10 +2,14 @@ import { spawn, spawnSync } from 'node:child_process' import { chmodSync, existsSync, + linkSync, mkdirSync, mkdtempSync, + lstatSync, readFileSync, + renameSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -91,6 +95,10 @@ if (!shouldFail) { for (const name of ['pre-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 }) } if (existsSync(running)) unlinkSync(running) +if (process.env.DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG === '1') { + const configPath = execFileSync('git', ['rev-parse', '--git-path', 'config.worktree'], { encoding: 'utf8' }).trim() + writeFileSync(configPath, '[invalid\\n') +} if (shouldFail) process.exit(77) ` } @@ -275,6 +283,125 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false) }) + it('repairs its owned absolute hook path after the checkout moves', async () => { + const fixture = createFixture() + const oldRoot = fixture.main + const first = await runInstaller(fixture, oldRoot) + expect(first.status, first.stderr).toBe(0) + const oldHooks = hooksPath(fixture, oldRoot) + const movedRoot = join(fixture.container, 'moved-main') + renameSync(oldRoot, movedRoot) + + const moved = await runInstaller(fixture, movedRoot) + + expect(moved.status, moved.stderr).toBe(0) + const movedHooks = hooksPath(fixture, movedRoot) + expect(movedHooks).not.toBe(oldHooks) + expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(movedHooks) + const canonicalMoved = git(fixture, movedRoot, ['rev-parse', '--show-toplevel']) + expect(readFileSync(join(movedHooks, 'pre-commit'), 'utf8')).toContain(`# root=${canonicalMoved}`) + expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain( + JSON.stringify(movedHooks), + ) + }) + + it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => { + const fixture = createFixture() + const oldRoot = fixture.main + const first = await runInstaller(fixture, oldRoot) + expect(first.status, first.stderr).toBe(0) + const oldHooks = hooksPath(fixture, oldRoot) + const markerName = '.dsh-lefthook-owned' + const externalMarker = join(fixture.container, 'external-marker') + linkSync(join(oldHooks, markerName), externalMarker) + const externalContent = readFileSync(externalMarker, 'utf8') + const movedRoot = join(fixture.container, 'moved-main') + renameSync(oldRoot, movedRoot) + + const result = await runInstaller(fixture, movedRoot) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('invalid ownership marker') + expect(readFileSync(externalMarker, 'utf8')).toBe(externalContent) + }) + + it.skipIf(process.platform === 'win32')('refuses aliased generated hooks before Lefthook can overwrite their targets', async () => { + for (const kind of ['symlink', 'hardlink'] as const) { + const fixture = createFixture() + const first = await runInstaller(fixture, fixture.main) + expect(first.status, first.stderr).toBe(0) + const hook = join(hooksPath(fixture, fixture.main), 'pre-commit') + const externalHook = join(fixture.container, `${kind}-external-hook`) + rmSync(hook) + write(externalHook, `external ${kind} target\n`) + if (kind === 'symlink') symlinkSync(externalHook, hook) + else linkSync(externalHook, hook) + const externalContent = readFileSync(externalHook, 'utf8') + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('non-regular or multiply linked hook entry') + expect(readFileSync(externalHook, 'utf8')).toBe(externalContent) + } + }) + + it('restores the marker-backed stale hook path when relocation reinstall fails', async () => { + const fixture = createFixture() + const oldRoot = fixture.main + const first = await runInstaller(fixture, oldRoot) + expect(first.status, first.stderr).toBe(0) + const oldHooks = hooksPath(fixture, oldRoot) + const markerName = '.dsh-lefthook-owned' + const previousMarker = readFileSync(join(oldHooks, markerName), 'utf8') + const movedRoot = join(fixture.container, 'moved-main') + renameSync(oldRoot, movedRoot) + + const failed = await runInstaller(fixture, movedRoot, { DSH_TEST_LEFTHOOK_FAIL: '1' }) + + expect(failed.status).toBe(1) + expect(failed.stderr).toContain('exit status 77') + const movedHooks = hooksPath(fixture, movedRoot) + expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(oldHooks) + expect(readFileSync(join(movedHooks, markerName), 'utf8')).toBe(previousMarker) + }) + + it('refuses dormant repository extensions before upgrading the repository format', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + git(fixture, fixture.main, ['config', 'extensions.dshUnknown', 'true']) + expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0) + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('dormant repository extension extensions.dshunknown') + expect(git(fixture, fixture.main, [ + 'config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion', + ])).toBe('0') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0) + 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') + const externalConfig = join(fixture.container, 'external-common.gitconfig') + renameSync(commonConfig, externalConfig) + symlinkSync(externalConfig, commonConfig) + const externalContent = readFileSync(externalConfig, 'utf8') + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('common repository config') + expect(result.stderr).toContain('not a regular file') + expect(lstatSync(commonConfig).isSymbolicLink()).toBe(true) + expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + it('leaves stale installer locks for explicit recovery', async () => { const fixture = createFixture() const lockPath = installLockPath(fixture) @@ -387,9 +514,33 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) }) + it.skipIf(process.platform === 'win32')('refuses an active symlinked worktree config before writing through it', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree') + const externalConfig = join(fixture.container, 'external.gitconfig') + const externalContent = '[user]\n\tname = External owner\n' + write(externalConfig, externalContent) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1']) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true']) + symlinkSync(externalConfig, worktreeConfig) + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('active worktree config') + expect(result.stderr).toContain('not a regular file') + expect(lstatSync(worktreeConfig).isSymbolicLink()).toBe(true) + expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent) + expect(gitResult(fixture, fixture.main, [ + 'config', '--file', externalConfig, '--get', 'core.hooksPath', + ]).status).toBe(1) + 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']) { + for (const key of ['core.worktree', 'core.bare', 'extensions.dshunknown']) { const fixture = createFixture() const commonConfig = join(commonDirectory(fixture), 'config') const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`) @@ -597,6 +748,21 @@ describe('worktree-local Lefthook installer', () => { expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n') }) + it('reports installation and hook-path rollback failures together', async () => { + const fixture = createFixture() + + const result = await runInstaller(fixture, fixture.main, { + DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG: '1', + DSH_TEST_LEFTHOOK_FAIL: '1', + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Lefthook installation failed') + expect(result.stderr).toContain('exit status 77') + expect(result.stderr).toContain('worktree hook rollback also failed') + expect(result.stderr).toContain('git config --worktree --unset-all core.hooksPath failed') + }) + it('refuses an unowned directory at the reserved worktree hook path', async () => { const fixture = createFixture() const reservedHook = join(hooksPath(fixture, fixture.main), 'pre-commit') diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 25a52b2d11..9903a7ffdc 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 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. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command.\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 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" }, { "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` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 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`。若要让继承自系统、全局或共用仓库配置的路径在其他 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" }, { "role": "user", From 7acb4332eb6e7f2f0c2b2ae6a02d41a7a7244cb5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:55:10 +0800 Subject: [PATCH 12/13] fix(dev-infra): keep change scope probes inert --- ...-27-explicit-change-scope-report.i18n.yaml | 4 +- ...2026-07-27-explicit-change-scope-report.md | 6 +- ...6-07-27-explicit-change-scope-report.zh.md | 6 +- scripts/change-scope.spec.ts | 67 ++++++++++++++++++- scripts/change-scope.ts | 49 +++++++++----- 5 files changed, 103 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml index 05c5c3ab35..1c39083a6e 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.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-explicit-change-scope-report.md -2026-07-27-explicit-change-scope-report.md: aa6398368d5f161ed852aa92c1d9d35dd2564a5e -2026-07-27-explicit-change-scope-report.zh.md: 042258122d43e5da81cd470e1cf5af09dfeb2fba +2026-07-27-explicit-change-scope-report.md: 2cce567940a142ed1f4699f4dc67322ed69565e9 +2026-07-27-explicit-change-scope-report.zh.md: cc08df3ee4f3d681bc4ae8b2b0eab3588dfe1a73 diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md index aa6398368d..2cce567940 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md @@ -12,13 +12,13 @@ An incorrect range undermines evidence selection because it can omit affected pa ## Decision -The root `change-scope` command requires `--base `, accepts `--head ` with `HEAD` as the default, and offers a versioned `--json` form. It resolves both inputs to commits with ambiguity detection and requires one merge base before writing output. The report records the repository root without normalizing legal path whitespace, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. Path records are split at raw NUL bytes and decoded as strict UTF-8; an invalid record aborts the report before output instead of substituting characters or collapsing distinct paths. +The root `change-scope` command requires `--base `, accepts `--head ` with `HEAD` as the default, and offers a versioned `--json` form. It resolves both inputs to commits with ambiguity detection and requires one merge base before writing output. The report records the repository root without normalizing legal path whitespace, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. Path records are split at raw NUL bytes; the repository root, branch, upstream, and every path are decoded as strict UTF-8. An invalid value aborts the report before output instead of substituting characters or collapsing distinct values. -Committed paths compare the resolved merge base with the resolved head. Dirty path sets always describe the current worktree and index, even when `--head` names another commit. Diff configuration cannot hide submodules or invoke external diff or text-conversion drivers, and rename detection is disabled so both sides of a rename remain visible. +Committed paths compare the resolved merge base with the resolved head. Dirty path sets always describe the current worktree and index, even when `--head` names another commit. Every Git probe disables configured filesystem monitors and optional lock-taking; diff configuration cannot hide submodules or invoke external diff or text-conversion drivers, and rename detection is disabled so both sides of a rename remain visible. The command never guesses or fetches a base, queries a hosting provider, or selects tests. Each calling workflow verifies current remote or stack state, supplies the base explicitly, and uses the factual report as input to semantic review or evidence selection. -Focused temporary-repository tests cover a fresh branch tracking `origin/master` without a same-name remote, its post-push upstream, a worktree path ending in legal whitespace, a stacked non-master base, every dirty layer, distinct non-UTF-8 POSIX paths failing without partial output, invalid, ambiguous, and non-commit refs, deterministic human/JSON parity, and unchanged refs, index, config, and status after reporting. +Focused temporary-repository tests cover a fresh branch tracking `origin/master` without a same-name remote, its post-push upstream, a worktree path ending in legal whitespace, a stacked non-master base, every dirty layer, a configured filesystem monitor remaining unexecuted, distinct non-UTF-8 POSIX paths and branch or upstream names failing without partial output, invalid, ambiguous, and non-commit refs, deterministic human/JSON parity, and unchanged refs, index, config, and status after reporting. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md index 042258122d..cc08df3ee4 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -根目录的 `change-scope` 命令要求提供 `--base `,接受可选的 `--head `(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会输出结果。报告记录仓库根目录(不对路径中的合法空白字符作规范化处理)、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。路径记录先按原始 NUL 字节切分,再逐条以严格 UTF-8 解码;遇到无效记录时,命令会在写出任何结果前失败,不会用替换字符代替无效字节或把不同路径合并为一条。 +根目录的 `change-scope` 命令要求提供 `--base `,接受可选的 `--head `(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会输出结果。报告记录仓库根目录(不对路径中的合法空白字符作规范化处理)、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。路径记录先按原始 NUL 字节切分;仓库根目录、分支、上游和每条路径都以严格 UTF-8 解码。遇到无效值时,命令会在写出任何结果前失败,不会用替换字符代替无效字节或把不同值合并为一条。 -已提交路径由解析后的合并基点与头部之间的比较得出。即使 `--head` 指定其他 commit,各类未提交路径集合仍始终描述当前 worktree 与索引。diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。 +已提交路径由解析后的合并基点与头部之间的比较得出。即使 `--head` 指定其他 commit,各类未提交路径集合仍始终描述当前 worktree 与索引。每次 Git 探测都会禁用配置的文件系统监视器和可选加锁;diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。 该命令从不猜测或获取基准,不查询代码托管提供方,也不选择测试。调用该命令的每个工作流都会验证当前远端或堆叠状态、显式提供基准,并将这份事实报告作为语义评审或证据选择的输入。 -聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;以合法空白字符结尾的 worktree 路径;堆叠分支以非 master 分支为基准;所有未提交改动层;两个互异的非 UTF-8 POSIX 路径会使报告失败且不产生部分输出;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。 +聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;以合法空白字符结尾的 worktree 路径;堆叠分支以非 master 分支为基准;所有未提交改动层;配置的文件系统监视器不会执行;互异的非 UTF-8 POSIX 路径、分支名或上游名会使报告失败且不产生部分输出;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。 ## 考虑过的替代方案 diff --git a/scripts/change-scope.spec.ts b/scripts/change-scope.spec.ts index db3a80dae7..6f1493d401 100644 --- a/scripts/change-scope.spec.ts +++ b/scripts/change-scope.spec.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -43,9 +43,9 @@ function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer { }) } -function write(path: string, content: string): void { +function write(path: string, content: string, mode?: number): void { mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, content) + writeFileSync(path, content, mode === undefined ? undefined : { mode }) } function fixture(worktreeName = 'worktree'): Fixture { @@ -209,6 +209,67 @@ describe('change-scope', () => { expect(repositoryState(root)).toEqual(before) }) + it.skipIf(process.platform === 'win32')('does not execute a configured filesystem monitor', () => { + const { container, root } = fixture() + const monitor = join(container, 'fsmonitor.sh') + const sideEffect = `${monitor}.ran` + write(monitor, '#!/bin/sh\ntouch "$0.ran"\n', 0o755) + git(root, ['config', 'core.fsmonitor', monitor]) + + const report = jsonReport(root, 'HEAD') + + expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] }) + expect(existsSync(sideEffect)).toBe(false) + }) + + it.skipIf(process.platform === 'win32')('rejects non-UTF-8 branch and upstream names without partial output', () => { + const invalidBranch = fixture() + const branchHead = git(invalidBranch.root, ['rev-parse', 'HEAD']) + const invalidBranchName = Buffer.from([0x80]) + writeFileSync(join(invalidBranch.root, '.git/packed-refs'), Buffer.concat([ + Buffer.from(`${branchHead} refs/heads/`), + invalidBranchName, + Buffer.from('\n'), + ])) + writeFileSync( + join(invalidBranch.root, '.git/HEAD'), + Buffer.concat([Buffer.from('ref: refs/heads/'), invalidBranchName, Buffer.from('\n')]), + ) + const branchOutput: string[] = [] + + expect(() => { + writeChangeScope(['--base', branchHead, '--json'], invalidBranch.root, chunk => branchOutput.push(chunk)) + }).toThrow('cannot inspect the current branch: Git stdout is not valid UTF-8') + expect(branchOutput).toEqual([]) + + const invalidUpstream = fixture() + const upstreamHead = git(invalidUpstream.root, ['rev-parse', 'HEAD']) + const invalidUpstreamName = Buffer.from([0x81]) + writeFileSync(join(invalidUpstream.root, '.git/packed-refs'), Buffer.concat([ + Buffer.from(`${upstreamHead} refs/remotes/origin/`), + invalidUpstreamName, + Buffer.from('\n'), + ])) + const configPath = join(invalidUpstream.root, '.git/config') + const config = readFileSync(configPath) + const merge = Buffer.from('\tmerge = refs/heads/master\n') + const mergeIndex = config.indexOf(merge) + expect(mergeIndex).toBeGreaterThanOrEqual(0) + writeFileSync(configPath, Buffer.concat([ + config.subarray(0, mergeIndex), + Buffer.from('\tmerge = refs/heads/'), + invalidUpstreamName, + Buffer.from('\n'), + config.subarray(mergeIndex + merge.length), + ])) + const upstreamOutput: string[] = [] + + expect(() => { + writeChangeScope(['--base', upstreamHead, '--json'], invalidUpstream.root, chunk => upstreamOutput.push(chunk)) + }).toThrow('cannot inspect the configured upstream: Git stdout is not valid UTF-8') + expect(upstreamOutput).toEqual([]) + }) + it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths without partial output', () => { const { root } = fixture() const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content') diff --git a/scripts/change-scope.ts b/scripts/change-scope.ts index fdd1c8566d..e588f77284 100644 --- a/scripts/change-scope.ts +++ b/scripts/change-scope.ts @@ -53,10 +53,19 @@ interface ChangeScopeOptions { json: boolean } -function executeGit(cwd: string, args: string[]): GitCommandResult { - const result = spawnSync('git', ['-C', cwd, ...args], { - encoding: 'utf8', - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, +function executeGit(cwd: string, args: string[], context: string): GitCommandResult { + const result = executeGitBytes(cwd, args) + return { + status: result.status, + stdout: decodeGitText(result.stdout, context, 'stdout'), + stderr: decodeGitText(result.stderr, context, 'stderr'), + error: result.error, + } +} + +function executeGitBytes(cwd: string, args: string[]): GitBytesCommandResult { + const result = spawnSync('git', ['-C', cwd, '-c', 'core.fsmonitor=false', ...args], { + env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LANG: 'C', LC_ALL: 'C' }, maxBuffer: MAX_GIT_OUTPUT, }) return { @@ -67,16 +76,11 @@ function executeGit(cwd: string, args: string[]): GitCommandResult { } } -function executeGitBytes(cwd: string, args: string[]): GitBytesCommandResult { - const result = spawnSync('git', ['-C', cwd, ...args], { - env: { ...process.env, LANG: 'C', LC_ALL: 'C' }, - maxBuffer: MAX_GIT_OUTPUT, - }) - return { - status: result.status, - stdout: result.stdout, - stderr: result.stderr, - error: result.error, +function decodeGitText(output: Buffer, context: string, stream: 'stdout' | 'stderr'): string { + try { + return UTF8_DECODER.decode(output) + } catch { + throw new Error(`${context}: Git ${stream} is not valid UTF-8`) } } @@ -85,7 +89,7 @@ function failureDetail(result: GitCommandResult): string { } function requireGit(cwd: string, args: string[], context: string): string { - const result = executeGit(cwd, args) + const result = executeGit(cwd, args, context) if (result.status !== 0) throw new Error(`${context}: ${failureDetail(result)}`) return result.stdout } @@ -116,6 +120,7 @@ function parseOptions(args: string[]): ChangeScopeOptions { } function resolveCommit(root: string, label: 'base' | 'head', ref: string): string { + const context = `cannot resolve ${label} ref ${JSON.stringify(ref)}` const result = executeGit(root, [ '-c', 'core.warnAmbiguousRefs=true', @@ -123,7 +128,7 @@ function resolveCommit(root: string, label: 'base' | 'head', ref: string): strin '--verify', '--end-of-options', `${ref}^{commit}`, - ]) + ], context) if (/\bambiguous\b/iu.test(result.stderr)) { throw new Error(`${label} ref ${JSON.stringify(ref)} is ambiguous; use a fully qualified ref or commit ID`) } @@ -138,7 +143,11 @@ function resolveCommit(root: string, label: 'base' | 'head', ref: string): strin } function resolveMergeBase(root: string, baseSha: string, headSha: string): string { - const result = executeGit(root, ['merge-base', '--all', baseSha, headSha]) + const result = executeGit( + root, + ['merge-base', '--all', baseSha, headSha], + 'cannot resolve the merge base', + ) if (result.status !== 0) { throw new Error(`base and head do not have a merge base: ${failureDetail(result)}`) } @@ -150,7 +159,11 @@ function resolveMergeBase(root: string, baseSha: string, headSha: string): strin } function currentBranch(root: string): string | null { - const result = executeGit(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']) + const result = executeGit( + root, + ['symbolic-ref', '--quiet', '--short', 'HEAD'], + 'cannot inspect the current branch', + ) if (result.status === 1) return null if (result.status !== 0) throw new Error(`cannot inspect the current branch: ${failureDetail(result)}`) return stripGitLineTerminator(result.stdout) From c982cf780586075d4d1bdfa06602c2a840248dec Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:05:31 +0800 Subject: [PATCH 13/13] refactor(dev-infra): narrow worktree hook safety checks --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 4 +- .../2026-07-27-worktree-local-lefthook.md | 10 +- .../2026-07-27-worktree-local-lefthook.zh.md | 10 +- docs/development.i18n.yaml | 4 +- docs/development.md | 6 +- docs/development.zh.md | 6 +- scripts/install-lefthook.mjs | 221 +++--------------- scripts/install-lefthook.spec.ts | 145 +++--------- .../request-response.expected.json | 4 +- 9 files changed, 93 insertions(+), 317 deletions(-) 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",