diff --git a/external_plugins/project-rules/.zcode-plugin/plugin.json b/external_plugins/project-rules/.zcode-plugin/plugin.json new file mode 100644 index 00000000..b4969f60 --- /dev/null +++ b/external_plugins/project-rules/.zcode-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "project-rules", + "version": "0.1.0", + "description": "Load CLAUDE.md and path-scoped rule files (.zcode/rules/*.md with paths: globs) into ZCode sessions, so projects coming from Claude Code or Cursor keep their instructions. Hooks only, no network.", + "description_i18n": { + "en": "Load CLAUDE.md and path-scoped rule files (.zcode/rules/*.md with paths: globs) into ZCode sessions, so projects coming from Claude Code or Cursor keep their instructions. Hooks only, no network.", + "zh-CN": "将 CLAUDE.md 与按路径生效的规则文件 (.zcode/rules/*.md, 支持 paths: glob) 注入 ZCode 会话, 让从 Claude Code 或 Cursor 迁移的项目保留原有指令. 仅使用 Hook, 不访问网络." + }, + "author": { + "name": "qubn", + "url": "https://github.com/dominiclafleur" + }, + "homepage": "https://github.com/dominiclafleur/zcode-project-rules", + "repository": "https://github.com/dominiclafleur/zcode-project-rules", + "license": "MIT", + "keywords": [ + "rules", + "claude-md", + "context", + "hooks", + "migration", + "cursor-rules" + ] +} diff --git a/external_plugins/project-rules/LICENSE b/external_plugins/project-rules/LICENSE new file mode 100644 index 00000000..70eb2373 --- /dev/null +++ b/external_plugins/project-rules/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 qubn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/external_plugins/project-rules/README.md b/external_plugins/project-rules/README.md new file mode 100644 index 00000000..6dc24bef --- /dev/null +++ b/external_plugins/project-rules/README.md @@ -0,0 +1,131 @@ +# project-rules + +[简体中文](./README_CN.md) + +Brings `CLAUDE.md` and path-scoped rule files to ZCode. ZCode reads only `AGENTS.md` from the project root, so a project coming from Claude Code or Cursor loses its `CLAUDE.md` and per-path rules. This plugin adds them back as model context through two hooks. It never blocks, edits, or rewrites a tool call. + +## What it does + +| Hook | Matcher | What gets injected | +| --- | --- | --- | +| `SessionStart` | all sources (startup, resume, clear, compact) | `CLAUDE.md` from the project root, if there is no `AGENTS.md` next to it, plus every rule in `.zcode/rules/*.md` that has no `paths:` | +| `PreToolUse` | `Read\|Edit\|Write` | Rules whose `paths:` globs match the tool's `file_path`, plus any path-less rule not yet delivered in this session (for example, one that changed or did not fit the SessionStart budget) | + +Each rule is delivered once per session. It is delivered again only when its file content changes, or after a new SessionStart (resume, clear, compaction), because the model context starts over then. + +The project root is the session's ZCode working directory. + +## Rule files + +Put Markdown files directly in `.zcode/rules/` at the project root. Frontmatter is optional. + +A rule without `paths:` always applies. It is injected at session start: + +```markdown +Run `pnpm test` before finishing a task. +Never commit generated files under dist/. +``` + +A rule with `paths:` applies when the agent reads, edits, or writes a matching file. For example, `.zcode/rules/typescript.md`: + +```markdown +--- +paths: src/**/*.ts, src/**/*.tsx +--- +Prefer `type` aliases over `interface`. +Use the logger in src/lib/log.ts instead of console.log. +``` + +`paths:` accepts any of these forms. Cursor's `globs:` key works as an alias, and trailing `# comments` are ignored. + +```yaml +paths: src/**/*.{ts,tsx}, *.proto +paths: ["src/api/**", "docs/api/"] +paths: + - src/db/**/*.sql + - migrations/ +globs: *.sql +``` + +### Glob syntax + +| Pattern | Matches | +| --- | --- | +| `*` | any characters except `/` | +| `?` | exactly one character except `/` | +| `**` | any number of directories, including none: `src/**/*.ts` matches `src/a.ts` and `src/x/y/a.ts` | +| `{a,b}` | either alternative: `*.{ts,tsx}` | +| no `/` in the pattern | the file name at any depth: `*.proto` is the same as `**/*.proto` | +| leading `/` or `./` | anchored at the project root: `/README.md` | +| trailing `/` | everything below that directory: `docs/` | + +The tool's `file_path` is made relative to the project root (absolute and relative paths both work) and matched with `/` separators, case-sensitively. A `file_path` that is outside the project both as written and after resolving symlinks never matches. Character classes (`[abc]`) and negation (`!`) are not supported. A rule uses its first 32 globs, and a glob longer than 256 characters is ignored. + +## Install + +From the GitHub repository, which is a standalone marketplace: + +```shell +zcode plugins marketplace add https://github.com/dominiclafleur/zcode-project-rules +zcode plugins install project-rules@project-rules-marketplace +zcode plugins enable project-rules@project-rules-marketplace +``` + +In the desktop app, open **Plugin Marketplace -> New**, paste the same URL, then install and enable `project-rules`. + +If the plugin is listed in the official marketplace, install it from **Settings -> Plugins** by searching for `project-rules`. + +Start a **new session** after installing or enabling it. ZCode takes a snapshot of hooks when a session starts. + +Requirements: `node` 16 or newer on `PATH`. There are no npm packages, and the plugin makes no network requests. + +## Migrating from Claude Code or Cursor + +- **CLAUDE.md**: works as-is when the project root has no `AGENTS.md`. If both exist, ZCode reads `AGENTS.md` and this plugin skips `CLAUDE.md` so the instructions are not loaded twice. Move anything you still need from `CLAUDE.md` into `AGENTS.md`. +- **Claude Code rules** (`.claude/rules/*.md`): copy them. Their `paths:` frontmatter is read as-is. + + ```shell + mkdir -p .zcode/rules && cp .claude/rules/*.md .zcode/rules/ + ``` + +- **Cursor rules** (`.cursor/rules/*.mdc`): copy them with a `.md` extension. `globs:` is read as `paths:`. Rules without globs become always-on, and Cursor's `alwaysApply` and `description` fields are ignored. +- **Not carried over**: `@path` imports inside `CLAUDE.md` (they stay as plain text), `CLAUDE.local.md`, `~/.claude/CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.md` files in subdirectories, and subdirectories of `.zcode/rules/`. + +## Limits + +- **Size**: each rule is cut at 4000 characters and `CLAUDE.md` at 8000. A truncated item ends with a note that tells the model to Read the file for the rest. One injection is capped at 12000 characters and at 30000 bytes of hook output, which keeps it under ZCode's 32 KiB hook stdout limit. Rules that do not fit are deferred to a later Read/Edit/Write call they apply to, and a note names up to five of them. +- **Timing**: PreToolUse context reaches the model together with the tool result. In the usual Read-then-Edit flow, the model sees a file's rules before it edits that file. A `Write` that creates a new file runs before the model sees the rule, so the rule applies to follow-up edits. +- **Triggers**: only `Read`, `Edit`, and `Write` calls that carry a `file_path`. Bash, Grep, Glob, and patch-style edits do not trigger path rules. A call whose hook input exceeds 1 MiB (for example a `Write` of a very large file) is skipped. +- **Discovery**: only `*.md` files directly inside `.zcode/rules/`, up to 200 files of at most 1 MiB each and 2 MiB in total; a file that would pass the total is skipped. Rule files are re-read on every Read/Edit/Write call, so edits apply without restarting. +- **Root**: `CLAUDE.md` and `.zcode/rules/` are looked up in the ZCode working directory only, not in parent directories. +- **Cost**: each Read/Edit/Write call starts a short-lived Node process, which usually takes tens of milliseconds. + +## Data and side effects + +- **Reads**: `CLAUDE.md`, whether `AGENTS.md` exists, and `.zcode/rules/*.md` in the project. Symlinked `CLAUDE.md` or rule files that resolve outside the project are skipped. +- **Writes**: `${ZCODE_PLUGIN_DATA}/state.json`, or `~/.zcode/project-rules/state.json` when `ZCODE_PLUGIN_DATA` is not set, and a short-lived `state.lock`. The state file holds session ids, rule file paths, and content hashes, and only your user can read it. A session is pruned 7 days after its last state write, which happens at SessionStart and whenever a rule is injected. Nothing is written to the project or to the plugin directory. +- **No** network access, MCP server, commands, or skills. Besides the `node` process itself, nothing is executed. +- The hook never returns deny, ask, or rewritten input, and it always exits 0, so tool calls always proceed. On an unexpected error it prints nothing. If the state file cannot be written, SessionStart context is still injected, but PreToolUse injects nothing. + +## Troubleshooting + +- **Nothing is injected**: start a new session after enabling the plugin. Check that **Settings -> Hooks** lists the two `project-rules` hooks, that rule files end in `.md`, and that each has a non-empty body below the frontmatter. +- **Diagnostics**: start ZCode with `PROJECT_RULES_DEBUG=1` in its environment, and the hook writes diagnostics to stderr. +- **Manual run** from your project directory, with `` set to the installed plugin path: + + ```shell + printf '%s\n' '{"hookEventName":"PreToolUse","sessionId":"manual","cwd":"'"$PWD"'","toolName":"Read","toolInput":{"file_path":"src/index.ts"}}' \ + | ZCODE_PLUGIN_DATA=/tmp/project-rules-manual node /hooks/rules.js + ``` + +## Development + +```shell +node test/test.mjs +``` + +The self-test needs no ZCode install and no marketplace. It spawns `hooks/rules.js` with SessionStart and PreToolUse payloads and checks the output for these cases: matching and non-matching paths, dedupe, re-injection after a content change, the size and read budgets, hostile globs, symlinks that leave the project, parallel calls, stale and stuck locks, corrupt or oddly shaped state, stale-session pruning, oversized stdin, and an unwritable or unset data directory. + +## License + +MIT. See [LICENSE](./LICENSE). diff --git a/external_plugins/project-rules/README_CN.md b/external_plugins/project-rules/README_CN.md new file mode 100644 index 00000000..86a8f51e --- /dev/null +++ b/external_plugins/project-rules/README_CN.md @@ -0,0 +1,131 @@ +# project-rules + +[English](./README.md) + +为 ZCode 补上 `CLAUDE.md` 与按路径生效的规则文件. ZCode 只从项目根目录读取 `AGENTS.md`, 从 Claude Code 或 Cursor 迁移过来的项目会丢失 `CLAUDE.md` 和按路径划分的规则. 本插件通过两个 Hook 把它们重新注入为模型上下文, 不会阻止, 修改或改写任何工具调用. + +## 功能 + +| Hook | Matcher | 注入内容 | +| --- | --- | --- | +| `SessionStart` | 所有来源 (startup, resume, clear, compact) | 项目根目录的 `CLAUDE.md` (仅当同级没有 `AGENTS.md` 时), 以及 `.zcode/rules/*.md` 中所有没有 `paths:` 的规则 | +| `PreToolUse` | `Read\|Edit\|Write` | `paths:` glob 与工具 `file_path` 匹配的规则, 以及本会话中尚未送达的无路径规则 (例如内容有变化, 或超出 SessionStart 预算而被推迟的规则) | + +每条规则在每个会话中只送达一次. 只有当规则文件内容发生变化, 或者发生新的 SessionStart (resume, clear, 上下文压缩) 时才会再次送达, 因为此时模型上下文已重新开始. + +项目根目录即该会话的 ZCode 工作目录. + +## 规则文件 + +把 Markdown 文件直接放在项目根目录的 `.zcode/rules/` 下. frontmatter 可选. + +没有 `paths:` 的规则始终生效, 在会话开始时注入: + +```markdown +完成任务前运行 `pnpm test`. +不要提交 dist/ 下的生成文件. +``` + +带 `paths:` 的规则在 Agent 读取, 编辑或写入匹配文件时生效. 例如 `.zcode/rules/typescript.md`: + +```markdown +--- +paths: src/**/*.ts, src/**/*.tsx +--- +优先使用 `type` 别名而不是 `interface`. +使用 src/lib/log.ts 中的 logger, 不要用 console.log. +``` + +`paths:` 支持以下任意写法. Cursor 的 `globs:` 键可作为别名, 行尾的 `# 注释` 会被忽略. + +```yaml +paths: src/**/*.{ts,tsx}, *.proto +paths: ["src/api/**", "docs/api/"] +paths: + - src/db/**/*.sql + - migrations/ +globs: *.sql +``` + +### Glob 语法 + +| 模式 | 匹配 | +| --- | --- | +| `*` | 除 `/` 以外的任意字符 | +| `?` | 除 `/` 以外的单个字符 | +| `**` | 任意层目录, 包括零层: `src/**/*.ts` 同时匹配 `src/a.ts` 和 `src/x/y/a.ts` | +| `{a,b}` | 任选其一: `*.{ts,tsx}` | +| 模式中没有 `/` | 任意深度的文件名: `*.proto` 等同于 `**/*.proto` | +| 以 `/` 或 `./` 开头 | 锚定到项目根目录: `/README.md` | +| 以 `/` 结尾 | 该目录下的所有文件: `docs/` | + +工具的 `file_path` 会先转换为相对项目根目录的路径 (绝对路径和相对路径都可以), 再以 `/` 作为分隔符匹配, 区分大小写. 无论按原样还是解析符号链接后都位于项目之外的 `file_path` 永远不会匹配. 不支持字符类 (`[abc]`) 和取反 (`!`). 每条规则只使用前 32 个 glob, 超过 256 个字符的 glob 会被忽略. + +## 安装 + +GitHub 仓库本身就是一个独立的插件市场: + +```shell +zcode plugins marketplace add https://github.com/dominiclafleur/zcode-project-rules +zcode plugins install project-rules@project-rules-marketplace +zcode plugins enable project-rules@project-rules-marketplace +``` + +在桌面端, 打开 **插件市场 -> 新建**, 粘贴同一个 URL, 然后安装并启用 `project-rules`. + +如果插件已收录到官方插件市场, 可在 **设置 -> 插件** 中搜索 `project-rules` 安装. + +安装或启用后请 **新建会话**. ZCode 在会话开始时为 Hook 配置建立快照. + +环境要求: `PATH` 中有 16 或更高版本的 `node`. 插件没有 npm 依赖, 也不发起任何网络请求. + +## 从 Claude Code 或 Cursor 迁移 + +- **CLAUDE.md**: 项目根目录没有 `AGENTS.md` 时可直接使用. 如果两者同时存在, ZCode 读取 `AGENTS.md`, 本插件跳过 `CLAUDE.md`, 以免同一份指令被加载两次. 请把 `CLAUDE.md` 中仍需要的内容合并到 `AGENTS.md`. +- **Claude Code 规则** (`.claude/rules/*.md`): 直接复制即可, 其中的 `paths:` frontmatter 可原样读取. + + ```shell + mkdir -p .zcode/rules && cp .claude/rules/*.md .zcode/rules/ + ``` + +- **Cursor 规则** (`.cursor/rules/*.mdc`): 复制时把扩展名改为 `.md`. `globs:` 会被当作 `paths:` 读取. 没有 globs 的规则会变成始终生效, Cursor 的 `alwaysApply` 和 `description` 字段会被忽略. +- **不会迁移**: `CLAUDE.md` 中的 `@path` 导入 (保留为普通文本), `CLAUDE.local.md`, `~/.claude/CLAUDE.md`, `.claude/CLAUDE.md`, 子目录中的 `CLAUDE.md`, 以及 `.zcode/rules/` 的子目录. + +## 限制 + +- **大小**: 每条规则截断为前 4000 个字符, `CLAUDE.md` 截断为前 8000 个字符. 被截断的内容末尾附有提示, 告诉模型 Read 原文件获取剩余内容. 单次注入上限为 12000 个字符, Hook 输出上限为 30000 字节, 低于 ZCode 32 KiB 的 Hook stdout 限制. 放不下的规则会推迟到之后适用于它们的 Read/Edit/Write 结果中送达, 并附有列出其中最多 5 条规则的提示. +- **时机**: PreToolUse 的上下文与工具结果一起送达模型. 在常见的先 Read 后 Edit 流程中, 模型会在编辑文件之前看到该文件的规则. 但新建文件的 `Write` 会在模型看到规则之前执行, 规则从后续编辑开始生效. +- **触发**: 只有带 `file_path` 的 `Read`, `Edit`, `Write` 调用会触发路径规则. Bash, Grep, Glob 和补丁式编辑不会触发. Hook 输入超过 1 MiB 的调用 (例如写入超大文件的 `Write`) 会被跳过. +- **发现**: 只读取 `.zcode/rules/` 目录下直接存放的 `*.md` 文件, 最多 200 个, 每个最大 1 MiB, 合计最多 2 MiB, 超出合计上限的文件会被跳过. 每次 Read/Edit/Write 调用都会重新读取规则文件, 因此修改无需重启即可生效. +- **根目录**: 只在 ZCode 工作目录中查找 `CLAUDE.md` 和 `.zcode/rules/`, 不会向上查找父目录. +- **开销**: 每次 Read/Edit/Write 调用都会启动一个短生命周期的 Node 进程, 通常耗时几十毫秒. + +## 数据与副作用 + +- **读取**: 项目中的 `CLAUDE.md`, `AGENTS.md` 是否存在, 以及 `.zcode/rules/*.md`. 解析后指向项目之外的 `CLAUDE.md` 或规则文件符号链接会被跳过. +- **写入**: `${ZCODE_PLUGIN_DATA}/state.json` (未设置 `ZCODE_PLUGIN_DATA` 时为 `~/.zcode/project-rules/state.json`) 以及短暂存在的 `state.lock`. 状态文件保存会话 ID, 规则文件路径和内容哈希, 只有当前用户可以读取. 会话在最后一次写入状态 7 天后被清理, 写入发生在 SessionStart 时以及每次注入规则时. 不会向项目目录或插件目录写入任何内容. +- **没有** 网络访问, MCP 服务, 命令或技能. 除 `node` 进程本身外不执行任何程序. +- Hook 从不返回 deny, ask 或改写后的输入, 并且始终以 0 退出, 工具调用始终照常进行. 出现意外错误时不输出内容. 如果无法写入状态文件, SessionStart 的上下文仍会注入, 但 PreToolUse 不会注入任何内容. + +## 故障排查 + +- **没有注入内容**: 启用插件后请新建会话. 确认 **设置 -> 钩子** 中列出了两个 `project-rules` Hook, 规则文件以 `.md` 结尾, 并且 frontmatter 之后的正文不为空. +- **诊断信息**: 在 ZCode 的启动环境中设置 `PROJECT_RULES_DEBUG=1`, Hook 会把诊断信息写到 stderr. +- **手动运行**: 在项目目录中执行以下命令, `` 为插件安装路径: + + ```shell + printf '%s\n' '{"hookEventName":"PreToolUse","sessionId":"manual","cwd":"'"$PWD"'","toolName":"Read","toolInput":{"file_path":"src/index.ts"}}' \ + | ZCODE_PLUGIN_DATA=/tmp/project-rules-manual node /hooks/rules.js + ``` + +## 开发 + +```shell +node test/test.mjs +``` + +自测不需要安装 ZCode, 也不需要插件市场. 它用 SessionStart 和 PreToolUse 负载启动 `hooks/rules.js`, 并检查以下场景的输出: 匹配与不匹配的路径, 去重, 内容变化后重新注入, 大小与读取预算, 恶意构造的 glob, 指向项目之外的符号链接, 并发调用, 过期或卡住的锁, 损坏或结构异常的状态文件, 过期会话清理, 超大的 stdin, 以及不可写或未设置的数据目录. + +## 许可证 + +MIT. 见 [LICENSE](./LICENSE). diff --git a/external_plugins/project-rules/hooks/hooks.json b/external_plugins/project-rules/hooks/hooks.json new file mode 100644 index 00000000..8f1780e5 --- /dev/null +++ b/external_plugins/project-rules/hooks/hooks.json @@ -0,0 +1,30 @@ +{ + "description": "project-rules: inject CLAUDE.md and .zcode/rules/*.md as model context. Never blocks or rewrites tool calls.", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${ZCODE_PLUGIN_ROOT}/hooks/rules.js\"", + "timeoutMs": 10000, + "statusMessage": "project-rules: loading CLAUDE.md and rules" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Read|Edit|Write", + "hooks": [ + { + "type": "command", + "command": "node \"${ZCODE_PLUGIN_ROOT}/hooks/rules.js\"", + "timeoutMs": 10000, + "statusMessage": "project-rules: matching path rules" + } + ] + } + ] + } +} diff --git a/external_plugins/project-rules/hooks/rules.js b/external_plugins/project-rules/hooks/rules.js new file mode 100644 index 00000000..4b0f5522 --- /dev/null +++ b/external_plugins/project-rules/hooks/rules.js @@ -0,0 +1,655 @@ +#!/usr/bin/env node +/** + * project-rules hook for ZCode. + * + * SessionStart: injects the project-root CLAUDE.md (only when no AGENTS.md exists, + * because ZCode already loads AGENTS.md) plus every .zcode/rules/*.md without `paths:`. + * PreToolUse (Read|Edit|Write): injects .zcode/rules/*.md whose `paths:` globs match + * toolInput.file_path, plus any path-less rule not yet delivered in this session. + * + * Protocol: one JSON object on stdin, optional JSON on stdout, exit code 0 always. + * This hook never blocks or rewrites a tool call; on any unexpected error it prints + * nothing. Set PROJECT_RULES_DEBUG=1 to get diagnostics on stderr. + * + * Manual smoke test: + * printf '%s\n' '{"hookEventName":"SessionStart","sessionId":"manual","cwd":"'"$PWD"'"}' \ + * | node hooks/rules.js + */ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const RULE_MAX_CHARS = 4000; +const CLAUDE_MD_MAX_CHARS = 8000; +const TOTAL_MAX_CHARS = 12000; +// ZCode cuts hook stdout at hooks.maxOutputBytes (default 32768); a cut JSON +// document fails to parse, so the serialized output must stay below this. +const MAX_STDOUT_BYTES = 30000; +const MAX_RULE_FILE_BYTES = 1024 * 1024; +const MAX_RULE_BYTES_TOTAL = 2 * 1024 * 1024; +const MAX_RULE_FILES = 200; +const MAX_RULE_GLOBS = 32; +const MAX_GLOB_CHARS = 256; +const MAX_BRACE_EXPANSIONS = 64; +const MAX_STDIN_BYTES = 1024 * 1024; +const STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const LOCK_WAIT_MS = 2000; +const LOCK_STALE_MS = 10000; +const STDIN_TIMEOUT_MS = 5000; +const TOOL_NAMES = new Set(['Read', 'Edit', 'Write']); +const RULES_DIR = path.join('.zcode', 'rules'); + +function debug(message) { + if (process.env.PROJECT_RULES_DEBUG === '1') { + process.stderr.write('[project-rules] ' + message + '\n'); + } +} + +// --------------------------------------------------------------------------- +// Glob matching: `*`, `**`, `?`, and `{a,b}` alternatives. Paths use `/`. +// A pattern without `/` matches the file name at any depth (gitignore style); +// a leading `/` or `./` anchors it to the project root; a trailing `/` means +// everything below that directory. +// --------------------------------------------------------------------------- + +/** + * Splits `value` on commas that sit outside braces and quotes; strips quotes + * and a trailing YAML ` # comment`. + */ +function splitList(value) { + const out = []; + let current = ''; + let depth = 0; + let quote = null; + for (const ch of value) { + if (quote) { + if (ch === quote) quote = null; + else current += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '#' && depth === 0 && (current === '' || /\s$/.test(current))) break; + if (ch === '{') depth += 1; + else if (ch === '}' && depth > 0) depth -= 1; + else if (ch === ',' && depth === 0) { + out.push(current); + current = ''; + continue; + } + current += ch; + } + out.push(current); + return out.map((item) => item.trim()).filter(Boolean); +} + +/** + * Expands `{a,b}` groups (nested and empty alternatives allowed) breadth-first, capped at + * MAX_BRACE_EXPANSIONS results. A first `{` without a matching `}` leaves the pattern literal. + */ +function expandBraces(pattern) { + const done = []; + const queue = [pattern]; + while (queue.length && done.length < MAX_BRACE_EXPANSIONS) { + const current = queue.shift(); + const open = current.indexOf('{'); + const options = []; + let start = open + 1; + let depth = 0; + let close = open; + for (; open !== -1 && close < current.length; close += 1) { + const ch = current[close]; + if (ch === '{') depth += 1; + else if (ch === '}' && --depth === 0) break; + else if (ch === ',' && depth === 1) { + options.push(current.slice(start, close)); + start = close + 1; + } + } + if (open === -1 || close === current.length) { + done.push(current); + continue; + } + options.push(current.slice(start, close)); + for (const option of options) { + if (queue.length >= MAX_BRACE_EXPANSIONS) break; + queue.push(current.slice(0, open) + option + current.slice(close + 1)); + } + } + return done; +} + +/** A glob as `/` segments, with anchoring and the trailing-`/` directory form resolved. */ +function globToSegments(glob) { + let pattern = glob.trim().replace(/\\/g, '/'); + const anchored = /^\.?\//.test(pattern); + pattern = pattern.replace(/^\.?\//, ''); + if (pattern.endsWith('/')) pattern += '**'; + if (!anchored && !pattern.includes('/')) pattern = '**/' + pattern; + const segments = pattern.split('/').filter((segment) => segment !== ''); + // A trailing `**` needs one segment: `docs/**` matches below docs/, not a file named docs. + if (segments[segments.length - 1] === '**') segments.splice(-1, 0, '*'); + return segments; +} + +/** + * Matches `subject` against `pattern`, where a `star` item spans any run of subject items and + * any other item must satisfy same(item, subjectItem). Greedy with one backtrack point, so it + * is O(pattern * subject); a RegExp built from a hostile glob can backtrack for minutes. + */ +function wildcard(pattern, subject, star, same) { + let p = 0; + let s = 0; + let starP = -1; + let starS = 0; + while (s < subject.length) { + if (pattern[p] === star) { + starP = p++; + starS = s; + } else if (p < pattern.length && same(pattern[p], subject[s])) { + p += 1; + s += 1; + } else if (starP === -1) { + return false; + } else { + p = starP + 1; + s = ++starS; + } + } + while (pattern[p] === star) p += 1; + return p === pattern.length; +} + +/** Segment lists for a rule's first MAX_RULE_GLOBS globs; over-long globs never match. */ +function compileGlobs(globs) { + const matchers = []; + for (const glob of globs.slice(0, MAX_RULE_GLOBS)) { + if (glob.length > MAX_GLOB_CHARS) { + debug('ignoring a glob over ' + MAX_GLOB_CHARS + ' chars: ' + glob.slice(0, 40) + '...'); + continue; + } + for (const expanded of expandBraces(glob)) { + if (expanded.trim()) matchers.push(globToSegments(expanded)); + } + } + return matchers; +} + +function matchesAny(relativePath, matchers) { + const names = relativePath.split('/'); + const sameName = (glob, name) => wildcard(glob, name, '*', (g, ch) => g === '?' || g === ch); + return matchers.some((segments) => wildcard(segments, names, '**', sameName)); +} + +// --------------------------------------------------------------------------- +// Rule files +// --------------------------------------------------------------------------- + +/** + * Parses optional frontmatter. Accepts `paths:` (or Cursor's `globs:`) as a + * comma-separated list, an inline `[a, b]` list, or a YAML block list. + */ +function parseRule(raw) { + let text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw; + text = text.replace(/\r\n/g, '\n'); + const match = /^---[ \t]*\n(?:([\s\S]*?)\n)?---[ \t]*(?:\n|$)/.exec(text); + if (!match) return { paths: [], body: text.trim() }; + + const lines = (match[1] || '').split('\n'); + const paths = []; + for (let i = 0; i < lines.length; i += 1) { + const field = /^(paths|globs)[ \t]*:[ \t]*(.*)$/.exec(lines[i]); + if (!field) continue; + let value = field[2].trim(); + if (value === '') { + while (i + 1 < lines.length && /^[ \t]*-[ \t]+/.test(lines[i + 1])) { + i += 1; + paths.push(...splitList(lines[i].replace(/^[ \t]*-[ \t]+/, ''))); + } + continue; + } + // Strip an inline list before splitList drops the comment: `["src/**"] # api`. + const inline = /^\[(.*?)\](?:[ \t]+#.*)?$/.exec(value); + if (inline) value = inline[1]; + paths.push(...splitList(value)); + } + return { paths, body: text.slice(match[0].length).trim() }; +} + +function hashContent(content) { + return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16); +} + +/** File text, or null for a non-file, a file over maxBytes, or one resolving outside root. */ +function readSmallFile(file, root, maxBytes) { + const real = fs.realpathSync(file); + if (!relativeInside(root, real)) return null; + const stat = fs.statSync(real); + if (!stat.isFile() || stat.size > maxBytes) return null; + return fs.readFileSync(real, 'utf8'); +} + +function loadRules(projectDir) { + const dir = path.join(projectDir, RULES_DIR); + let names; + try { + names = fs.readdirSync(dir); + } catch (_) { + return []; + } + const root = realish(projectDir); + let budget = MAX_RULE_BYTES_TOTAL; + const rules = []; + for (const name of names.filter((n) => n.toLowerCase().endsWith('.md')).sort()) { + if (rules.length >= MAX_RULE_FILES) break; + const file = path.join(dir, name); + let raw; + try { + raw = readSmallFile(file, root, Math.min(MAX_RULE_FILE_BYTES, budget)); + } catch (err) { + debug('skipping ' + file + ': ' + err.message); + continue; + } + if (raw === null) continue; + budget -= Buffer.byteLength(raw); + const parsed = parseRule(raw); + if (!parsed.body) continue; + rules.push({ + kind: 'rule', + file, + label: RULES_DIR.split(path.sep).join('/') + '/' + name, + hash: hashContent(raw), + body: parsed.body, + paths: parsed.paths, + }); + } + return rules; +} + +function loadClaudeMd(projectDir) { + if (fs.existsSync(path.join(projectDir, 'AGENTS.md'))) return null; + const file = path.join(projectDir, 'CLAUDE.md'); + let raw; + try { + raw = readSmallFile(file, realish(projectDir), MAX_RULE_FILE_BYTES); + } catch (_) { + return null; + } + if (raw === null || !raw.trim()) return null; + return { kind: 'claude', file, label: 'CLAUDE.md', hash: hashContent(raw), body: raw.trim() }; +} + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +function realish(p) { + try { + return fs.realpathSync(p); + } catch (_) { + try { + return path.join(fs.realpathSync(path.dirname(p)), path.basename(p)); + } catch (_err) { + return p; + } + } +} + +function relativeInside(base, target) { + const rel = path.relative(base, target); + if (!rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) return null; + return rel.split(path.sep).join('/'); +} + +/** Project-relative `/` path for a tool's file path, or null when outside the project. */ +function toProjectRelative(filePath, projectDir) { + if (typeof filePath !== 'string' || !filePath.trim()) return null; + const absolute = path.resolve(projectDir, filePath); + // A file that exists but resolves (symlink) outside the project never matches: + // rule scoping follows the real target, not just the path as written. + if (fs.existsSync(absolute) && !relativeInside(realish(path.resolve(projectDir)), realish(absolute))) { + return null; + } + return ( + relativeInside(path.resolve(projectDir), absolute) || + relativeInside(realish(path.resolve(projectDir)), realish(absolute)) + ); +} + +// --------------------------------------------------------------------------- +// Per-session dedupe state in ${ZCODE_PLUGIN_DATA:-~/.zcode/project-rules}/state.json: +// { version: 1, sessions: { : { updatedAt, rules: { : } } } } +// A rule is delivered again only when its content hash changes. Every state write +// refreshes updatedAt, and a session is pruned STATE_TTL_MS after its last write. +// --------------------------------------------------------------------------- + +function sleep(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** Runs fn under an exclusive lock file so parallel tool calls do not double-inject. */ +function withLock(dataDir, fn) { + const lockFile = path.join(dataDir, 'state.lock'); + const deadline = Date.now() + LOCK_WAIT_MS; + let fd = null; + while (fd === null) { + try { + fd = fs.openSync(lockFile, 'wx'); + } catch (err) { + if (err.code !== 'EEXIST') { + // Unwritable data dir: run unlocked; saveState decides what that means. + debug('lock unavailable (' + err.code + '); continuing without the lock'); + break; + } + try { + // Reclaim a lock left by a crashed hook; the mtime is checked at unlink time. + if (Date.now() - fs.statSync(lockFile).mtimeMs > LOCK_STALE_MS) { + fs.unlinkSync(lockFile); + continue; + } + } catch (_) { + // Vanished or not removable (a directory, EPERM): wait out the deadline, no spinning. + } + if (Date.now() > deadline) { + debug('lock wait timed out; continuing without the lock'); + break; + } + sleep(15); + } + } + try { + return fn(); + } finally { + if (fd !== null) { + try { + // After a long stall another hook may have reclaimed the lock: never remove theirs. + if (fs.statSync(lockFile).ino === fs.fstatSync(fd).ino) fs.unlinkSync(lockFile); + } catch (_) { + // Already removed as stale by another process. + } + fs.closeSync(fd); + } + } +} + +const isMap = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); + +function loadState(stateFile) { + let sessions = null; + try { + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + if (isMap(state) && isMap(state.sessions)) sessions = state.sessions; + } catch (_) { + // Missing or corrupt state starts fresh. + } + // Null prototype: a session id such as `__proto__` stays an ordinary, saved key. + return { version: 1, sessions: Object.assign(Object.create(null), sessions) }; +} + +function pruneState(state, now) { + for (const [id, session] of Object.entries(state.sessions)) { + const updatedAt = session && typeof session.updatedAt === 'number' ? session.updatedAt : 0; + if (now - updatedAt > STATE_TTL_MS) delete state.sessions[id]; + } +} + +function saveState(stateFile, state) { + const tmp = stateFile + '.' + process.pid + '.tmp'; + try { + fs.writeFileSync(tmp, JSON.stringify(state), { flag: 'wx', mode: 0o600 }); + fs.renameSync(tmp, stateFile); + } catch (err) { + try { + fs.unlinkSync(tmp); + } catch (_) { + // Nothing was written. + } + throw err; + } +} + +/** The session's entry, recreated when missing or malformed, with updatedAt refreshed. */ +function sessionEntry(state, sessionId, now) { + const existing = state.sessions[sessionId]; + const session = isMap(existing) && isMap(existing.rules) ? existing : { rules: {} }; + session.updatedAt = now; + state.sessions[sessionId] = session; + return session; +} + +// --------------------------------------------------------------------------- +// Context assembly with size caps +// --------------------------------------------------------------------------- + +function clip(text, max) { + if (text.length <= max) return text; + let end = max; + const code = text.charCodeAt(end - 1); + if (code >= 0xd800 && code <= 0xdbff) end -= 1; + return text.slice(0, end); +} + +function makeBlock(item, maxChars) { + const body = clip(item.body, maxChars); + const lines = [item.kind === 'claude' ? '## CLAUDE.md' : '## Rule: ' + item.label]; + if (item.kind === 'rule') { + const scope = item.paths.length ? item.paths.join(', ') : 'all files'; + lines.push('Applies to: ' + scope + (item.matched ? ' (matched ' + item.matched + ')' : '')); + } + lines.push(body); + if (body.length < item.body.length) { + lines.push( + '[project-rules: truncated, showing the first ' + body.length + ' of ' + item.body.length + + ' characters. Read ' + item.label + ' for the rest.]' + ); + } + return lines.join('\n'); +} + +function serialize(eventName, text) { + return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: text } }); +} + +function render(intro, included, deferred) { + const parts = ['', intro, '']; + parts.push(included.map((entry) => entry.block).join('\n\n')); + if (deferred.length) { + const names = deferred.slice(0, 5).map((item) => item.label); + if (deferred.length > names.length) names.push('...'); + parts.push( + '', + '[project-rules: ' + deferred.length + ' more rule(s) deferred to keep context small (' + + names.join(', ') + '). They will be attached to a later matching Read/Edit/Write result.]' + ); + } + parts.push(''); + return parts.join('\n'); +} + +/** Packs items in priority order under the char and byte budgets. */ +function buildContext(eventName, intro, items) { + const included = []; + const deferred = []; + let used = intro.length; + for (const item of items) { + const max = item.kind === 'claude' ? CLAUDE_MD_MAX_CHARS : RULE_MAX_CHARS; + const block = makeBlock(item, max); + if (included.length && used + block.length + 2 > TOTAL_MAX_CHARS) { + deferred.push(item); + continue; + } + included.push({ item, block, max }); + used += block.length + 2; + } + if (!included.length) return null; + + // The wrapper and the deferral note count too: enforce both caps on the final text. + const fits = (candidate) => + candidate.length <= TOTAL_MAX_CHARS && + Buffer.byteLength(serialize(eventName, candidate)) <= MAX_STDOUT_BYTES; + let text = render(intro, included, deferred); + while (!fits(text)) { + if (included.length > 1) { + deferred.unshift(included.pop().item); + } else { + const only = included[0]; + only.max = Math.floor(only.max * 0.75); + if (only.max < 200) return null; + only.block = makeBlock(only.item, only.max); + } + text = render(intro, included, deferred); + } + return { output: serialize(eventName, text), included: included.map((entry) => entry.item) }; +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +const SESSION_INTRO = + 'Project instructions loaded by the project-rules plugin. ZCode does not read these files ' + + 'natively; follow them as you would AGENTS.md.'; +const TOOL_INTRO = + 'Project rules loaded by the project-rules plugin from .zcode/rules/. Follow them for this ' + + 'and later work on matching files.'; + +function onSessionStart(ctx) { + const items = []; + const claude = loadClaudeMd(ctx.projectDir); + if (claude) items.push(claude); + for (const rule of loadRules(ctx.projectDir)) { + if (!rule.paths.length) items.push(rule); + } + return withLock(ctx.dataDir, () => { + const state = loadState(ctx.stateFile); + pruneState(state, ctx.now); + // Each SessionStart (startup, resume, clear, compact) begins a fresh model + // context, so every rule is re-armed for this session. + const session = { updatedAt: ctx.now, rules: {} }; + state.sessions[ctx.sessionId] = session; + const built = items.length ? buildContext('SessionStart', SESSION_INTRO, items) : null; + if (built) for (const item of built.included) session.rules[item.file] = item.hash; + try { + saveState(ctx.stateFile, state); + } catch (err) { + // Still deliver session context; dedupe for later tool calls is degraded. + debug('could not save state: ' + err.message); + } + return built ? built.output : null; + }); +} + +function onPreToolUse(ctx, input) { + const toolName = input.toolName || input.tool_name; + if (!TOOL_NAMES.has(toolName)) return null; + const toolInput = input.toolInput || input.tool_input || {}; + const filePath = toolInput.file_path || toolInput.filePath || toolInput.path; + const relative = toProjectRelative(filePath, ctx.projectDir); + + const rules = loadRules(ctx.projectDir); + if (!rules.length) return null; + + return withLock(ctx.dataDir, () => { + const state = loadState(ctx.stateFile); + pruneState(state, ctx.now); + const session = sessionEntry(state, ctx.sessionId, ctx.now); + const pending = (rule) => session.rules[rule.file] !== rule.hash; + + const items = []; + if (relative) { + for (const rule of rules) { + if (rule.paths.length && pending(rule) && matchesAny(relative, compileGlobs(rule.paths))) { + items.push(Object.assign({}, rule, { matched: relative })); + } + } + } + for (const rule of rules) { + if (!rule.paths.length && pending(rule)) items.push(rule); + } + const built = items.length ? buildContext('PreToolUse', TOOL_INTRO, items) : null; + if (!built) return null; + + for (const item of built.included) session.rules[item.file] = item.hash; + // Only emit what could be recorded; otherwise every tool call would repeat it. + saveState(ctx.stateFile, state); + return built.output; + }); +} + +function run(input, env) { + if (!input || typeof input !== 'object') return null; + const eventName = input.hookEventName || input.hook_event_name; + if (eventName !== 'SessionStart' && eventName !== 'PreToolUse') return null; + + const projectDir = path.resolve(env.ZCODE_PROJECT_DIR || input.cwd || process.cwd()); + const dataDir = env.ZCODE_PLUGIN_DATA || path.join(os.homedir(), '.zcode', 'project-rules'); + try { + fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); + } catch (err) { + debug('cannot create data dir: ' + err.message); + } + const ctx = { + dataDir, + now: Date.now(), + projectDir, + sessionId: String(input.sessionId || input.session_id || 'unknown-session'), + stateFile: path.join(dataDir, 'state.json'), + }; + return eventName === 'SessionStart' ? onSessionStart(ctx) : onPreToolUse(ctx, input); +} + +function main() { + const chunks = []; + let size = 0; + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(timer); + let output = null; + try { + if (size > MAX_STDIN_BYTES) throw new Error('stdin over ' + MAX_STDIN_BYTES + ' bytes'); + output = run(JSON.parse(Buffer.concat(chunks).toString('utf8')), process.env); + } catch (err) { + debug('no output: ' + (err && err.message ? err.message : String(err))); + } + process.exitCode = 0; + if (output) process.stdout.write(output); + }; + const timer = setTimeout(() => { + finish(); + process.stdin.destroy(); + }, STDIN_TIMEOUT_MS); + // Past the cap, drain without buffering: closing the pipe early would EPIPE the host. + process.stdin.on('data', (chunk) => { + size += chunk.length; + if (size <= MAX_STDIN_BYTES) chunks.push(chunk); + }); + process.stdin.on('end', finish); + process.stdin.on('error', finish); +} + +if (require.main === module) { + process.on('uncaughtException', (err) => { + debug('uncaught: ' + (err && err.message ? err.message : String(err))); + process.exit(0); + }); + main(); +} + +module.exports = { + compileGlobs, + expandBraces, + matchesAny, + parseRule, + run, + splitList, + toProjectRelative, + withLock, + limits: { CLAUDE_MD_MAX_CHARS, MAX_STDOUT_BYTES, RULE_MAX_CHARS, TOTAL_MAX_CHARS }, +}; diff --git a/external_plugins/project-rules/test/test.mjs b/external_plugins/project-rules/test/test.mjs new file mode 100644 index 00000000..3182c1fc --- /dev/null +++ b/external_plugins/project-rules/test/test.mjs @@ -0,0 +1,773 @@ +#!/usr/bin/env node +// Self-test for hooks/rules.js. No marketplace, no ZCode, no dependencies: +// spawns the hook with sample stdin payloads and asserts on stdout. +// Run: node test/test.mjs +import assert from 'assert'; +import { spawn, spawnSync } from 'child_process'; +import fs from 'fs'; +import { createRequire } from 'module'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const pluginRoot = path.resolve(here, '..'); +const hookScript = path.join(pluginRoot, 'hooks', 'rules.js'); +const require = createRequire(import.meta.url); +const rules = require(hookScript); + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'project-rules-test-')); +let failures = 0; +let passes = 0; + +async function test(name, fn) { + try { + await fn(); + passes += 1; + console.log('ok - ' + name); + } catch (err) { + failures += 1; + console.log('not ok - ' + name); + console.log(String(err && err.stack ? err.stack : err).replace(/^/gm, ' ')); + } +} + +// --------------------------------------------------------------------------- +// Fixtures and helpers +// --------------------------------------------------------------------------- + +let projectCounter = 0; +function makeProject(files) { + projectCounter += 1; + const dir = path.join(tmpRoot, 'project-' + projectCounter); + for (const [rel, content] of Object.entries(files)) { + const file = path.join(dir, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + } + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function makeDataDir() { + return fs.mkdtempSync(path.join(tmpRoot, 'data-')); +} + +let sessionCounter = 0; +function newSession() { + sessionCounter += 1; + return 'session-' + sessionCounter; +} + +function sessionStart(sessionId, project, source) { + return { + hookEventName: 'SessionStart', + hook_event_name: 'SessionStart', + sessionId, + session_id: sessionId, + cwd: project, + source: source || 'startup', + }; +} + +function preToolUse(sessionId, project, toolName, filePath) { + const toolInput = filePath === undefined ? {} : { file_path: filePath }; + return { + hookEventName: 'PreToolUse', + hook_event_name: 'PreToolUse', + sessionId, + session_id: sessionId, + cwd: project, + toolName, + tool_name: toolName, + toolInput, + tool_input: toolInput, + toolCallId: 'call-' + Math.random().toString(16).slice(2), + }; +} + +function hookEnv(project, dataDir) { + const env = Object.assign({}, process.env); + delete env.PROJECT_RULES_DEBUG; + env.ZCODE_PLUGIN_ROOT = pluginRoot; + env.ZCODE_PLUGIN_DATA = dataDir; + env.ZCODE_PROJECT_DIR = project; + return env; +} + +function parseResult(status, stdout, stderr) { + const result = { status, stdout, stderr, json: null, context: null }; + if (stdout.trim()) { + result.json = JSON.parse(stdout); + result.context = result.json.hookSpecificOutput.additionalContext; + } + return result; +} + +function callHook(payload, project, dataDir, options) { + const opts = options || {}; + const input = opts.raw !== undefined ? opts.raw : JSON.stringify(payload) + '\n'; + const child = spawnSync(process.execPath, [hookScript], { + input, + env: opts.env || hookEnv(project, dataDir), + encoding: 'utf8', + timeout: 15000, + }); + return parseResult(child.status, child.stdout, child.stderr); +} + +/** Spawns hooks that wait on stdin; release() feeds them all at once to force overlap. */ +function spawnHeldHooks(count, payload, project, dataDir) { + const children = []; + const done = []; + for (let i = 0; i < count; i += 1) { + const child = spawn(process.execPath, [hookScript], { env: hookEnv(project, dataDir) }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => (stdout += chunk)); + child.stderr.on('data', (chunk) => (stderr += chunk)); + done.push( + new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => resolve(parseResult(code, stdout, stderr))); + }) + ); + children.push(child); + } + return { + release() { + const line = JSON.stringify(payload) + '\n'; + for (const child of children) child.stdin.end(line); + return Promise.all(done); + }, + }; +} + +function assertSilent(result) { + assert.strictEqual(result.status, 0, 'exit code; stderr: ' + result.stderr); + assert.strictEqual(result.stdout, '', 'expected no stdout, got: ' + result.stdout.slice(0, 300)); +} + +function assertContext(result, eventName) { + assert.strictEqual(result.status, 0, 'exit code; stderr: ' + result.stderr); + assert.ok(result.json, 'expected JSON on stdout'); + assert.strictEqual(result.json.hookSpecificOutput.hookEventName, eventName); + assert.strictEqual(typeof result.context, 'string'); + return result.context; +} + +const BASE_FILES = { + 'CLAUDE.md': '# Team notes\nCLAUDE-MARKER: use tabs for indentation.\n', + '.zcode/rules/general.md': 'GENERAL-MARKER: always run the tests before finishing.\n', + '.zcode/rules/typescript.md': '---\npaths: src/**/*.ts\n---\nTS-MARKER: prefer type aliases over interfaces.\n', + '.zcode/rules/proto.md': '---\npaths: *.proto\n---\nPROTO-MARKER: never renumber protobuf fields.\n', + '.zcode/rules/notes.txt': 'TXT-MARKER: not a rule file.\n', + 'src/app/main.ts': 'export {};\n', +}; + +// --------------------------------------------------------------------------- +// Unit tests: glob matcher, frontmatter, paths +// --------------------------------------------------------------------------- + +function globMatches(glob, file) { + return rules.matchesAny(file, rules.compileGlobs([glob])); +} + +await test('glob: ** spans zero or more directories', () => { + assert.ok(globMatches('src/**/*.ts', 'src/a.ts')); + assert.ok(globMatches('src/**/*.ts', 'src/x/y/a.ts')); + assert.ok(!globMatches('src/**/*.ts', 'lib/a.ts')); + assert.ok(!globMatches('src/**/*.ts', 'src/a.tsx')); + assert.ok(!globMatches('src/**/*.ts', 'src/a.ts.bak')); + assert.ok(globMatches('**/test/**', 'test/a.js')); + assert.ok(globMatches('**/test/**', 'x/test/y/z.js')); + assert.ok(globMatches('**', 'any/file/at/all.txt')); +}); + +await test('glob: * and ? stay inside one path segment', () => { + assert.ok(globMatches('src/*.ts', 'src/a.ts')); + assert.ok(!globMatches('src/*.ts', 'src/a/b.ts')); + assert.ok(globMatches('src/?.js', 'src/a.js')); + assert.ok(!globMatches('src/?.js', 'src/ab.js')); + assert.ok(!globMatches('a?b', 'a/b')); +}); + +await test('glob: slash-less patterns match the file name at any depth', () => { + assert.ok(globMatches('*.proto', 'api.proto')); + assert.ok(globMatches('*.proto', 'a/b/api.proto')); + assert.ok(!globMatches('*.proto', 'api.proto.txt')); + assert.ok(globMatches('Makefile', 'tools/Makefile')); +}); + +await test('glob: leading / or ./ anchors, trailing / means the whole directory', () => { + assert.ok(globMatches('/README.md', 'README.md')); + assert.ok(!globMatches('/README.md', 'docs/README.md')); + assert.ok(globMatches('./README.md', 'README.md')); + assert.ok(globMatches('docs/', 'docs/guide/intro.md')); + assert.ok(!globMatches('docs/', 'src/docs.ts')); +}); + +await test('glob: {a,b} alternatives, including empty and nested ones', () => { + assert.ok(globMatches('src/**/*.{ts,tsx}', 'src/a.tsx')); + assert.ok(globMatches('src/**/*.{ts,tsx}', 'src/b/c.ts')); + assert.ok(!globMatches('src/**/*.{ts,tsx}', 'src/b/c.js')); + assert.ok(globMatches('*.ts{,x}', 'a.ts')); + assert.ok(globMatches('*.ts{,x}', 'a.tsx')); + assert.ok(globMatches('{src,lib/{a,b}}/*.js', 'lib/b/x.js')); + assert.ok(rules.expandBraces('{a,b}{c,d}{e,f}{g,h}{i,j}{k,l}{m,n}').length <= 64); +}); + +await test('glob: regex metacharacters are literal', () => { + assert.ok(globMatches('a.b+c(1)', 'a.b+c(1)')); + assert.ok(!globMatches('a.b+c(1)', 'aXb+c(1)')); + assert.ok(!globMatches('a.b+c(1)', 'a.bbc(1)')); +}); + +await test('glob: hostile patterns match in linear time; a rule keeps 32 globs of up to 256 chars', () => { + const started = Date.now(); + // As backtracking RegExps these stalled for a minute or more. + assert.ok(!globMatches('**/'.repeat(12) + 'x', 'a/'.repeat(25) + 'y')); + assert.ok(globMatches('**/'.repeat(12) + 'y', 'a/'.repeat(25) + 'y')); + assert.ok(!globMatches('*a'.repeat(12) + 'b', 'a'.repeat(60))); + assert.ok(Date.now() - started < 200, 'took ' + (Date.now() - started) + ' ms'); + assert.strictEqual(rules.compileGlobs(new Array(40).fill('*.md')).length, 32); + assert.strictEqual(rules.compileGlobs(['a'.repeat(257), 'b'.repeat(256)]).length, 1); +}); + +await test('braces: one pass keeps order, empty and nested options, unbalanced literals, the 64 cap', () => { + assert.deepStrictEqual(rules.expandBraces('{a,b}{c,d}'), ['ac', 'ad', 'bc', 'bd']); + assert.deepStrictEqual(rules.expandBraces('x{,y,{z,w}}'), ['x', 'xy', 'xz', 'xw']); + assert.deepStrictEqual(rules.expandBraces('{a{b,c}'), ['{a{b,c}']); + assert.deepStrictEqual(rules.expandBraces('a}b{c}'), ['a}bc']); + assert.strictEqual(rules.expandBraces('{a,b}'.repeat(7)).length, 64); +}); + +await test('frontmatter: comma list, braces, quotes, inline and block lists, globs alias', () => { + assert.deepStrictEqual(rules.parseRule('---\npaths: src/**/*.ts, *.proto\n---\nbody').paths, [ + 'src/**/*.ts', + '*.proto', + ]); + assert.deepStrictEqual(rules.parseRule('---\npaths: src/**/*.{ts,tsx}, lib/*.js\n---\nx').paths, [ + 'src/**/*.{ts,tsx}', + 'lib/*.js', + ]); + assert.deepStrictEqual(rules.parseRule('---\npaths: ["a/**", \'b/*.md\']\n---\nx').paths, ['a/**', 'b/*.md']); + assert.deepStrictEqual( + rules.parseRule('---\ndescription: API rules\npaths:\n - "src/api/**/*.ts"\n - docs/api/\n---\nx').paths, + ['src/api/**/*.ts', 'docs/api/'] + ); + assert.deepStrictEqual(rules.parseRule('---\nglobs: *.sql\nalwaysApply: false\n---\nx').paths, ['*.sql']); + assert.deepStrictEqual( + rules.parseRule('---\npaths: src/**/*.ts # TypeScript only\nother:\n - x\n---\nx').paths, + ['src/**/*.ts'] + ); + assert.deepStrictEqual(rules.parseRule('---\npaths:\n - lib/** # libraries\n - "a#b"\n---\nx').paths, ['lib/**', 'a#b']); +}); + +await test('frontmatter: optional, BOM and CRLF tolerant, body excludes frontmatter', () => { + const plain = rules.parseRule('Just a rule.\n'); + assert.deepStrictEqual(plain, { paths: [], body: 'Just a rule.' }); + const crlf = rules.parseRule('\ufeff---\r\npaths: *.go\r\n---\r\nGo rule.\r\n'); + assert.deepStrictEqual(crlf, { paths: ['*.go'], body: 'Go rule.' }); + assert.deepStrictEqual(rules.parseRule('---\npaths:\n---\nGlobal.').paths, []); + assert.deepStrictEqual(rules.parseRule('---\n---\nEmpty frontmatter.'), { paths: [], body: 'Empty frontmatter.' }); + const midLine = rules.parseRule('---\npaths: a---b\nmore: x\n---\nBody.'); + assert.deepStrictEqual(midLine, { paths: ['a---b'], body: 'Body.' }); +}); + +await test('frontmatter: an inline list with a trailing comment', () => { + assert.deepStrictEqual(rules.parseRule('---\npaths: ["src/**"] # api\n---\nx').paths, ['src/**']); + assert.deepStrictEqual(rules.parseRule('---\npaths: [a/*.ts, "b]c"] # see [docs]\n---\nx').paths, ['a/*.ts', 'b]c']); +}); + +await test('paths: absolute and relative inside the project, null outside', () => { + const project = makeProject({ 'src/a.ts': '' }); + assert.strictEqual(rules.toProjectRelative(path.join(project, 'src', 'a.ts'), project), 'src/a.ts'); + assert.strictEqual(rules.toProjectRelative('src/a.ts', project), 'src/a.ts'); + assert.strictEqual(rules.toProjectRelative(path.join(project, 'new', 'file.ts'), project), 'new/file.ts'); + assert.strictEqual(rules.toProjectRelative('../elsewhere/a.ts', project), null); + assert.strictEqual(rules.toProjectRelative(path.join(tmpRoot, 'x.ts'), project), null); + assert.strictEqual(rules.toProjectRelative(project, project), null); + assert.strictEqual(rules.toProjectRelative(undefined, project), null); + // macOS: /var is a symlink to /private/var; either spelling must resolve. + const real = fs.realpathSync(project); + assert.strictEqual(rules.toProjectRelative(path.join(real, 'src', 'a.ts'), project), 'src/a.ts'); +}); + +// --------------------------------------------------------------------------- +// Hook process tests +// --------------------------------------------------------------------------- + +const project = makeProject(BASE_FILES); +const dataDir = makeDataDir(); +const mainTs = path.join(project, 'src', 'app', 'main.ts'); + +await test('SessionStart injects CLAUDE.md and path-less rules only', () => { + const sid = newSession(); + const context = assertContext(callHook(sessionStart(sid, project), project, dataDir), 'SessionStart'); + assert.ok(context.includes('CLAUDE-MARKER')); + assert.ok(context.includes('## CLAUDE.md')); + assert.ok(context.includes('GENERAL-MARKER')); + assert.ok(!context.includes('TS-MARKER')); + assert.ok(!context.includes('PROTO-MARKER')); + assert.ok(!context.includes('TXT-MARKER')); + assert.ok(context.startsWith('') && context.endsWith('')); +}); + +await test('SessionStart skips CLAUDE.md when AGENTS.md exists at the root', () => { + const withAgents = makeProject(Object.assign({}, BASE_FILES, { 'AGENTS.md': 'agents file\n' })); + const context = assertContext( + callHook(sessionStart(newSession(), withAgents), withAgents, makeDataDir()), + 'SessionStart' + ); + assert.ok(!context.includes('CLAUDE-MARKER')); + assert.ok(context.includes('GENERAL-MARKER')); +}); + +await test('rule files and CLAUDE.md that resolve outside the project are skipped', () => { + if (process.platform === 'win32') return; // creating symlinks needs extra privileges there + const secret = path.join(makeProject({ 'secret.md': 'SECRET-MARKER: outside the project.\n' }), 'secret.md'); + const linked = makeProject({ 'docs/shared.md': 'SHARED-MARKER: inside the project.\n', '.zcode/rules/.keep': '' }); + fs.symlinkSync(secret, path.join(linked, '.zcode', 'rules', 'x.md')); + fs.symlinkSync(secret, path.join(linked, 'CLAUDE.md')); + fs.symlinkSync(path.join('..', '..', 'docs', 'shared.md'), path.join(linked, '.zcode', 'rules', 'shared.md')); + const data = makeDataDir(); + const sid = newSession(); + const context = assertContext(callHook(sessionStart(sid, linked), linked, data), 'SessionStart'); + assert.ok(context.includes('SHARED-MARKER'), 'a symlink that stays inside the project still works'); + assert.ok(!context.includes('SECRET-MARKER')); + assertSilent(callHook(preToolUse(sid, linked, 'Read', path.join(linked, 'docs', 'shared.md')), linked, data)); +}); + +await test('SessionStart with nothing to inject prints nothing', () => { + const empty = makeProject({ 'README.md': 'hi\n' }); + assertSilent(callHook(sessionStart(newSession(), empty), empty, makeDataDir())); +}); + +await test('PreToolUse Read on a matching path injects that rule once, then dedupes', () => { + const sid = newSession(); + callHook(sessionStart(sid, project), project, dataDir); + + const first = callHook(preToolUse(sid, project, 'Read', mainTs), project, dataDir); + const context = assertContext(first, 'PreToolUse'); + assert.ok(context.includes('TS-MARKER')); + assert.ok(context.includes('Applies to: src/**/*.ts (matched src/app/main.ts)')); + assert.ok(!context.includes('GENERAL-MARKER'), 'global rule was already delivered at SessionStart'); + assert.ok(!context.includes('PROTO-MARKER')); + assert.ok(!context.includes('CLAUDE-MARKER'), 'CLAUDE.md is SessionStart-only'); + + // Same file again, and a different matching file: already delivered. + assertSilent(callHook(preToolUse(sid, project, 'Read', mainTs), project, dataDir)); + assertSilent(callHook(preToolUse(sid, project, 'Edit', path.join(project, 'src', 'other.ts')), project, dataDir)); +}); + +await test('PreToolUse on a non-matching path prints nothing', () => { + const sid = newSession(); + callHook(sessionStart(sid, project), project, dataDir); + assertSilent(callHook(preToolUse(sid, project, 'Read', path.join(project, 'README.md')), project, dataDir)); + assertSilent(callHook(preToolUse(sid, project, 'Read', path.join(project, 'src', 'main.js')), project, dataDir)); +}); + +await test('PreToolUse outside the project matches no path rules', () => { + const sid = newSession(); + callHook(sessionStart(sid, project), project, dataDir); + const outside = makeProject({ 'src/x.ts': '' }); + assertSilent(callHook(preToolUse(sid, project, 'Read', path.join(outside, 'src', 'x.ts')), project, dataDir)); +}); + +await test('content change re-injects the rule once', () => { + const changing = makeProject(BASE_FILES); + const data = makeDataDir(); + const sid = newSession(); + const file = path.join(changing, 'src', 'app', 'main.ts'); + callHook(sessionStart(sid, changing), changing, data); + assert.ok(assertContext(callHook(preToolUse(sid, changing, 'Read', file), changing, data), 'PreToolUse').includes('TS-MARKER')); + assertSilent(callHook(preToolUse(sid, changing, 'Read', file), changing, data)); + + fs.writeFileSync( + path.join(changing, '.zcode', 'rules', 'typescript.md'), + '---\npaths: src/**/*.ts\n---\nTS-MARKER-V2: use strict null checks.\n' + ); + const updated = assertContext(callHook(preToolUse(sid, changing, 'Edit', file), changing, data), 'PreToolUse'); + assert.ok(updated.includes('TS-MARKER-V2')); + assertSilent(callHook(preToolUse(sid, changing, 'Write', file), changing, data)); + + // A changed path-less rule is re-delivered with the next Read/Edit/Write call. + fs.writeFileSync(path.join(changing, '.zcode', 'rules', 'general.md'), 'GENERAL-MARKER-V2: changed.\n'); + const general = assertContext( + callHook(preToolUse(sid, changing, 'Read', path.join(changing, 'README.md')), changing, data), + 'PreToolUse' + ); + assert.ok(general.includes('GENERAL-MARKER-V2')); +}); + +await test('Edit with a relative path matches a slash-less glob at depth', () => { + const sid = newSession(); + callHook(sessionStart(sid, project), project, dataDir); + const context = assertContext(callHook(preToolUse(sid, project, 'Edit', 'api/v1/user.proto'), project, dataDir), 'PreToolUse'); + assert.ok(context.includes('PROTO-MARKER')); + assert.ok(context.includes('(matched api/v1/user.proto)')); +}); + +await test('globs are compiled only when a pending path rule is matched', () => { + const lazy = makeProject({ '.zcode/rules/lazy.md': '---\npaths: ' + 'z'.repeat(300) + ', *.ts\n---\nLAZY-MARKER\n' }); + const data = makeDataDir(); + const env = Object.assign(hookEnv(lazy, data), { PROJECT_RULES_DEBUG: '1' }); + const call = (payload) => callHook(payload, lazy, data, { env }); + const compiled = /ignoring a glob over 256 chars/; + const sid = newSession(); + assert.doesNotMatch(call(sessionStart(sid, lazy)).stderr, compiled); + const first = call(preToolUse(sid, lazy, 'Read', 'a.ts')); + assert.ok(assertContext(first, 'PreToolUse').includes('LAZY-MARKER'), 'the short glob still applies'); + assert.match(first.stderr, compiled); + const delivered = call(preToolUse(sid, lazy, 'Read', 'b.ts')); + assertSilent(delivered); + assert.doesNotMatch(delivered.stderr, compiled, 'a delivered rule is not compiled again'); +}); + +await test('PreToolUse without a prior SessionStart also delivers path-less rules', () => { + const sid = newSession(); + const context = assertContext(callHook(preToolUse(sid, project, 'Write', mainTs), project, dataDir), 'PreToolUse'); + assert.ok(context.includes('TS-MARKER')); + assert.ok(context.includes('GENERAL-MARKER')); + assert.ok(context.indexOf('TS-MARKER') < context.indexOf('GENERAL-MARKER'), 'matching rules come first'); +}); + +await test('tools other than Read/Edit/Write are ignored', () => { + const sid = newSession(); + assertSilent(callHook(preToolUse(sid, project, 'Bash', mainTs), project, dataDir)); + assertSilent(callHook(preToolUse(sid, project, 'Grep', mainTs), project, dataDir)); +}); + +await test('SessionStart (resume/clear/compact) re-arms rules for the session', () => { + const sid = newSession(); + callHook(sessionStart(sid, project), project, dataDir); + assertContext(callHook(preToolUse(sid, project, 'Read', mainTs), project, dataDir), 'PreToolUse'); + assertSilent(callHook(preToolUse(sid, project, 'Read', mainTs), project, dataDir)); + + const again = assertContext(callHook(sessionStart(sid, project, 'compact'), project, dataDir), 'SessionStart'); + assert.ok(again.includes('CLAUDE-MARKER')); + assert.ok(assertContext(callHook(preToolUse(sid, project, 'Read', mainTs), project, dataDir), 'PreToolUse').includes('TS-MARKER')); +}); + +await test('Claude-style snake_case-only payloads work', () => { + const sid = newSession(); + const payload = { + session_id: sid, + hook_event_name: 'PreToolUse', + cwd: project, + tool_name: 'Read', + tool_input: { file_path: mainTs }, + }; + const env = hookEnv(project, dataDir); + delete env.ZCODE_PROJECT_DIR; + const context = assertContext(callHook(payload, project, dataDir, { env }), 'PreToolUse'); + assert.ok(context.includes('TS-MARKER')); +}); + +await test('a long rule is cut at 4000 chars with a truncation note', () => { + const body = 'LONG-START ' + 'x'.repeat(9000) + ' LONG-END'; + const big = makeProject({ '.zcode/rules/big.md': '---\npaths: *.md\n---\n' + body }); + const context = assertContext( + callHook(preToolUse(newSession(), big, 'Read', path.join(big, 'README.md')), big, makeDataDir()), + 'PreToolUse' + ); + assert.ok(context.includes('LONG-START')); + assert.ok(!context.includes('LONG-END')); + assert.ok(context.includes('[project-rules: truncated, showing the first 4000 of ' + body.length + ' characters. Read .zcode/rules/big.md for the rest.]')); + assert.ok(context.length < rules.limits.RULE_MAX_CHARS + 600); +}); + +await test('total budget defers extra rules to later calls; each lands exactly once', () => { + const files = {}; + // Sized so three blocks pass the packing check but overflow 12000 once the + // wrapper and the deferral note are added: the final-text cap must bind. + for (let i = 1; i <= 5; i += 1) files['.zcode/rules/r' + i + '.md'] = 'RULE-' + i + '-MARKER ' + 'y'.repeat(3880) + '\n'; + const many = makeProject(files); + const data = makeDataDir(); + const sid = newSession(); + const seen = {}; + const record = (context) => { + assert.ok(context.length <= rules.limits.TOTAL_MAX_CHARS, 'context too long: ' + context.length); + for (let i = 1; i <= 5; i += 1) if (context.includes('RULE-' + i + '-MARKER')) seen[i] = (seen[i] || 0) + 1; + }; + + const start = assertContext(callHook(sessionStart(sid, many), many, data), 'SessionStart'); + record(start); + assert.ok(start.includes('more rule(s) deferred to keep context small')); + assert.ok(Object.keys(seen).length < 5); + + for (let call = 0; call < 3; call += 1) { + const result = callHook(preToolUse(sid, many, 'Read', path.join(many, 'any.txt')), many, data); + if (result.context) record(result.context); + } + assert.deepStrictEqual(seen, { 1: 1, 2: 1, 3: 1, 4: 1, 5: 1 }); +}); + +await test('multi-byte text that fits the char budget is still held under the 32 KiB stdout limit', () => { + // About 11000 chars (inside the 12000 char budget) but about 33000 UTF-8 bytes. + const cjk = makeProject({ + 'CLAUDE.md': 'CJK-CLAUDE ' + '\u89c4'.repeat(7900) + '\n', + '.zcode/rules/cjk.md': 'CJK-RULE ' + '\u5219'.repeat(3000) + '\n', + }); + const data = makeDataDir(); + const sid = newSession(); + const result = callHook(sessionStart(sid, cjk), cjk, data); + assertContext(result, 'SessionStart'); + assert.ok(result.context.length <= rules.limits.TOTAL_MAX_CHARS); + assert.ok(Buffer.byteLength(result.stdout) <= rules.limits.MAX_STDOUT_BYTES, 'bytes: ' + Buffer.byteLength(result.stdout)); + assert.ok(result.context.includes('CJK-CLAUDE')); + assert.ok(!result.context.includes('CJK-RULE')); + assert.ok(result.context.includes('1 more rule(s) deferred'), 'the rule is deferred, not dropped'); + const later = assertContext(callHook(preToolUse(sid, cjk, 'Read', path.join(cjk, 'x.txt')), cjk, data), 'PreToolUse'); + assert.ok(later.includes('CJK-RULE')); +}); + +await test('a single block that escapes past the byte limit is shrunk, not dropped', () => { + // Control characters serialize as 6-byte \u escapes: 8000 chars -> 48000 bytes. + const escaped = makeProject({ 'CLAUDE.md': 'ESC-START ' + '\u0001'.repeat(8000) + '\n' }); + const result = callHook(sessionStart(newSession(), escaped), escaped, makeDataDir()); + assertContext(result, 'SessionStart'); + assert.ok(Buffer.byteLength(result.stdout) <= rules.limits.MAX_STDOUT_BYTES, 'bytes: ' + Buffer.byteLength(result.stdout)); + assert.ok(result.context.includes('ESC-START')); + assert.ok(result.context.includes('Read CLAUDE.md for the rest.')); +}); + +await test('rule files share a 2 MiB read budget per call; files that do not fit are skipped', () => { + const pad = 'p'.repeat(900 * 1024); + const heavy = makeProject({ + '.zcode/rules/r1.md': 'R1-MARKER ' + pad, + '.zcode/rules/r2.md': 'R2-MARKER ' + pad, + '.zcode/rules/r3.md': 'R3-MARKER ' + pad, + '.zcode/rules/r4.md': 'R4-MARKER small\n', + }); + const context = assertContext(callHook(sessionStart(newSession(), heavy), heavy, makeDataDir()), 'SessionStart'); + for (const marker of ['R1-MARKER', 'R2-MARKER', 'R4-MARKER']) assert.ok(context.includes(marker), marker); + assert.ok(!context.includes('R3-MARKER') && !context.includes('r3.md'), 'r3 is not loaded, not even deferred'); +}); + +await test('malformed, empty, and unknown-event stdin exit 0 silently', () => { + assertSilent(callHook(null, project, dataDir, { raw: '{not json' })); + assertSilent(callHook(null, project, dataDir, { raw: '' })); + assertSilent(callHook(null, project, dataDir, { raw: '[1,2,3]\n' })); + assertSilent(callHook({ hookEventName: 'Stop', sessionId: newSession(), cwd: project }, project, dataDir)); +}); + +await test('stdin over 1 MiB is drained and ignored: exit 0, no output', () => { + const payload = Object.assign(sessionStart(newSession(), project), { padding: 'x'.repeat(1024 * 1024) }); + const child = spawnSync(process.execPath, [hookScript], { + input: JSON.stringify(payload), + env: hookEnv(project, makeDataDir()), + encoding: 'utf8', + }); + assert.strictEqual(child.error, undefined, 'the hook read all of stdin, so the writer saw no EPIPE'); + assertSilent(parseResult(child.status, child.stdout, child.stderr)); +}); + +await test('corrupt state.json is replaced, not fatal', () => { + const data = makeDataDir(); + fs.writeFileSync(path.join(data, 'state.json'), '{"sessions": oops'); + const context = assertContext(callHook(sessionStart(newSession(), project), project, data), 'SessionStart'); + assert.ok(context.includes('CLAUDE-MARKER')); + const state = JSON.parse(fs.readFileSync(path.join(data, 'state.json'), 'utf8')); + assert.strictEqual(state.version, 1); + + // A session entry without a rules map is rebuilt instead of failing every call. + const sid = newSession(); + state.sessions[sid] = { updatedAt: Date.now() }; + fs.writeFileSync(path.join(data, 'state.json'), JSON.stringify(state)); + assert.ok(assertContext(callHook(preToolUse(sid, project, 'Read', mainTs), project, data), 'PreToolUse').includes('TS-MARKER')); +}); + +await test('array-shaped state maps and a __proto__ session id still dedupe', () => { + const cases = [ + ['array-sessions', []], + ['array-rules', { 'array-rules': { updatedAt: Date.now(), rules: [] } }], + ['__proto__', {}], + ]; + for (const [sid, sessions] of cases) { + const data = makeDataDir(); + fs.writeFileSync(path.join(data, 'state.json'), JSON.stringify({ version: 1, sessions })); + const first = callHook(preToolUse(sid, project, 'Read', mainTs), project, data); + assert.ok(assertContext(first, 'PreToolUse').includes('TS-MARKER'), sid); + assertSilent(callHook(preToolUse(sid, project, 'Read', mainTs), project, data)); + } +}); + +await test('sessions idle for more than 7 days are pruned from state', () => { + const data = makeDataDir(); + const day = 24 * 60 * 60 * 1000; + fs.writeFileSync( + path.join(data, 'state.json'), + JSON.stringify({ + version: 1, + sessions: { + 'old-session': { updatedAt: Date.now() - 8 * day, rules: { '/x.md': 'abc' } }, + 'recent-session': { updatedAt: Date.now() - 1 * day, rules: { '/y.md': 'def' } }, + 'broken-session': null, + }, + }) + ); + const sid = newSession(); + callHook(sessionStart(sid, project), project, data); + const state = JSON.parse(fs.readFileSync(path.join(data, 'state.json'), 'utf8')); + assert.ok(!('old-session' in state.sessions)); + assert.ok(!('broken-session' in state.sessions)); + assert.ok('recent-session' in state.sessions); + assert.ok(sid in state.sessions); + assert.ok(!fs.existsSync(path.join(data, 'state.lock')), 'lock file released'); +}); + +await test('a state write refreshes updatedAt, which the 7-day TTL counts from', () => { + const data = makeDataDir(); + const sid = newSession(); + const sixDaysAgo = Date.now() - 6 * 24 * 60 * 60 * 1000; + const sessions = { [sid]: { updatedAt: sixDaysAgo, rules: {} } }; + fs.writeFileSync(path.join(data, 'state.json'), JSON.stringify({ version: 1, sessions })); + const before = Date.now(); + assertContext(callHook(preToolUse(sid, project, 'Read', mainTs), project, data), 'PreToolUse'); + const state = JSON.parse(fs.readFileSync(path.join(data, 'state.json'), 'utf8')); + assert.ok(state.sessions[sid].updatedAt >= before, 'still ' + state.sessions[sid].updatedAt); +}); + +await test('parallel tool calls inject a rule exactly once', async () => { + const data = makeDataDir(); + const sid = newSession(); + callHook(sessionStart(sid, project), project, data); + const held = spawnHeldHooks(16, preToolUse(sid, project, 'Read', mainTs), project, data); + // Let every process finish booting and block on stdin, then release them together. + await new Promise((resolve) => setTimeout(resolve, 1000)); + const results = await held.release(); + for (const result of results) assert.strictEqual(result.status, 0, result.stderr); + const injected = results.filter((result) => result.context && result.context.includes('TS-MARKER')); + assert.strictEqual(injected.length, 1); +}); + +await test('a held state lock makes the hook wait for it', async () => { + const data = makeDataDir(); + const lockFile = path.join(data, 'state.lock'); + fs.writeFileSync(lockFile, ''); + const held = spawnHeldHooks(1, preToolUse(newSession(), project, 'Read', mainTs), project, data); + const finished = held.release().then((results) => ({ results, at: Date.now() })); + await new Promise((resolve) => setTimeout(resolve, 600)); + const releasedAt = Date.now(); + fs.unlinkSync(lockFile); + const { results, at } = await finished; + assert.ok(at >= releasedAt, 'hook finished ' + (releasedAt - at) + ' ms before the lock was released'); + assert.ok(results[0].context.includes('TS-MARKER')); +}); + +await test('a stale state lock is reclaimed', () => { + const data = makeDataDir(); + const lockFile = path.join(data, 'state.lock'); + fs.writeFileSync(lockFile, ''); + const old = (Date.now() - 60000) / 1000; + fs.utimesSync(lockFile, old, old); + const started = Date.now(); + const result = callHook(preToolUse(newSession(), project, 'Read', mainTs), project, data); + assert.ok(assertContext(result, 'PreToolUse').includes('TS-MARKER')); + assert.ok(Date.now() - started < 1900, 'should not wait out the 2 s lock deadline'); + assert.ok(!fs.existsSync(lockFile)); +}); + +await test('an old state.lock that cannot be removed (a directory) waits out the deadline, no spin', () => { + const data = makeDataDir(); + const lockDir = path.join(data, 'state.lock'); + fs.mkdirSync(lockDir); + const old = (Date.now() - 60000) / 1000; + fs.utimesSync(lockDir, old, old); + const started = Date.now(); + const result = callHook(preToolUse(newSession(), project, 'Read', mainTs), project, data); + assert.ok(Date.now() - started < 2500, 'took ' + (Date.now() - started) + ' ms'); + assert.ok(assertContext(result, 'PreToolUse').includes('TS-MARKER')); + assert.ok(fs.statSync(lockDir).isDirectory()); +}); + +await test('a lock the hook does not own is never removed', () => { + // A live lock outlasting the wait: the hook proceeds unlocked and leaves it alone. + const data = makeDataDir(); + const lockFile = path.join(data, 'state.lock'); + fs.writeFileSync(lockFile, 'live'); + assert.ok(assertContext(callHook(preToolUse(newSession(), project, 'Read', mainTs), project, data), 'PreToolUse').includes('TS-MARKER')); + assert.strictEqual(fs.readFileSync(lockFile, 'utf8'), 'live'); + // Our lock, reclaimed by another hook while we stalled: release leaves the new owner's lock. + fs.unlinkSync(lockFile); + rules.withLock(data, () => { + fs.unlinkSync(lockFile); + fs.writeFileSync(lockFile, 'new owner'); + }); + assert.strictEqual(fs.readFileSync(lockFile, 'utf8'), 'new owner'); +}); + +await test('unwritable data dir: SessionStart still delivers, PreToolUse stays silent', () => { + const blocker = path.join(tmpRoot, 'not-a-directory'); + fs.writeFileSync(blocker, 'file in the way'); + const env = hookEnv(project, blocker); + const sid = newSession(); + const start = callHook(sessionStart(sid, project), project, blocker, { env }); + assert.ok(assertContext(start, 'SessionStart').includes('CLAUDE-MARKER')); + // Without state every call would repeat the rule, so nothing is emitted. + assertSilent(callHook(preToolUse(sid, project, 'Read', mainTs), project, blocker, { env })); +}); + +await test('without ZCODE_PLUGIN_DATA, state goes to a private ~/.zcode/project-rules', () => { + const home = fs.mkdtempSync(path.join(tmpRoot, 'home-')); + const env = Object.assign(hookEnv(project, ''), { HOME: home, USERPROFILE: home }); + delete env.ZCODE_PLUGIN_DATA; + const sid = newSession(); + assertContext(callHook(sessionStart(sid, project), project, null, { env }), 'SessionStart'); + const dir = path.join(home, '.zcode', 'project-rules'); + assert.ok(sid in JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).sessions); + if (process.platform !== 'win32') { + assert.strictEqual(fs.statSync(dir).mode & 0o777, 0o700); + assert.strictEqual(fs.statSync(path.join(dir, 'state.json')).mode & 0o777, 0o600); + } +}); + +await test('hooks.json and plugin.json match the plugin contract', () => { + const hooks = JSON.parse(fs.readFileSync(path.join(pluginRoot, 'hooks', 'hooks.json'), 'utf8')).hooks; + assert.deepStrictEqual(Object.keys(hooks).sort(), ['PreToolUse', 'SessionStart']); + assert.strictEqual(hooks.PreToolUse[0].matcher, 'Read|Edit|Write'); + for (const event of ['PreToolUse', 'SessionStart']) { + const hook = hooks[event][0].hooks[0]; + assert.strictEqual(hook.type, 'command'); + assert.strictEqual(hook.command, 'node "${ZCODE_PLUGIN_ROOT}/hooks/rules.js"'); + } + const manifest = JSON.parse(fs.readFileSync(path.join(pluginRoot, '.zcode-plugin', 'plugin.json'), 'utf8')); + assert.strictEqual(manifest.name, 'project-rules'); + assert.match(manifest.version, /^\d+\.\d+\.\d+$/); + assert.ok(manifest.description_i18n.en && manifest.description_i18n['zh-CN']); + assert.ok(!('hooks' in manifest), 'standard hooks/hooks.json is auto-discovered; do not declare it twice'); +}); + +fs.rmSync(tmpRoot, { recursive: true, force: true }); +console.log('\n' + passes + ' passed, ' + failures + ' failed'); +process.exitCode = failures ? 1 : 0; + +// Symlinked file_path: outside target excluded, inside target still matches. +{ + const dir = makeProject({ + '.zcode/rules/ts-style.md': '---\npaths: "src/**/*.ts"\n---\nTS rule marker.', + '.zcode/rules/all.md': '---\n---\nAll marker.', + 'src/inbound.ts': '// real file', + }); + const outsideTarget = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pr-outside-')), 'outside.ts'); + fs.writeFileSync(outsideTarget, '// outside target'); + const escapeLink = path.join(dir, 'src', 'escape.ts'); + fs.symlinkSync(outsideTarget, escapeLink); + const inboundLink = path.join(dir, 'src', 'inbound-link.ts'); + fs.symlinkSync(path.join(dir, 'src', 'inbound.ts'), inboundLink); + const data = makeDataDir(); + + const escapeCtx = callHook(preToolUse('symlink-escape-1', dir, 'Edit', escapeLink), dir, data); + const escapeText = JSON.stringify(escapeCtx.context ?? null); + test('symlinked file_path resolving outside the project injects no path rule', () => { + assert.ok(!escapeText.includes('TS rule marker'), 'path rule must not fire for outside target'); + }); + + const inboundCtx = callHook(preToolUse('symlink-escape-2', dir, 'Read', inboundLink), dir, data); + const inboundText = JSON.stringify(inboundCtx.context ?? null); + test('symlinked file_path resolving inside the project still matches', () => { + assert.ok(inboundText.includes('TS rule marker'), 'path rule must fire for inside target'); + }); +} diff --git a/marketplace.json b/marketplace.json index c7f09a06..74b3e645 100644 --- a/marketplace.json +++ b/marketplace.json @@ -1443,6 +1443,39 @@ "mcp", "verification" ] - } + }, + { + "name": "project-rules", + "source": "./external_plugins/project-rules", + "version": "0.1.0", + "description": "Load CLAUDE.md and path-scoped rule files (.zcode/rules/*.md with paths: globs) into ZCode sessions, so projects coming from Claude Code or Cursor keep their instructions. Hooks only, no network.", + "description_i18n": { + "en": "Load CLAUDE.md and path-scoped rule files (.zcode/rules/*.md with paths: globs) into ZCode sessions, so projects coming from Claude Code or Cursor keep their instructions. Hooks only, no network.", + "zh-CN": "将 CLAUDE.md 与按路径生效的规则文件 (.zcode/rules/*.md, 支持 paths: glob) 注入 ZCode 会话, 让从 Claude Code 或 Cursor 迁移的项目保留原有指令. 仅使用 Hook, 不访问网络." + }, + "author": { + "name": "qubn", + "url": "https://github.com/dominiclafleur" + }, + "homepage": "https://github.com/dominiclafleur/zcode-project-rules", + "repository": "https://github.com/dominiclafleur/zcode-project-rules", + "license": "MIT", + "category": "developer-tools", + "tags": [ + "rules", + "claude-md", + "context", + "hooks", + "migration" + ], + "keywords": [ + "rules", + "claude-md", + "context", + "hooks", + "migration", + "cursor-rules" + ] + } ] } \ No newline at end of file