diff --git a/.devin-plugin/plugin.json b/.devin-plugin/plugin.json
new file mode 100644
index 0000000..f90cbad
--- /dev/null
+++ b/.devin-plugin/plugin.json
@@ -0,0 +1,22 @@
+{
+ "name": "module-gates",
+ "version": "1.0.3",
+ "description": "Enforce module boundary contracts for Devin CLI.",
+ "author": {
+ "name": "Cause Chung",
+ "email": "cuzfrog@gmail.com"
+ },
+ "homepage": "https://github.com/cuzfrog/module-gates",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/cuzfrog/module-gates.git"
+ },
+ "license": "MIT",
+ "keywords": [
+ "module",
+ "gate",
+ "boundary",
+ "devin"
+ ],
+ "skills": "./skills"
+}
diff --git a/README.ja.md b/README.ja.md
index 5199c24..7693468 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -13,6 +13,7 @@
対応しているエージェントフレームワーク:
- **pi** — pi 拡張機能
- **Claude Code** — プラグイン、または CLI でインストールしたプレーンフック
+- **Devin CLI** — プラグイン、または CLI でインストールしたプレーンフック
他のエージェント(qwen-code、cursor 等)への対応はブリッジを追加することで実現できます。
@@ -43,7 +44,9 @@
## インストール
-### pi
+
+pi
+
```bash
pi install npm:@cuzfrog/module-gates
```
@@ -52,7 +55,10 @@ pi install npm:@cuzfrog/module-gates
pi -e npm:@cuzfrog/module-gates
```
-### Claude Code
+
+
+
+Claude Code
このリポジトリのマーケットプレイスからプラグインとして(ログイン不要 — 公開リポジトリ):
```
@@ -97,7 +103,55 @@ npx module-gates install-claude
}
}
```
-pi のインストールディレクトリは異なる場合がある;pi npm ルテム下の `run.mjs` を探す。`SessionStart` フック(システムプロンプト注入)は省略可能 — `PreToolUse` のみでゲートを強制する。
+pi のインストールディレクトリは異なる場合がある;pi npm ルート下の `run.mjs` を探す。`SessionStart` フック(システムプロンプト注入)は省略可能 — `PreToolUse` のみでゲートを強制する。
+
+
+
+
+Devin CLI
+
+プラグインとして(プロジェクトにパッケージをインストールする必要あり):
+```bash
+npm install --save-dev @cuzfrog/module-gates
+devin plugins install cuzfrog/module-gates
+```
+
+または、通常のフックとしてプロジェクトに接続する:
+```bash
+npm install --save-dev @cuzfrog/module-gates
+npx module-gates install-devin
+```
+これは `PreToolUse` と `SessionStart` フックを `.devin/hooks.v1.json` に書き込む;`npx module-gates uninstall-devin` で削除する。`SessionStart` フックは自動的にシステムプロンプトヒントを注入する。
+
+または `.devin/hooks.v1.json` で手動でフックを指す:
+```json
+{
+ "PreToolUse": [
+ {
+ "matcher": "^(write|edit|apply_patch)$",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"${DEVIN_PROJECT_DIR}/node_modules/@cuzfrog/module-gates/src/bridges/devin/run.mjs\" pre-tool-use"
+ }
+ ]
+ }
+ ],
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"${DEVIN_PROJECT_DIR}/node_modules/@cuzfrog/module-gates/src/bridges/devin/run.mjs\" session-start"
+ }
+ ]
+ }
+ ]
+}
+```
+グローバルまたはカスタムインストールの場合、`${DEVIN_PROJECT_DIR}/node_modules` をパッケージが存在するパス(例:`$(npm root -g)`)に置き換える。`SessionStart` フック(システムプロンプト注入)は省略可能 — `PreToolUse` のみでゲートを強制する。
+
+
## モジュール記述子の意味論
diff --git a/README.md b/README.md
index 842785b..798020b 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ Hooks that controls the entropy of the codebase by enforcing module boundaries,
Supported agent harnesses:
- **pi** — pi extension
- **Claude Code** — plugin, or plain hooks installed by the CLI
+- **Devin CLI** — plugin, or plain hooks installed by the CLI
Adding support for another agent (qwen-code, cursor, ...) means adding a bridge.
@@ -43,7 +44,9 @@ The attempt to add 2 public helper functions is blocked, forcing the agent to re
## Installation
-### pi
+
+pi
+
```bash
pi install npm:@cuzfrog/module-gates
```
@@ -52,7 +55,10 @@ Or load directly for a single session:
pi -e npm:@cuzfrog/module-gates
```
-### Claude Code
+
+
+
+Claude Code
As a plugin, from this repository's marketplace (no login required — public repo):
```
@@ -99,6 +105,54 @@ Or reuse an existing pi installation by pointing hooks at it manually in `~/.cla
```
The pi install directory may differ; locate `run.mjs` under your pi npm root. The `SessionStart` hook (system prompt injection) is optional — `PreToolUse` alone enforces the gates.
+
+
+
+Devin CLI
+
+As a plugin (requires the package installed in the project):
+```bash
+npm install --save-dev @cuzfrog/module-gates
+devin plugins install cuzfrog/module-gates
+```
+
+Or as plain hooks wired into a project:
+```bash
+npm install --save-dev @cuzfrog/module-gates
+npx module-gates install-devin
+```
+This writes `PreToolUse` and `SessionStart` hooks into `.devin/hooks.v1.json`; `npx module-gates uninstall-devin` removes them. The `SessionStart` hook injects the system prompt hint automatically.
+
+Or point at the package manually in `.devin/hooks.v1.json`:
+```json
+{
+ "PreToolUse": [
+ {
+ "matcher": "^(write|edit|apply_patch)$",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"${DEVIN_PROJECT_DIR}/node_modules/@cuzfrog/module-gates/src/bridges/devin/run.mjs\" pre-tool-use"
+ }
+ ]
+ }
+ ],
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"${DEVIN_PROJECT_DIR}/node_modules/@cuzfrog/module-gates/src/bridges/devin/run.mjs\" session-start"
+ }
+ ]
+ }
+ ]
+}
+```
+For a global or custom install, replace `${DEVIN_PROJECT_DIR}/node_modules` with the path where the package lives (e.g. `$(npm root -g)`). The `SessionStart` hook (system prompt injection) is optional — `PreToolUse` alone enforces the gates.
+
+
+
## Module Descriptor Semantics
A module descriptor is a Markdown file (default name: `MODULE.md`) placed in a directory. You can piggy-back on your module context file for example `CONTEXT.md`. A `MODULE.md` only enforces its own immediate directory.
diff --git a/README.zh.md b/README.zh.md
index 59d853d..b3c67cc 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -13,6 +13,7 @@ Hooks 通过强制模块边界来控制代码库的熵,帮助对抗代码Slops
支持的代理框架:
- **pi** — pi 扩展
- **Claude Code** — 插件,或通过 CLI 安装的普通 hooks
+- **Devin CLI** — 插件,或通过 CLI 安装的普通 hooks
添加对其他代理(qwen-code、cursor 等)的支持意味着添加一个桥接层。
@@ -43,7 +44,9 @@ Hooks 通过强制模块边界来控制代码库的熵,帮助对抗代码Slops
## 安装
-### pi
+
+pi
+
```bash
pi install npm:@cuzfrog/module-gates
```
@@ -52,7 +55,10 @@ pi install npm:@cuzfrog/module-gates
pi -e npm:@cuzfrog/module-gates
```
-### Claude Code
+
+
+
+Claude Code
作为插件,从本仓库的市场安装(无需登录 — 公开仓库):
```
@@ -99,6 +105,54 @@ npx module-gates install-claude
```
pi 安装目录可能不同;请在 pi npm 根目录下定位 `run.mjs`。`SessionStart` hook(系统提示注入)是可选的 — 仅 `PreToolUse` 即可强制执行门控。
+
+
+
+Devin CLI
+
+作为插件(需要项目中安装该包):
+```bash
+npm install --save-dev @cuzfrog/module-gates
+devin plugins install cuzfrog/module-gates
+```
+
+或者作为普通 hooks 连接到项目:
+```bash
+npm install --save-dev @cuzfrog/module-gates
+npx module-gates install-devin
+```
+这会将 `PreToolUse` 和 `SessionStart` hooks 写入 `.devin/hooks.v1.json`;`npx module-gates uninstall-devin` 会移除它们。`SessionStart` hook 会自动注入系统提示。
+
+或者在 `.devin/hooks.v1.json` 中手动指向 hooks:
+```json
+{
+ "PreToolUse": [
+ {
+ "matcher": "^(write|edit|apply_patch)$",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"${DEVIN_PROJECT_DIR}/node_modules/@cuzfrog/module-gates/src/bridges/devin/run.mjs\" pre-tool-use"
+ }
+ ]
+ }
+ ],
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"${DEVIN_PROJECT_DIR}/node_modules/@cuzfrog/module-gates/src/bridges/devin/run.mjs\" session-start"
+ }
+ ]
+ }
+ ]
+}
+```
+对于全局或自定义安装,将 `${DEVIN_PROJECT_DIR}/node_modules` 替换为包实际所在的路径(例如 `$(npm root -g)`)。`SessionStart` hook(系统提示注入)是可选的 — 仅 `PreToolUse` 即可强制执行门控。
+
+
+
## 模块描述符语义
模块描述符是一个 Markdown 文件(默认名称:`MODULE.md`),放在目录中。你可以复用模块上下文文件,例如 `CONTEXT.md`。`MODULE.md` 只强制执行其所在目录的规则。
diff --git a/bin/module-gates.mjs b/bin/module-gates.mjs
index 3994338..edf8dcf 100755
--- a/bin/module-gates.mjs
+++ b/bin/module-gates.mjs
@@ -13,19 +13,26 @@ function printUsage() {
Commands:
install-claude [--project-dir
] Install Claude Code hooks into /.claude/settings.json
uninstall-claude [--project-dir ] Remove Claude Code hooks from /.claude/settings.json
+ install-devin [--project-dir ] Install Devin CLI hooks into /.devin/hooks.v1.json
+ uninstall-devin [--project-dir ] Remove Devin CLI hooks from /.devin/hooks.v1.json
Environment:
CLAUDE_PROJECT_DIR Default --project-dir when running inside Claude Code.
+ DEVIN_PROJECT_DIR Default --project-dir when running inside Devin CLI.
Examples:
module-gates install-claude
module-gates install-claude --project-dir /path/to/project
module-gates uninstall-claude
+ module-gates install-devin
+ module-gates install-devin --project-dir /path/to/project
+ module-gates uninstall-devin
`);
}
function parseProjectDir(argv) {
- let projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
+ let projectDir =
+ process.env.DEVIN_PROJECT_DIR ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--project-dir" && i + 1 < argv.length) {
projectDir = argv[++i];
@@ -67,6 +74,26 @@ async function main() {
process.exit(0);
}
+ if (cmd === "install-devin") {
+ const mod = await loadTsModule(join(PKG_ROOT, "src/cli/install-devin.ts"));
+ const result = mod.installDevin({ projectDir });
+ if (!result.ok) {
+ process.stderr.write(`${result.reason}\n`);
+ process.exit(1);
+ }
+ process.exit(0);
+ }
+
+ if (cmd === "uninstall-devin") {
+ const mod = await loadTsModule(join(PKG_ROOT, "src/cli/uninstall-devin.ts"));
+ const result = mod.uninstallDevin({ projectDir });
+ if (!result.ok) {
+ process.stderr.write(`${result.reason}\n`);
+ process.exit(1);
+ }
+ process.exit(0);
+ }
+
process.stderr.write(`Unknown command: ${cmd}\n`);
printUsage();
process.exit(2);
diff --git a/hooks.json b/hooks.json
new file mode 100644
index 0000000..67164ab
--- /dev/null
+++ b/hooks.json
@@ -0,0 +1,25 @@
+{
+ "PreToolUse": [
+ {
+ "matcher": "^(write|edit|apply_patch)$",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"${DEVIN_PROJECT_DIR}/node_modules/@cuzfrog/module-gates/src/bridges/devin/run.mjs\" pre-tool-use",
+ "timeout": 10
+ }
+ ]
+ }
+ ],
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"${DEVIN_PROJECT_DIR}/node_modules/@cuzfrog/module-gates/src/bridges/devin/run.mjs\" session-start",
+ "timeout": 10
+ }
+ ]
+ }
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index 53b871e..64e1630 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@cuzfrog/module-gates",
- "version": "1.0.2",
+ "version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@cuzfrog/module-gates",
- "version": "1.0.2",
+ "version": "1.1.0",
"license": "MIT",
"dependencies": {
"jiti": "2.7.0",
diff --git a/package.json b/package.json
index 76ed048..c719806 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,11 @@
{
"name": "@cuzfrog/module-gates",
- "version": "1.0.3",
- "description": "Controls the entropy of the codebase by enforcing code module boundaries. Ships bridges for pi and Claude Code.",
+ "version": "1.1.0",
+ "description": "Controls the entropy of the codebase by enforcing code module boundaries. Ships bridges for pi, Claude Code, and Devin CLI.",
"keywords": [
"pi-package",
"claude-code",
+ "devin",
"module-boundaries",
"architecture"
],
@@ -56,6 +57,8 @@
"src/",
"skills/",
"bin/",
+ ".devin-plugin/",
+ "hooks.json",
"README.md",
"LICENSE"
],
diff --git a/scripts/gh-bot.mjs b/scripts/gh-bot.mjs
index f0aa258..52123e6 100755
--- a/scripts/gh-bot.mjs
+++ b/scripts/gh-bot.mjs
@@ -55,7 +55,7 @@ function createJWT(appId, privateKey) {
const header = { alg: "RS256", typ: "JWT" };
const payload = {
iat: now,
- exp: now + 600,
+ exp: now + 300,
iss: appId,
};
diff --git a/src/bridges/devin/MODULE.md b/src/bridges/devin/MODULE.md
new file mode 100644
index 0000000..a845151
--- /dev/null
+++ b/src/bridges/devin/MODULE.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/src/bridges/devin/apply-patch.test.ts b/src/bridges/devin/apply-patch.test.ts
new file mode 100644
index 0000000..b1f082b
--- /dev/null
+++ b/src/bridges/devin/apply-patch.test.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect } from "vitest";
+import { extractPatchOperations } from "./apply-patch.ts";
+
+const readFile = (path: string): string => {
+ if (path.endsWith("/src/app.ts")) {
+ return "console.log(\"hello\");\nexport { foo };";
+ }
+ if (path.endsWith("/src/utils.ts")) {
+ return "export const util = 1;";
+ }
+ return "";
+};
+
+describe("extractPatchOperations", () => {
+ it("adds a new file from + lines", () => {
+ const patch = "*** Add File: /project/src/new.ts\n+export const x = 1;";
+ const ops = extractPatchOperations(patch, readFile, "/project");
+ expect(ops).toHaveLength(1);
+ expect(ops[0].filePath).toBe("/project/src/new.ts");
+ expect(ops[0].before).toBe("");
+ expect(ops[0].after).toBe("export const x = 1;");
+ });
+
+ it("deletes a file", () => {
+ const patch = "*** Delete File: /project/src/app.ts";
+ const ops = extractPatchOperations(patch, readFile, "/project");
+ expect(ops).toHaveLength(1);
+ expect(ops[0].filePath).toBe("/project/src/app.ts");
+ expect(ops[0].before).toBe("console.log(\"hello\");\nexport { foo };");
+ expect(ops[0].after).toBe("");
+ });
+
+ it("updates a file with full-file representation", () => {
+ const patch = [
+ "*** Update File: /project/src/app.ts",
+ " console.log(\"hello\");",
+ "+export { bar };",
+ "-export { foo };",
+ ].join("\n");
+ const ops = extractPatchOperations(patch, readFile, "/project");
+ expect(ops).toHaveLength(1);
+ expect(ops[0].after).toBe("console.log(\"hello\");\nexport { bar };");
+ });
+
+ it("moves a file", () => {
+ const patch = [
+ "*** Update File: /project/src/app.ts",
+ "*** Move to: /project/src/moved.ts",
+ "+moved content",
+ ].join("\n");
+ const ops = extractPatchOperations(patch, readFile, "/project");
+ expect(ops).toHaveLength(2);
+ expect(ops[0]).toEqual({
+ filePath: "/project/src/app.ts",
+ before: "console.log(\"hello\");\nexport { foo };",
+ after: "",
+ });
+ expect(ops[1].filePath).toBe("/project/src/moved.ts");
+ expect(ops[1].after).toBe("moved content");
+ });
+
+ it("applies a hunked diff with @@ headers", () => {
+ const before = "line1\nline2\nline3";
+ const read = (path: string): string => (path.endsWith("/file.ts") ? before : "");
+ const patch = [
+ "*** Update File: /project/file.ts",
+ "@@ -2,1 +2,2 @@",
+ " line2",
+ "+line2.5",
+ " line3",
+ "*** End of File",
+ ].join("\n");
+ const ops = extractPatchOperations(patch, read, "/project");
+ expect(ops).toHaveLength(1);
+ expect(ops[0].after).toBe("line1\nline2\nline2.5\nline3");
+ });
+
+ it("resolves relative paths against cwd", () => {
+ const patch = "*** Add File: src/relative.ts\n+export const x = 1;";
+ const ops = extractPatchOperations(patch, readFile, "/project");
+ expect(ops[0].filePath).toBe("/project/src/relative.ts");
+ });
+
+ it("strips Begin/End Patch wrapper", () => {
+ const patch = [
+ "*** Begin Patch",
+ "*** Add File: /project/src/new.ts",
+ "+export const x = 1;",
+ "*** End Patch",
+ ].join("\n");
+ const ops = extractPatchOperations(patch, readFile, "/project");
+ expect(ops).toHaveLength(1);
+ expect(ops[0].after).toBe("export const x = 1;");
+ });
+});
diff --git a/src/bridges/devin/apply-patch.ts b/src/bridges/devin/apply-patch.ts
new file mode 100644
index 0000000..22683ff
--- /dev/null
+++ b/src/bridges/devin/apply-patch.ts
@@ -0,0 +1,189 @@
+import * as path from "node:path";
+
+export type PatchOperation = {
+ filePath: string;
+ before: string;
+ after: string;
+};
+
+export function extractPatchOperations(
+ rawPatch: string,
+ readFile: (absPath: string) => string,
+ cwd: string,
+): PatchOperation[] {
+ const lines = splitPatchLines(rawPatch);
+ const operations: PatchOperation[] = [];
+ let index = 0;
+
+ while (index < lines.length) {
+ const line = lines[index];
+ if (line.startsWith("*** Begin Patch")) {
+ index++;
+ continue;
+ }
+ if (line.startsWith("*** End Patch")) {
+ index++;
+ continue;
+ }
+
+ if (line.startsWith("*** Add File: ")) {
+ const filePath = resolvePatchPath(line.slice("*** Add File: ".length), cwd);
+ const { lines: hunkLines, nextIndex } = collectHunkLines(lines, index + 1);
+ index = nextIndex;
+ const after = hunkLines
+ .filter((l) => l.startsWith("+"))
+ .map((l) => l.slice(1))
+ .join("\n");
+ operations.push({ filePath, before: "", after });
+ continue;
+ }
+
+ if (line.startsWith("*** Delete File: ")) {
+ const filePath = resolvePatchPath(line.slice("*** Delete File: ".length), cwd);
+ const { nextIndex } = collectHunkLines(lines, index + 1);
+ index = nextIndex;
+ const absPath = path.resolve(cwd, filePath);
+ const before = readFile(absPath);
+ operations.push({ filePath, before, after: "" });
+ continue;
+ }
+
+ if (line.startsWith("*** Update File: ")) {
+ const sourcePath = resolvePatchPath(line.slice("*** Update File: ".length), cwd);
+ const sourceAbs = path.resolve(cwd, sourcePath);
+ const before = readFile(sourceAbs);
+
+ let targetPath = sourcePath;
+ let targetBefore = before;
+ index++;
+
+ if (index < lines.length && lines[index].startsWith("*** Move to: ")) {
+ targetPath = resolvePatchPath(lines[index].slice("*** Move to: ".length), cwd);
+ targetBefore = readFile(path.resolve(cwd, targetPath));
+ index++;
+ }
+
+ const { lines: hunkLines, nextIndex } = collectHunkLines(lines, index);
+ index = nextIndex;
+
+ const after = computeAfter(before, hunkLines);
+
+ if (targetPath !== sourcePath) {
+ operations.push({ filePath: sourcePath, before, after: "" });
+ }
+ operations.push({ filePath: targetPath, before: targetBefore, after });
+ continue;
+ }
+
+ index++;
+ }
+
+ return operations;
+}
+
+function splitPatchLines(rawPatch: string): string[] {
+ const body = rawPatch.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
+ return body.split("\n");
+}
+
+function resolvePatchPath(rawPath: string, cwd: string): string {
+ const trimmed = rawPath.trim();
+ if (path.isAbsolute(trimmed)) return trimmed;
+ return path.resolve(cwd, trimmed);
+}
+
+function collectHunkLines(lines: string[], start: number): { lines: string[]; nextIndex: number } {
+ const hunkLines: string[] = [];
+ let index = start;
+ while (index < lines.length) {
+ const line = lines[index];
+ if (
+ line.startsWith("*** Add File: ") ||
+ line.startsWith("*** Update File: ") ||
+ line.startsWith("*** Delete File: ") ||
+ line.startsWith("*** Begin Patch") ||
+ line.startsWith("*** End Patch")
+ ) {
+ break;
+ }
+ if (line === "*** End of File") {
+ index++;
+ break;
+ }
+ hunkLines.push(line);
+ index++;
+ }
+ return { lines: hunkLines, nextIndex: index };
+}
+
+function computeAfter(before: string, hunkLines: string[]): string {
+ if (hunkLines.length === 0) return before;
+
+ const hasHunkHeaders = hunkLines.some((l) => l.startsWith("@@"));
+ if (!hasHunkHeaders) {
+ return hunkLines
+ .filter((l) => l.startsWith(" ") || l.startsWith("+"))
+ .map((l) => l.slice(1))
+ .join("\n");
+ }
+
+ return applyHunkedPatch(before, hunkLines);
+}
+
+function applyHunkedPatch(before: string, hunkLines: string[]): string {
+ const beforeLines = before === "" ? [] : before.split("\n");
+ const hunks = parseHunks(hunkLines);
+
+ for (let i = hunks.length - 1; i >= 0; i--) {
+ const hunk = hunks[i];
+ beforeLines.splice(hunk.oldStart - 1, hunk.oldCount, ...hunk.newLines);
+ }
+
+ return beforeLines.join("\n");
+}
+
+type Hunk = {
+ oldStart: number;
+ oldCount: number;
+ newLines: string[];
+};
+
+function parseHunks(lines: string[]): Hunk[] {
+ const hunks: Hunk[] = [];
+ let current: string[] = [];
+
+ for (const line of lines) {
+ if (line.startsWith("@@")) {
+ if (current.length > 0) {
+ const parsed = parseHunk(current);
+ if (parsed) hunks.push(parsed);
+ }
+ current = [line];
+ continue;
+ }
+ current.push(line);
+ }
+
+ if (current.length > 0) {
+ const parsed = parseHunk(current);
+ if (parsed) hunks.push(parsed);
+ }
+
+ return hunks;
+}
+
+function parseHunk(lines: string[]): Hunk | undefined {
+ const header = lines[0];
+ const body = lines.slice(1);
+
+ const headerMatch = header.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
+ const oldStart = headerMatch ? Number(headerMatch[1]) : 1;
+
+ const newLines = body
+ .filter((l) => l.startsWith(" ") || l.startsWith("+"))
+ .map((l) => l.slice(1));
+
+ const oldCount = body.filter((l) => l.startsWith(" ") || l.startsWith("-")).length;
+
+ return { oldStart, oldCount, newLines };
+}
diff --git a/src/bridges/devin/config-sources.ts b/src/bridges/devin/config-sources.ts
new file mode 100644
index 0000000..f8bf3d4
--- /dev/null
+++ b/src/bridges/devin/config-sources.ts
@@ -0,0 +1,8 @@
+import type { ConfigSource } from "../../core/index.ts";
+
+export const DEVIN_CONFIG_SOURCES: ConfigSource[] = [
+ { filePath: ".module-gates/config.json" },
+ { filePath: ".devin/config.json", key: "module-gates" },
+ { filePath: ".devin/config.local.json", key: "module-gates" },
+ { filePath: ".pi/settings.json", key: "module-gates" },
+];
diff --git a/src/bridges/devin/index.ts b/src/bridges/devin/index.ts
new file mode 100644
index 0000000..d46bb90
--- /dev/null
+++ b/src/bridges/devin/index.ts
@@ -0,0 +1,10 @@
+export {
+ buildPreToolUseEntry,
+ buildSessionStartEntry,
+ HOOK_MARKER,
+ PRE_TOOL_USE_MATCHER,
+ readHooks,
+ removeHooks,
+ upsertHooks,
+ writeHooks,
+} from "./settings-writer.ts";
diff --git a/src/bridges/devin/pre-tool-use.test.ts b/src/bridges/devin/pre-tool-use.test.ts
new file mode 100644
index 0000000..d144e2c
--- /dev/null
+++ b/src/bridges/devin/pre-tool-use.test.ts
@@ -0,0 +1,117 @@
+import { describe, it, expect, beforeAll } from "vitest";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { spawnSync } from "node:child_process";
+import { FIXTURES } from "../../../test/behavior/helpers.ts";
+
+const RUN = path.resolve("src/bridges/devin/run.mjs");
+
+beforeAll(() => {
+ if (!fs.existsSync(RUN)) {
+ throw new Error(`${RUN} not found.`);
+ }
+});
+
+function runHook(stdinObj: unknown, cwd?: string) {
+ return spawnSync("node", [RUN, "pre-tool-use"], {
+ input: JSON.stringify(stdinObj),
+ encoding: "utf-8",
+ timeout: 30_000,
+ cwd,
+ });
+}
+
+describe("pre-tool-use hook", () => {
+ it("exits 0 for unrelated tools", () => {
+ const r = runHook({ hook_event_name: "PreToolUse", tool_name: "read", tool_input: { file_path: "x" } });
+ expect(r.status).toBe(0);
+ });
+
+ it("exits 2 and writes denial for write to readonly file", () => {
+ const r = runHook(
+ {
+ hook_event_name: "PreToolUse",
+ tool_name: "write",
+ tool_input: { file_path: "src/config.ts", content: "// new" },
+ },
+ FIXTURES,
+ );
+ expect(r.status).toBe(2);
+ expect(r.stdout).toContain("Readonly rule");
+ });
+
+ it("exits 0 for write to an editable file", () => {
+ const r = runHook(
+ {
+ hook_event_name: "PreToolUse",
+ tool_name: "write",
+ tool_input: { file_path: "src/app.ts", content: "export function greet() { return 1; }" },
+ },
+ FIXTURES,
+ );
+ expect(r.status).toBe(0);
+ });
+
+ it("handles edit", () => {
+ const r = runHook(
+ {
+ hook_event_name: "PreToolUse",
+ tool_name: "edit",
+ tool_input: { file_path: "src/config.ts", old_string: "API_URL", new_string: "DIFFERENT_URL" },
+ },
+ FIXTURES,
+ );
+ expect(r.status).toBe(2);
+ expect(r.stdout).toContain("Readonly rule");
+ });
+
+ it("replaces all occurrences when replace_all is true", () => {
+ const r = runHook(
+ {
+ hook_event_name: "PreToolUse",
+ tool_name: "edit",
+ tool_input: {
+ file_path: "src/app.ts",
+ old_string: "foo",
+ new_string: "bar",
+ replace_all: true,
+ },
+ },
+ FIXTURES,
+ );
+ expect(r.status).toBe(0);
+ });
+
+ it("exits 0 for non-PreToolUse events", () => {
+ const r = runHook({ hook_event_name: "SessionStart", tool_name: "write", tool_input: {} });
+ expect(r.status).toBe(0);
+ });
+
+ it("fails open on malformed JSON", () => {
+ const r = spawnSync("node", [RUN, "pre-tool-use"], {
+ input: "not json",
+ encoding: "utf-8",
+ timeout: 30_000,
+ });
+ expect(r.status).toBe(0);
+ expect(r.stderr).toContain("[Module Gate]");
+ });
+
+ it("blocks apply_patch that writes a readonly file", () => {
+ const patch = [
+ "*** Update File: src/config.ts",
+ "+// new content",
+ "*** End of File",
+ ].join("\n");
+ const r = runHook(
+ {
+ hook_event_name: "PreToolUse",
+ tool_name: "apply_patch",
+ tool_input: { raw_patch: patch },
+ },
+ FIXTURES,
+ );
+ expect(r.status).toBe(2);
+ expect(r.stdout).toContain("Readonly rule");
+ });
+});
diff --git a/src/bridges/devin/pre-tool-use.ts b/src/bridges/devin/pre-tool-use.ts
new file mode 100644
index 0000000..e153a28
--- /dev/null
+++ b/src/bridges/devin/pre-tool-use.ts
@@ -0,0 +1,168 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+import {
+ createGateEngine,
+ readFileSafe,
+ type GateDenial,
+ type GateEdit,
+} from "../../core/index.ts";
+import { extractPatchOperations, type PatchOperation } from "./apply-patch.ts";
+import { DEVIN_CONFIG_SOURCES } from "./config-sources.ts";
+
+type DevinPreToolUseEvent = {
+ hook_event_name?: string;
+ tool_name?: string;
+ tool_input?: unknown;
+};
+
+type DevinEditInput = {
+ file_path?: string;
+ old_string?: string;
+ new_string?: string;
+ replace_all?: boolean;
+};
+
+type DevinWriteInput = {
+ file_path?: string;
+ content?: string;
+};
+
+type FileEdit = {
+ filePath: string;
+ before: string;
+ after: string;
+};
+
+async function main(): Promise {
+ let raw: string;
+ try {
+ raw = fs.readFileSync(0, "utf-8");
+ } catch {
+ process.exit(0);
+ }
+
+ let event: DevinPreToolUseEvent;
+ try {
+ event = JSON.parse(raw);
+ } catch {
+ process.stderr.write("[Module Gate] hook: invalid JSON input; allowing tool call.\n");
+ process.exit(0);
+ }
+
+ if (event.hook_event_name !== "PreToolUse") process.exit(0);
+ if (
+ event.tool_name !== "write" &&
+ event.tool_name !== "edit" &&
+ event.tool_name !== "apply_patch"
+ ) {
+ process.exit(0);
+ }
+
+ const cwd = process.env.DEVIN_PROJECT_DIR ?? process.cwd();
+
+ let fileEdits: FileEdit[];
+ try {
+ fileEdits = extractFileEdits(event, cwd);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ process.stderr.write(`[Module Gate] could not parse tool input: ${message}\n`);
+ process.exit(0);
+ }
+
+ if (fileEdits.length === 0) process.exit(0);
+
+ let engine;
+ try {
+ engine = await createGateEngine(cwd, { configSources: DEVIN_CONFIG_SOURCES });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ process.stderr.write(`[Module Gate] index build failed: ${message}\n`);
+ process.exit(0);
+ }
+
+ for (const d of engine.diagnostics) {
+ process.stderr.write(`[Module Gate] ${d.message}\n`);
+ }
+
+ if (engine.index.contracts.length === 0) {
+ process.stderr.write("[Module Gate] No module descriptor files found. Gates are not active.\n");
+ process.exit(0);
+ }
+
+ const denials: GateDenial[] = [];
+ for (const { filePath, before, after } of fileEdits) {
+ const result = engine.checkEdit(
+ filePath,
+ [{ oldText: before, newText: after } as GateEdit],
+ { beforeOverride: before },
+ );
+ if (result) denials.push(result);
+ }
+
+ if (denials.length > 0) {
+ const reason = denials.map((d) => d.reason).join("\n\n");
+ process.stdout.write(JSON.stringify({ decision: "block", reason }));
+ process.stderr.write(`[Module Gate] ${reason}\n`);
+ process.exit(2);
+ }
+
+ process.exit(0);
+}
+
+function extractFileEdits(event: DevinPreToolUseEvent, cwd: string): FileEdit[] {
+ const toolInput = event.tool_input ?? {};
+
+ if (event.tool_name === "write") {
+ const input = toolInput as DevinWriteInput;
+ if (!input.file_path) return [];
+ const absPath = path.resolve(cwd, input.file_path);
+ return [{ filePath: input.file_path, before: readFileSafe(absPath), after: input.content ?? "" }];
+ }
+
+ if (event.tool_name === "edit") {
+ const input = toolInput as DevinEditInput;
+ if (!input.file_path) return [];
+ const absPath = path.resolve(cwd, input.file_path);
+ const before = readFileSafe(absPath);
+ const after = computeEditAfter(before, input);
+ return [{ filePath: input.file_path, before, after }];
+ }
+
+ const rawPatch = extractRawPatch(toolInput);
+ if (!rawPatch) return [];
+
+ const operations = extractPatchOperations(rawPatch, (p) => readFileSafe(p), cwd);
+ return operations.map((op) => ({ filePath: op.filePath, before: op.before, after: op.after }));
+}
+
+function computeEditAfter(before: string, input: DevinEditInput): string {
+ const oldText = input.old_string ?? "";
+ const newText = input.new_string ?? "";
+
+ if (oldText === "") return before;
+
+ if (input.replace_all) {
+ return before.split(oldText).join(newText);
+ }
+
+ return before.replace(oldText, newText);
+}
+
+function extractRawPatch(toolInput: unknown): string | undefined {
+ if (typeof toolInput === "string") return toolInput;
+ if (!toolInput || typeof toolInput !== "object" || Array.isArray(toolInput)) return undefined;
+
+ const candidate = (toolInput as Record).raw_patch;
+ if (typeof candidate === "string") return candidate;
+
+ const patch = (toolInput as Record).patch;
+ if (typeof patch === "string") return patch;
+
+ return undefined;
+}
+
+main().catch((err) => {
+ const message = err instanceof Error ? err.message : String(err);
+ process.stderr.write(`[Module Gate] hook internal error: ${message}\n`);
+ process.exit(0);
+});
diff --git a/src/bridges/devin/run.mjs b/src/bridges/devin/run.mjs
new file mode 100644
index 0000000..5674468
--- /dev/null
+++ b/src/bridges/devin/run.mjs
@@ -0,0 +1,24 @@
+#!/usr/bin/env node
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { loadTsModule } from "../../bootstrap-jiti.mjs";
+
+const HOOK_SCRIPTS = {
+ "pre-tool-use": "pre-tool-use.ts",
+ "session-start": "session-start.ts",
+};
+
+const here = dirname(fileURLToPath(import.meta.url));
+const subcommand = process.argv[2];
+const script = HOOK_SCRIPTS[subcommand];
+
+if (!script) {
+ process.stderr.write(`[Module Gate] unknown hook subcommand: ${subcommand ?? ""}\n`);
+ process.exit(0);
+}
+
+loadTsModule(join(here, script)).catch((err) => {
+ const message = err instanceof Error ? err.message : String(err);
+ process.stderr.write(`[Module Gate] hook bootstrap failed: ${message}\n`);
+ process.exit(0);
+});
diff --git a/src/bridges/devin/session-start.test.ts b/src/bridges/devin/session-start.test.ts
new file mode 100644
index 0000000..fad5be7
--- /dev/null
+++ b/src/bridges/devin/session-start.test.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect, beforeAll } from "vitest";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { spawnSync } from "node:child_process";
+import { FIXTURES } from "../../../test/behavior/helpers.ts";
+
+const RUN = path.resolve("src/bridges/devin/run.mjs");
+
+beforeAll(() => {
+ if (!fs.existsSync(RUN)) {
+ throw new Error(`${RUN} not found.`);
+ }
+});
+
+function runSessionStart(stdinObj: unknown, cwd?: string) {
+ return spawnSync("node", [RUN, "session-start"], {
+ input: JSON.stringify(stdinObj),
+ encoding: "utf-8",
+ timeout: 30_000,
+ cwd,
+ });
+}
+
+describe("session-start hook", () => {
+ it("outputs additionalContext for a session start", () => {
+ const r = runSessionStart({ hook_event_name: "SessionStart" }, FIXTURES);
+ expect(r.status).toBe(0);
+ const parsed = JSON.parse(r.stdout);
+ expect(parsed.hookSpecificOutput.hookEventName).toBe("SessionStart");
+ expect(typeof parsed.hookSpecificOutput.additionalContext).toBe("string");
+ });
+
+ it("exits 0 for non-SessionStart events", () => {
+ const r = runSessionStart({ hook_event_name: "PreToolUse" }, FIXTURES);
+ expect(r.status).toBe(0);
+ expect(r.stdout).toBe("");
+ });
+
+ it("fails open on malformed JSON", () => {
+ const r = spawnSync("node", [RUN, "session-start"], {
+ input: "not json",
+ encoding: "utf-8",
+ timeout: 30_000,
+ });
+ expect(r.status).toBe(0);
+ });
+});
diff --git a/src/bridges/devin/session-start.ts b/src/bridges/devin/session-start.ts
new file mode 100644
index 0000000..7eb8399
--- /dev/null
+++ b/src/bridges/devin/session-start.ts
@@ -0,0 +1,56 @@
+import * as fs from "node:fs";
+import { createGateEngine } from "../../core/index.ts";
+import { DEVIN_CONFIG_SOURCES } from "./config-sources.ts";
+
+type DevinSessionStartEvent = {
+ hook_event_name?: string;
+};
+
+async function main(): Promise {
+ let raw: string;
+ try {
+ raw = fs.readFileSync(0, "utf-8");
+ } catch {
+ process.exit(0);
+ }
+
+ let event: DevinSessionStartEvent;
+ try {
+ event = JSON.parse(raw);
+ } catch {
+ process.exit(0);
+ }
+
+ if (event.hook_event_name !== "SessionStart") process.exit(0);
+
+ const cwd = process.env.DEVIN_PROJECT_DIR ?? process.cwd();
+
+ let engine;
+ try {
+ engine = await createGateEngine(cwd, { configSources: DEVIN_CONFIG_SOURCES });
+ } catch {
+ process.exit(0);
+ }
+
+ for (const d of engine.diagnostics) {
+ process.stderr.write(`[Module Gate] ${d.message}\n`);
+ }
+
+ if (engine.config.disableSystemPrompt) process.exit(0);
+ if (engine.index.contracts.length === 0) process.exit(0);
+
+ const additionalContext = engine.systemPromptHint("").trim();
+ process.stdout.write(
+ JSON.stringify({
+ hookSpecificOutput: {
+ hookEventName: "SessionStart",
+ additionalContext,
+ },
+ }),
+ );
+ process.exit(0);
+}
+
+main().catch(() => {
+ process.exit(0);
+});
diff --git a/src/bridges/devin/settings-writer.test.ts b/src/bridges/devin/settings-writer.test.ts
new file mode 100644
index 0000000..4a2d2a0
--- /dev/null
+++ b/src/bridges/devin/settings-writer.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect } from "vitest";
+import {
+ buildPreToolUseEntry,
+ buildSessionStartEntry,
+ upsertHooks,
+ removeHooks,
+ HOOK_MARKER,
+} from "./settings-writer.ts";
+
+describe("buildPreToolUseEntry", () => {
+ it("matches write, edit, and apply_patch", () => {
+ const entry = buildPreToolUseEntry();
+ expect(entry.matcher).toBe("^(write|edit|apply_patch)$");
+ expect(entry.hooks[0].command).toContain(HOOK_MARKER);
+ expect(entry.hooks[0].command).toContain("pre-tool-use");
+ expect(entry.hooks[0].command).toContain("DEVIN_PROJECT_DIR");
+ });
+});
+
+describe("buildSessionStartEntry", () => {
+ it("has no matcher and targets session-start", () => {
+ const entry = buildSessionStartEntry();
+ expect(entry.matcher).toBeUndefined();
+ expect(entry.hooks[0].command).toContain("session-start");
+ expect(entry.hooks[0].command).toContain("DEVIN_PROJECT_DIR");
+ });
+});
+
+describe("upsertHooks", () => {
+ it("inserts PreToolUse and SessionStart entries", () => {
+ const next = upsertHooks({});
+ expect(next.PreToolUse).toHaveLength(1);
+ expect(next.SessionStart).toHaveLength(1);
+ expect(next.PreToolUse[0].hooks[0].command).toContain(HOOK_MARKER);
+ });
+
+ it("replaces an existing module-gates entry", () => {
+ const existing = {
+ PreToolUse: [
+ {
+ matcher: ".*",
+ hooks: [
+ {
+ type: "command",
+ command: `node old.js ${HOOK_MARKER}`,
+ },
+ ],
+ },
+ ],
+ };
+ const next = upsertHooks(existing);
+ expect(next.PreToolUse).toHaveLength(1);
+ expect(next.PreToolUse[0].hooks[0].command).toContain("run.mjs");
+ });
+
+ it("preserves unrelated events", () => {
+ const existing = {
+ PostToolUse: [
+ {
+ matcher: ".*",
+ hooks: [{ type: "command", command: "node other.js" }],
+ },
+ ],
+ };
+ const next = upsertHooks(existing);
+ expect(next.PostToolUse).toHaveLength(1);
+ expect(next.PreToolUse).toHaveLength(1);
+ });
+});
+
+describe("removeHooks", () => {
+ it("removes module-gates entries", () => {
+ const before = {
+ PreToolUse: [
+ {
+ matcher: ".*",
+ hooks: [{ type: "command", command: `node old.js ${HOOK_MARKER}` }],
+ },
+ ],
+ };
+ const after = removeHooks(before);
+ expect(after.PreToolUse).toBeUndefined();
+ });
+
+ it("preserves unrelated hooks", () => {
+ const before = {
+ PreToolUse: [
+ {
+ matcher: ".*",
+ hooks: [{ type: "command", command: "node other.js" }],
+ },
+ ],
+ };
+ const after = removeHooks(before);
+ expect(after.PreToolUse).toHaveLength(1);
+ });
+});
diff --git a/src/bridges/devin/settings-writer.ts b/src/bridges/devin/settings-writer.ts
new file mode 100644
index 0000000..9555468
--- /dev/null
+++ b/src/bridges/devin/settings-writer.ts
@@ -0,0 +1,101 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+
+export const HOOK_MARKER = "@cuzfrog/module-gates";
+export const PRE_TOOL_USE_MATCHER = "^(write|edit|apply_patch)$";
+
+export type DevinHook = {
+ type: string;
+ command?: string;
+ timeout?: number;
+ [key: string]: unknown;
+};
+
+export type DevinHookMatcher = {
+ matcher?: string;
+ hooks: DevinHook[];
+};
+
+export type DevinHooks = {
+ [event: string]: DevinHookMatcher[];
+};
+
+export function readHooks(projectDir: string): DevinHooks {
+ const hooksPath = path.join(projectDir, ".devin", "hooks.v1.json");
+ try {
+ const raw = fs.readFileSync(hooksPath, "utf-8");
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ return parsed as DevinHooks;
+ }
+ return {};
+ } catch {
+ return {};
+ }
+}
+
+export function buildPreToolUseEntry(): DevinHookMatcher {
+ return {
+ matcher: PRE_TOOL_USE_MATCHER,
+ hooks: [
+ {
+ type: "command",
+ command: `node ${HOOK_BASE} pre-tool-use`,
+ timeout: 10,
+ },
+ ],
+ };
+}
+
+export function buildSessionStartEntry(): DevinHookMatcher {
+ return {
+ hooks: [
+ {
+ type: "command",
+ command: `node ${HOOK_BASE} session-start`,
+ timeout: 10,
+ },
+ ],
+ };
+}
+
+export function upsertHooks(hooks: DevinHooks): DevinHooks {
+ const next: DevinHooks = JSON.parse(JSON.stringify(hooks));
+ next.PreToolUse = upsertEvent(next.PreToolUse ?? [], buildPreToolUseEntry());
+ next.SessionStart = upsertEvent(next.SessionStart ?? [], buildSessionStartEntry());
+ return next;
+}
+
+export function removeHooks(hooks: DevinHooks): DevinHooks {
+ const next: DevinHooks = JSON.parse(JSON.stringify(hooks));
+ for (const event of ["PreToolUse", "SessionStart"]) {
+ const existing = next[event];
+ if (!existing) continue;
+ const filtered = existing.filter((m) => !hasMarker(m));
+ if (filtered.length === 0) delete next[event];
+ else next[event] = filtered;
+ }
+ if (Object.keys(next).length === 0) return {};
+ return next;
+}
+
+export function writeHooks(projectDir: string, hooks: DevinHooks): string {
+ const devinDir = path.join(projectDir, ".devin");
+ fs.mkdirSync(devinDir, { recursive: true });
+ const target = path.join(devinDir, "hooks.v1.json");
+ fs.writeFileSync(target, JSON.stringify(hooks, null, 2) + "\n", "utf-8");
+ return target;
+}
+
+const HOOK_BASE =
+ '"${DEVIN_PROJECT_DIR}/node_modules/@cuzfrog/module-gates/src/bridges/devin/run.mjs"';
+
+function upsertEvent(existing: DevinHookMatcher[], entry: DevinHookMatcher): DevinHookMatcher[] {
+ return [...existing.filter((m) => !hasMarker(m)), entry];
+}
+
+function hasMarker(matcher: DevinHookMatcher): boolean {
+ return matcher.hooks.some(
+ (h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER),
+ );
+}
diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts
index e4a28a9..3a7054c 100644
--- a/src/cli/cli.test.ts
+++ b/src/cli/cli.test.ts
@@ -7,6 +7,7 @@ import { FIXTURES } from "../../test/behavior/helpers.ts";
const BIN = path.resolve("bin/module-gates.mjs");
const RUN = path.resolve("src/bridges/claude/run.mjs");
+const DEVIN_RUN = path.resolve("src/bridges/devin/run.mjs");
let tmp: string;
beforeEach(() => {
@@ -77,4 +78,44 @@ describe("module-gates CLI", () => {
expect(pre.filter((m: { hooks: { command: string }[] }) => m.hooks.some((h) => h.command.includes("@cuzfrog/module-gates")))).toHaveLength(0);
fs.rmSync(path.join(FIXTURES, ".claude"), { recursive: true, force: true });
});
+
+ it("install-devin writes hooks.v1.json with the marker", () => {
+ const r = cli("install-devin", "--project-dir", tmp);
+ expect(r.status).toBe(0);
+ const hooksPath = path.join(tmp, ".devin", "hooks.v1.json");
+ expect(fs.existsSync(hooksPath)).toBe(true);
+ const json = JSON.parse(fs.readFileSync(hooksPath, "utf-8"));
+ const pre = json.PreToolUse ?? [];
+ expect(pre.some((m: { hooks: { command: string }[] }) => m.hooks.some((h) => h.command.includes("@cuzfrog/module-gates")))).toBe(true);
+ });
+
+ it("uninstall-devin removes the marker entry", () => {
+ cli("install-devin", "--project-dir", tmp);
+ cli("uninstall-devin", "--project-dir", tmp);
+ const json = JSON.parse(fs.readFileSync(path.join(tmp, ".devin", "hooks.v1.json"), "utf-8"));
+ const pre = json.PreToolUse ?? [];
+ expect(pre.filter((m: { hooks: { command: string }[] }) => m.hooks.some((h) => h.command.includes("@cuzfrog/module-gates")))).toHaveLength(0);
+ });
+
+ it("end-to-end: install devin, invoke write hook, deny on readonly, uninstall", () => {
+ cli("install-devin", "--project-dir", FIXTURES);
+ const payload = {
+ hook_event_name: "PreToolUse",
+ tool_name: "write",
+ tool_input: { file_path: "src/config.ts", content: "// modified" },
+ };
+ const hook = spawnSync("node", [DEVIN_RUN, "pre-tool-use"], {
+ input: JSON.stringify(payload),
+ encoding: "utf-8",
+ timeout: 30_000,
+ cwd: FIXTURES,
+ });
+ expect(hook.status).toBe(2);
+ expect(hook.stdout).toContain("Readonly rule");
+ cli("uninstall-devin", "--project-dir", FIXTURES);
+ const after = JSON.parse(fs.readFileSync(path.join(FIXTURES, ".devin", "hooks.v1.json"), "utf-8"));
+ const pre = after.PreToolUse ?? [];
+ expect(pre.filter((m: { hooks: { command: string }[] }) => m.hooks.some((h) => h.command.includes("@cuzfrog/module-gates")))).toHaveLength(0);
+ fs.rmSync(path.join(FIXTURES, ".devin"), { recursive: true, force: true });
+ });
});
\ No newline at end of file
diff --git a/src/cli/install-devin.ts b/src/cli/install-devin.ts
new file mode 100644
index 0000000..a3e56d1
--- /dev/null
+++ b/src/cli/install-devin.ts
@@ -0,0 +1,47 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+import {
+ readHooks,
+ upsertHooks,
+ writeHooks,
+ HOOK_MARKER,
+ PRE_TOOL_USE_MATCHER,
+} from "../bridges/devin/index.ts";
+
+export type InstallDevinOptions = {
+ projectDir: string;
+};
+
+export type InstallDevinResult =
+ | { ok: true; written: string }
+ | { ok: false; reason: string };
+
+export function installDevin(opts: InstallDevinOptions): InstallDevinResult {
+ const projectDir = path.resolve(opts.projectDir);
+ if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
+ return { ok: false, reason: `Project directory does not exist: ${projectDir}` };
+ }
+
+ const hooks = readHooks(projectDir);
+ const updated = upsertHooks(hooks);
+ const written = writeHooks(projectDir, updated);
+
+ const relPath = path.relative(projectDir, written) || written;
+ process.stdout.write(`Wrote ${relPath}\n\n`);
+ process.stdout.write("Hook entries inserted under PreToolUse and SessionStart:\n");
+ for (const event of ["PreToolUse", "SessionStart"]) {
+ const matcher = updated[event]?.find((m) =>
+ m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)),
+ );
+ if (!matcher) continue;
+ process.stdout.write(` ${event} matcher: ${JSON.stringify(matcher.matcher)}\n`);
+ for (const h of matcher.hooks) {
+ if (typeof h.command === "string") {
+ process.stdout.write(` command: ${h.command}\n`);
+ }
+ }
+ }
+ process.stdout.write(`\nPreToolUse matcher targets: ${PRE_TOOL_USE_MATCHER}\n`);
+
+ return { ok: true, written };
+}
diff --git a/src/cli/uninstall-devin.ts b/src/cli/uninstall-devin.ts
new file mode 100644
index 0000000..4baf16c
--- /dev/null
+++ b/src/cli/uninstall-devin.ts
@@ -0,0 +1,40 @@
+import * as fs from "node:fs";
+import * as path from "node:path";
+import {
+ readHooks,
+ removeHooks,
+ writeHooks,
+ HOOK_MARKER,
+} from "../bridges/devin/index.ts";
+
+export type UninstallDevinOptions = {
+ projectDir: string;
+};
+
+export type UninstallDevinResult =
+ | { ok: true; removed: boolean; written: string }
+ | { ok: false; reason: string };
+
+export function uninstallDevin(opts: UninstallDevinOptions): UninstallDevinResult {
+ const projectDir = path.resolve(opts.projectDir);
+ const hooksPath = path.join(projectDir, ".devin", "hooks.v1.json");
+
+ if (!fs.existsSync(hooksPath)) {
+ process.stdout.write(`No .devin/hooks.v1.json found at ${hooksPath} — nothing to do.\n`);
+ return { ok: true, removed: false, written: hooksPath };
+ }
+
+ const before = readHooks(projectDir);
+ const beforeHadMarker = JSON.stringify(before).includes(HOOK_MARKER);
+ const after = removeHooks(before);
+ const afterHasMarker = JSON.stringify(after).includes(HOOK_MARKER);
+
+ if (beforeHadMarker && !afterHasMarker) {
+ writeHooks(projectDir, after);
+ process.stdout.write(`Removed module-gates hooks from ${hooksPath}.\n`);
+ return { ok: true, removed: true, written: hooksPath };
+ }
+
+ process.stdout.write(`No module-gates hooks found in ${hooksPath}.\n`);
+ return { ok: true, removed: false, written: hooksPath };
+}
diff --git a/src/core/config.ts b/src/core/config.ts
index d16448f..422c845 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -1,5 +1,6 @@
import * as fs from "node:fs";
import * as path from "node:path";
+import { parseJsonc } from "./utils/jsonc.ts";
export type ModuleGateConfig = {
moduleDescriptorFileName: string;
@@ -53,7 +54,7 @@ function readFirstUserConfig(cwd: string, sources: ConfigSource[]): UserConfig {
function readSource(cwd: string, source: ConfigSource): UserConfig | undefined {
try {
const raw = fs.readFileSync(path.join(cwd, source.filePath), "utf-8");
- const settings = JSON.parse(raw);
+ const settings = parseJsonc(raw);
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
return undefined;
}
diff --git a/src/core/utils/jsonc.test.ts b/src/core/utils/jsonc.test.ts
new file mode 100644
index 0000000..616b072
--- /dev/null
+++ b/src/core/utils/jsonc.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect } from "vitest";
+import { parseJsonc } from "./jsonc.ts";
+
+describe("parseJsonc", () => {
+ it("parses plain JSON", () => {
+ expect(parseJsonc('{"a": 1}')).toEqual({ a: 1 });
+ });
+
+ it("strips line comments", () => {
+ const raw = `{
+ // this is a comment
+ "a": 1
+ }`;
+ expect(parseJsonc(raw)).toEqual({ a: 1 });
+ });
+
+ it("strips block comments", () => {
+ const raw = `{
+ /* block
+ comment */
+ "a": 1
+ }`;
+ expect(parseJsonc(raw)).toEqual({ a: 1 });
+ });
+
+ it("does not strip comments inside strings", () => {
+ const raw = `{
+ "a": "// not a comment",
+ "b": "/* also not */"
+ }`;
+ expect(parseJsonc(raw)).toEqual({
+ a: "// not a comment",
+ b: "/* also not */",
+ });
+ });
+
+ it("preserves escaped quotes", () => {
+ const raw = '{ "a": "say \\"hi\\"" }';
+ expect(parseJsonc(raw)).toEqual({ a: 'say "hi"' });
+ });
+});
diff --git a/src/core/utils/jsonc.ts b/src/core/utils/jsonc.ts
new file mode 100644
index 0000000..f9828e0
--- /dev/null
+++ b/src/core/utils/jsonc.ts
@@ -0,0 +1,67 @@
+export function parseJsonc(raw: string): unknown {
+ const stripped = stripJsonComments(raw);
+ return JSON.parse(stripped);
+}
+
+function stripJsonComments(text: string): string {
+ let result = "";
+ let inString = false;
+ let escape = false;
+ let inLineComment = false;
+ let inBlockComment = false;
+
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i];
+ const next = text[i + 1];
+
+ if (inString) {
+ result += ch;
+ if (escape) {
+ escape = false;
+ } else if (ch === "\\") {
+ escape = true;
+ } else if (ch === '"') {
+ inString = false;
+ }
+ continue;
+ }
+
+ if (inLineComment) {
+ if (ch === "\n") {
+ inLineComment = false;
+ result += ch;
+ }
+ continue;
+ }
+
+ if (inBlockComment) {
+ if (ch === "*" && next === "/") {
+ inBlockComment = false;
+ i++;
+ }
+ continue;
+ }
+
+ if (ch === '"') {
+ inString = true;
+ result += ch;
+ continue;
+ }
+
+ if (ch === "/" && next === "/") {
+ inLineComment = true;
+ i++;
+ continue;
+ }
+
+ if (ch === "/" && next === "*") {
+ inBlockComment = true;
+ i++;
+ continue;
+ }
+
+ result += ch;
+ }
+
+ return result;
+}