From 49ea44d8a5b7ab1bd21579c8ef481057e272f747 Mon Sep 17 00:00:00 2001 From: DJJ Date: Tue, 25 Aug 2026 00:04:07 +0800 Subject: [PATCH 1/2] fix(hooks): make startup non-interactive and repo-scoped --- README.md | 2 +- README.zh-CN.md | 2 +- cli/src/lib/fs.ts | 34 ++++++++++++++++++-- cli/tests/cli.test.ts | 17 ++++++++++ hooks/hooks.json | 8 ++--- llmdoc/architecture.mdx | 2 +- llmdoc/cli-runtime/state-and-validation.mdx | 6 ++++ llmdoc/plugin-packaging/claude-and-codex.mdx | 4 +-- scripts/check-codex-surface.mjs | 6 ++-- tests/parity-checklist.md | 2 +- 10 files changed, 68 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 134ff9b..2724517 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ npm install --save-dev @tokenroll/llmdoc V3 assumes the CLI is always present. Navigation, search, validation, delta detection, hook signals, and workflow entrypoints come from `npx @tokenroll/llmdoc`. -> Always invoke with the full scoped name `npx @tokenroll/llmdoc ` — never a bare `npx llmdoc`, which would resolve to an unrelated third-party npm package in environments where this package is not installed. With the scoped name there is no wrong-package risk: npx runs the locally installed copy, or fetches the correct package if missing. Hooks use `npx --no-install @tokenroll/llmdoc` so they stay offline-safe and fail open. +> Always invoke with the full scoped name `npx @tokenroll/llmdoc ` — never a bare `npx llmdoc`, which would resolve to an unrelated third-party npm package in environments where this package is not installed. With the scoped name there is no wrong-package risk: npx runs the locally installed copy, or fetches the correct package if missing. Hooks use `npx -y @tokenroll/llmdoc` so a missing package can be fetched without an interactive prompt. Install the CLI locally for deterministic, offline-capable runs. ## Public Surface diff --git a/README.zh-CN.md b/README.zh-CN.md index 5de5252..afdb342 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,7 +18,7 @@ npm install --save-dev @tokenroll/llmdoc V3 假定 CLI 始终存在。导航、检索、校验、delta 检测、hook 信号和工作流入口都来自 `npx @tokenroll/llmdoc`。 -> 一律使用完整 scoped 名调用:`npx @tokenroll/llmdoc `,永远不要用裸的 `npx llmdoc`——在未安装本包的环境里,裸名会解析到 npm 上一个无关的第三方包。使用 scoped 名则不存在任何错包风险:npx 会运行本地已安装副本,缺失时自动获取正确的包。hooks 使用 `npx --no-install @tokenroll/llmdoc`,保证离线安全且 fail-open。 +> 一律使用完整 scoped 名调用:`npx @tokenroll/llmdoc `,永远不要用裸的 `npx llmdoc`——在未安装本包的环境里,裸名会解析到 npm 上一个无关的第三方包。使用 scoped 名则不存在任何错包风险:npx 会运行本地已安装副本,缺失时自动获取正确的包。hooks 使用 `npx -y @tokenroll/llmdoc`,缺包时无需交互确认即可获取;若要保证版本确定且离线可用,仍应把 CLI 安装为项目本地依赖。 ## 公开接口 diff --git a/cli/src/lib/fs.ts b/cli/src/lib/fs.ts index da54cdd..8cdcdeb 100644 --- a/cli/src/lib/fs.ts +++ b/cli/src/lib/fs.ts @@ -12,10 +12,18 @@ export function findProjectRoot(startDir: string): string { } export function findProjectRootOrNull(startDir: string): string | null { - let current = path.resolve(startDir); + const start = path.resolve(startDir); + const gitRoot = findNearestGitRootOrNull(start); + if (gitRoot) { + return isDirectory(path.join(gitRoot, "llmdoc")) ? gitRoot : null; + } + + // Git 是 llmdoc 有效性与 delta 语义的基础;这里只为仓库初始化前的 + // 临时目录保留兼容回退。存在 Git 边界时绝不能越界命中同级仓库。 + let current = start; while (true) { - if (fs.existsSync(path.join(current, "llmdoc")) && fs.statSync(path.join(current, "llmdoc")).isDirectory()) { + if (isDirectory(path.join(current, "llmdoc"))) { return current; } @@ -27,6 +35,28 @@ export function findProjectRootOrNull(startDir: string): string | null { } } +function findNearestGitRootOrNull(startDir: string): string | null { + let current = startDir; + while (true) { + if (fs.existsSync(path.join(current, ".git"))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function isDirectory(candidate: string): boolean { + try { + return fs.statSync(candidate).isDirectory(); + } catch { + return false; + } +} + export function normalizeRepoRelativePath(input: string): string { const normalized = input.replaceAll("\\", "/"); if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) { diff --git a/cli/tests/cli.test.ts b/cli/tests/cli.test.ts index 06fc103..ec7b089 100644 --- a/cli/tests/cli.test.ts +++ b/cli/tests/cli.test.ts @@ -715,6 +715,23 @@ code: const compact = await runCli(["hook", "compact"], bareDir); expect(compact.exitCode).toBe(0); expect(JSON.parse(compact.stdout)).toEqual({ continue: true }); + + // 回归:不得越过当前 Git 根目录,把同级名为 llmdoc 的仓库误认为知识目录。 + const workspaceParent = fs.mkdtempSync(path.join(os.tmpdir(), "llmdoc-sibling-")); + const siblingRepository = path.join(workspaceParent, "llmdoc"); + const unrelatedRepository = path.join(workspaceParent, "website"); + fs.mkdirSync(siblingRepository); + fs.mkdirSync(path.join(unrelatedRepository, ".git"), { recursive: true }); + + const isolatedSessionStart = await runCli(["hook", "session-start"], unrelatedRepository); + expect(isolatedSessionStart.exitCode).toBe(0); + expect(isolatedSessionStart.stdout).toBe(""); + + const enabledRepository = createFixture(); + const nestedDirectory = path.join(enabledRepository, "src", "api"); + const nestedSessionStart = await runCli(["hook", "session-start"], nestedDirectory); + expect(nestedSessionStart.exitCode).toBe(0); + expect(nestedSessionStart.stdout).toContain("llmdoc yes"); }); test("new escapes hostile descriptions into valid front matter", async () => { diff --git a/hooks/hooks.json b/hooks/hooks.json index 0f5bccf..4108b86 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,12 +1,12 @@ { - "description": "V3 llmdoc hooks delegated to the installed llmdoc CLI. SessionStart may return plain text; Stop and PreCompact must return valid JSON on success.", + "description": "V3 llmdoc hooks delegated to the scoped llmdoc CLI. Missing packages are installed non-interactively; SessionStart may return plain text, while Stop and PreCompact return JSON on success.", "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", - "command": "npx --no-install @tokenroll/llmdoc hook session-start", + "command": "npx -y @tokenroll/llmdoc hook session-start", "additionalContextLimit": 1200 } ] @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "npx --no-install @tokenroll/llmdoc hook stop" + "command": "npx -y @tokenroll/llmdoc hook stop" } ] } @@ -27,7 +27,7 @@ "hooks": [ { "type": "command", - "command": "npx --no-install @tokenroll/llmdoc hook compact" + "command": "npx -y @tokenroll/llmdoc hook compact" } ] } diff --git a/llmdoc/architecture.mdx b/llmdoc/architecture.mdx index e051765..ef40065 100644 --- a/llmdoc/architecture.mdx +++ b/llmdoc/architecture.mdx @@ -27,7 +27,7 @@ code: 这个仓库把 `llmdoc` 拆成四层相互配合的能力: - `cli/` 是运行时实体,负责扫描 `llmdoc/`、校验 front matter 与 `meta.json`、做检索与影响面分析,并暴露 hook 与维护命令。 -- 仓库根的 Claude 表面负责判断性 SOP:`skills/` 下五个 SKILL.md(`llmdoc` operating skill 加 `init/update/prune/upgrade` 四个显式工作流)描述工作流,`agents/investigator.md` 与 `agents/recorder.md` 描述两个角色,`hooks/hooks.json` 只负责把生命周期事件转发到 CLI。 +- 仓库根的 Claude 表面负责判断性 SOP:`skills/` 下五个 SKILL.md(`llmdoc` operating skill 加 `init/update/prune/upgrade` 四个显式工作流)描述工作流,`agents/investigator.md` 与 `agents/recorder.md` 描述两个角色,`hooks/hooks.json` 只负责用 `npx -y @tokenroll/llmdoc hook ...` 把生命周期事件转发到 CLI。 - ACPlugin 生成的 Codex 兼容表面位于 `.agents/plugins/marketplace.json`、`.agents/skills/`、`.codex/agents/` 与 `.codex-plugin/plugin.json`,它们承接 Claude 根表面的 marketplace、命令、operating skill、角色契约与 manifest。 - `llmdoc/` 是仓库自身的 dogfood 知识面,用 V3 两层目录反过来解释这套实现本身。 diff --git a/llmdoc/cli-runtime/state-and-validation.mdx b/llmdoc/cli-runtime/state-and-validation.mdx index de5345d..74629ac 100644 --- a/llmdoc/cli-runtime/state-and-validation.mdx +++ b/llmdoc/cli-runtime/state-and-validation.mdx @@ -3,6 +3,7 @@ description: Workspace 扫描、schema 校验与影响面规则。文档 ID 即 kind: architecture code: paths: + - cli/src/lib/fs.ts - cli/src/lib/workspace.ts - cli/src/lib/state.ts - cli/src/lib/git.ts @@ -13,6 +14,7 @@ code: - cli/src/commands/delta.ts - cli/src/commands/fingerprint.ts - cli/src/commands/hook.ts + - cli/tests/cli.test.ts - cli/schemas/doc-frontmatter.schema.json - cli/schemas/meta.schema.json - cli/schemas/output.schema.json @@ -20,6 +22,10 @@ code: # 状态面与校验面 +## 项目根发现边界 + +`cli/src/lib/fs.ts` 会先定位离当前目录最近的 Git 根,并且只接受该 Git 根直属的 `llmdoc/`。一旦存在 Git 边界,发现过程就不会继续向上,避免把同一工作区中名为 `llmdoc` 的同级仓库误认成当前项目的知识面;仅在完全没有 Git 边界时,才为初始化前的临时目录保留向上查找 `llmdoc/` 的兼容回退。 + ## 文档树如何被装载 `cli/src/lib/workspace.ts` 会递归扫描 `llmdoc/`,只接受 `.mdx` 与唯一的 `meta.json`。每份文档会被解析出 front matter、标题、正文链接、`CodeRef` 与 topic 归属。 diff --git a/llmdoc/plugin-packaging/claude-and-codex.mdx b/llmdoc/plugin-packaging/claude-and-codex.mdx index 02f9c50..b82e7b2 100644 --- a/llmdoc/plugin-packaging/claude-and-codex.mdx +++ b/llmdoc/plugin-packaging/claude-and-codex.mdx @@ -27,7 +27,7 @@ code: - `.claude-plugin/*.json` 提供插件元数据与 marketplace 入口 - `skills/init|update|prune|upgrade/SKILL.md` 是四个显式工作流的薄壳 SOP - `agents/investigator.md` 与 `agents/recorder.md` 是角色契约 -- 根 `hooks/hooks.json` 只负责把生命周期事件转给 `npx @tokenroll/llmdoc hook ...` +- 根 `hooks/hooks.json` 只负责把生命周期事件转给 `npx -y @tokenroll/llmdoc hook ...` 这些文件描述“何时、为什么、授权到哪里”,而不是重写 CLI 的算法。 @@ -59,7 +59,7 @@ acplugin 转换注意(重生成后需人工核对):`model: inherit` 硬编 `scripts/check-codex-surface.mjs` 会自动拒绝任一 marketplace 名不是 `llmdoc-plugin` 或两侧名称不一致;`tests/parity-checklist.md` 则要求发布前人工确认两侧 marketplace 名与插件名分别为 `llmdoc-plugin` 和 `llmdoc`。 -根 `hooks/hooks.json` 不靠 ACPlugin 复制;Claude 与 Codex 都直接共用这份根 hook 壳,并统一调用同一个 CLI hook 子命令。 +根 `hooks/hooks.json` 不靠 ACPlugin 复制;Claude 与 Codex 都直接共用这份根 hook 壳,并统一通过 `npx -y @tokenroll/llmdoc` 调用 CLI hook 子命令。`-y` 允许缺包时非交互获取正确的 scoped 包;本地安装仍是确定版本和离线运行的首选。 维护时要遵守两点: diff --git a/scripts/check-codex-surface.mjs b/scripts/check-codex-surface.mjs index cf2683a..9d90cc1 100644 --- a/scripts/check-codex-surface.mjs +++ b/scripts/check-codex-surface.mjs @@ -79,14 +79,14 @@ if (fs.existsSync(skillsRoot)) { } } -// 4) hooks.json:合法 JSON 且所有命令使用 scoped 包名 + --no-install +// 4) hooks.json:合法 JSON 且所有命令使用 scoped 包名 + 非交互安装确认 const hooks = readJson("hooks/hooks.json"); if (hooks) { const commands = JSON.stringify(hooks).match(/"command":"([^"]+)"/g) ?? []; for (const raw of commands) { const command = raw.slice(11, -1); - if (!command.startsWith("npx --no-install @tokenroll/llmdoc")) { - errors.push(`hooks.json: 命令必须以 'npx --no-install @tokenroll/llmdoc' 开头: ${command}`); + if (!command.startsWith("npx -y @tokenroll/llmdoc")) { + errors.push(`hooks.json: 命令必须以 'npx -y @tokenroll/llmdoc' 开头: ${command}`); } } } diff --git a/tests/parity-checklist.md b/tests/parity-checklist.md index 801ee01..51076a3 100644 --- a/tests/parity-checklist.md +++ b/tests/parity-checklist.md @@ -9,7 +9,7 @@ Claude Code 根插件是唯一手工维护的准源。Codex 表面由 ACPlugin - [ ] Codex 的 `.agents/skills/llmdoc/SKILL.md` 包含 Claude operating skill 的完整正文。 - [ ] `init/update/prune/upgrade` 的授权边界、前置 clean 检查、失败回滚和五态结果契约未在转换中丢失。 - [ ] `upgrade` 在两个平台都保持仅显式调用(Claude 侧 `disable-model-invocation: true`;Codex 侧 `policy.allow_implicit_invocation: false`),未被 operating skill 或 hook 隐式触发。 -- [ ] Claude 的 `SessionStart`、`Stop`、`PreCompact` 都直接调用本地 `llmdoc` CLI;Codex 保留仓库根 `hooks/hooks.json`,并按官方信任模型启用。 +- [ ] Claude 的 `SessionStart`、`Stop`、`PreCompact` 都通过 `npx -y @tokenroll/llmdoc` 调用 scoped CLI;Codex 保留仓库根 `hooks/hooks.json`,并按官方信任模型启用。 - [ ] hooks fail-open、永不写 `llmdoc/`;SessionStart 不超过 200 token,Stop/PreCompact 成功时输出合法 JSON。 - [ ] 生成目录中没有 V2 `worker`、`reflector`、startup pack、watermark 或旧命令残留。 - [ ] `.agents/skills/cmd-upgrade/agents/openai.yaml` 设置 `policy.allow_implicit_invocation: false`,确保 upgrade 只能显式调用。 From 5b0140cec1214268269930df1898940f9303f951 Mon Sep 17 00:00:00 2001 From: DJJ Date: Tue, 25 Aug 2026 00:05:40 +0800 Subject: [PATCH 2/2] chore(llmdoc): refresh fingerprints --- llmdoc/meta.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/llmdoc/meta.json b/llmdoc/meta.json index 364d964..ae55eef 100644 --- a/llmdoc/meta.json +++ b/llmdoc/meta.json @@ -6,19 +6,19 @@ }, "documents": { "architecture.mdx": { - "validatedRevision": "dc98378e5308aa21f09e78aef23e806d94f889d4" + "validatedRevision": "49ea44d8a5b7ab1bd21579c8ef481057e272f747" }, "cli-runtime/state-and-validation.mdx": { - "validatedRevision": "a982208863b2c9a3e0495eee88e28b99ecf79a2e" + "validatedRevision": "49ea44d8a5b7ab1bd21579c8ef481057e272f747" }, "cli-runtime/retrieval-and-mutation.mdx": { "validatedRevision": "a982208863b2c9a3e0495eee88e28b99ecf79a2e" }, "plugin-packaging/claude-and-codex.mdx": { - "validatedRevision": "dc98378e5308aa21f09e78aef23e806d94f889d4" + "validatedRevision": "49ea44d8a5b7ab1bd21579c8ef481057e272f747" }, "plugin-packaging/development-and-release.mdx": { - "validatedRevision": "a982208863b2c9a3e0495eee88e28b99ecf79a2e" + "validatedRevision": "49ea44d8a5b7ab1bd21579c8ef481057e272f747" }, "workflows/init-and-update.mdx": { "validatedRevision": "a982208863b2c9a3e0495eee88e28b99ecf79a2e"