From 10b525d1e54dc2b373203210b2363c66151ce30d Mon Sep 17 00:00:00 2001 From: Cause Chung Date: Thu, 6 Aug 2026 18:53:11 -0400 Subject: [PATCH 1/3] feat: add Devin CLI bridge with PreToolUse, SessionStart, apply_patch parser and installer Add a Devin CLI bridge so module-gates can block write/edit/apply_patch and inject session context the same way it does for Claude Code and pi. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .devin-plugin/plugin.json | 22 +++ bin/module-gates.mjs | 29 +++- hooks.json | 25 +++ package.json | 5 +- src/bridges/devin/MODULE.md | 2 + src/bridges/devin/apply-patch.test.ts | 95 +++++++++++ src/bridges/devin/apply-patch.ts | 189 ++++++++++++++++++++++ src/bridges/devin/config-sources.ts | 8 + src/bridges/devin/index.ts | 10 ++ src/bridges/devin/pre-tool-use.test.ts | 117 ++++++++++++++ src/bridges/devin/pre-tool-use.ts | 168 +++++++++++++++++++ src/bridges/devin/run.mjs | 24 +++ src/bridges/devin/session-start.test.ts | 47 ++++++ src/bridges/devin/session-start.ts | 56 +++++++ src/bridges/devin/settings-writer.test.ts | 97 +++++++++++ src/bridges/devin/settings-writer.ts | 101 ++++++++++++ src/cli/cli.test.ts | 41 +++++ src/cli/install-devin.ts | 47 ++++++ src/cli/uninstall-devin.ts | 40 +++++ src/core/config.ts | 3 +- src/core/utils/jsonc.test.ts | 41 +++++ src/core/utils/jsonc.ts | 67 ++++++++ 22 files changed, 1231 insertions(+), 3 deletions(-) create mode 100644 .devin-plugin/plugin.json create mode 100644 hooks.json create mode 100644 src/bridges/devin/MODULE.md create mode 100644 src/bridges/devin/apply-patch.test.ts create mode 100644 src/bridges/devin/apply-patch.ts create mode 100644 src/bridges/devin/config-sources.ts create mode 100644 src/bridges/devin/index.ts create mode 100644 src/bridges/devin/pre-tool-use.test.ts create mode 100644 src/bridges/devin/pre-tool-use.ts create mode 100644 src/bridges/devin/run.mjs create mode 100644 src/bridges/devin/session-start.test.ts create mode 100644 src/bridges/devin/session-start.ts create mode 100644 src/bridges/devin/settings-writer.test.ts create mode 100644 src/bridges/devin/settings-writer.ts create mode 100644 src/cli/install-devin.ts create mode 100644 src/cli/uninstall-devin.ts create mode 100644 src/core/utils/jsonc.test.ts create mode 100644 src/core/utils/jsonc.ts 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/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.json b/package.json index 76ed048..0dee93c 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.", + "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/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; +} From e22c3e7a889893a32f60dc067de6db518773bf2a Mon Sep 17 00:00:00 2001 From: Cause Chung Date: Thu, 6 Aug 2026 19:00:44 -0400 Subject: [PATCH 2/3] docs: add Devin CLI install guide to README and bump version to 1.1.0 - Make each agent's installation section foldable with
- Add Devin CLI (plugin and plain hooks) instructions in all three languages - Bump package version to 1.1.0 for the new feature Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.ja.md | 60 ++++++++++++++++++++++++++++++++++++++++++++--- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++-- README.zh.md | 58 +++++++++++++++++++++++++++++++++++++++++++-- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 172 insertions(+), 10 deletions(-) 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/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 0dee93c..c719806 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cuzfrog/module-gates", - "version": "1.0.3", + "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", From 8333aed1ef7d529751eabd45ac7a7be93e41eb21 Mon Sep 17 00:00:00 2001 From: Cause Chung Date: Thu, 6 Aug 2026 19:01:57 -0400 Subject: [PATCH 3/3] fix(scripts): reduce gh-bot JWT expiry to 5 minutes to avoid GitHub 401 GitHub rejected the 10-minute JWT as too far in the future; 5 minutes keeps it safely inside the accepted window. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/gh-bot.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, };