@cuzfrog/pi-module-gates 0.13.3 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-module-gates - Constraints liberate, liberties constrain.
2
2
 
3
- Expirimental pi cli extension that controls the entropy of the codebase by enforcing code module boundaries.
3
+ Experimental pi cli extension that controls the entropy of the codebase by enforcing code module boundaries.
4
4
  It helps combat slop generation and code architecture degradation.
5
5
 
6
6
  ## Problem
@@ -17,6 +17,9 @@ AI coding agents produce edits with limited context knowledge (myopia) — their
17
17
 
18
18
  The extension intercepts agent `write`/`edit` operations and enforces these contracts. Violations are blocked with a clear reason.
19
19
 
20
+ The attempt to add 2 public helper functions is blocked, forcing the agent to re-think the design.
21
+ ![Module Gate denial example](doc/module_gates_block.png)
22
+
20
23
  ### How it works
21
24
 
22
25
  1. **Indexing** — On session start, scans the project tree for descriptor files and builds a module index.
@@ -28,7 +31,7 @@ The extension intercepts agent `write`/`edit` operations and enforces these cont
28
31
  - **Module interface import gate** — external files can only import from the module not internal files, i.e. re-exports from `index.ts` or `mod.rs`. A child module may import from a parent module's internal files (not recommended but allowed). (Only Typescript/JavaScript and Rust are supported)
29
32
  - **Import gate** (not implemented yet) — would the change introduce an import violating visibility scope?
30
33
 
31
- - System prompt: [system-prompt.md](src/context/system-prompt.ts)
34
+ - System prompt: [system-prompt.md](src/context/system-prompt.template.md)
32
35
  - Currently [supported languages](src/gates/checkers/index.ts): **TypeScript/JavaScript**, **Rust**, **Java**, **Go**, **Kotlin**, **Scala**
33
36
 
34
37
  ## Installation
@@ -127,9 +130,51 @@ Add a `module-gates` entry to `.pi/settings.json`:
127
130
  | `moduleDescriptorReadonly` | `true` | When `true`, descriptor files are readonly.|
128
131
  | `sourceRoot` | `"src/"` | Directory to scan for descriptor files and enforce gates. Set to `""` to scan from project root. |
129
132
  | `disableModuleInterfaceImportGate` | `false` | When `true`, imports will not be forced to be from module interface. |
133
+ | `disableSystemPrompt` | `false` | When `true`, skip injecting the module-gates hint into the agent's system prompt. |
130
134
 
131
135
  When no settings file exists or no `module-gates` key is present, defaults apply.
132
136
 
137
+ ## Claude Code Support
138
+
139
+ ### Install
140
+ Add the following to `.claude/settings.json` in the current project, pointing the `PreToolUse` hook at the installed binary.
141
+ ```json
142
+ {
143
+ "hooks": {
144
+ "PreToolUse": [
145
+ {
146
+ "matcher": "Edit|MultiEdit|Write",
147
+ "hooks": [
148
+ {
149
+ "type": "command",
150
+ "command": "bun ${CLAUDE_PROJECT_DIR}/node_modules/@cuzfrog/pi-module-gates/src/claude/pre-tool-use.ts",
151
+ "statusMessage": "Module gate checking edit..."
152
+ }
153
+ ]
154
+ }
155
+ ]
156
+ }
157
+ }
158
+ ```
159
+
160
+ If `pi-module-gates` is already installed in pi global dir, you can use below path instead:
161
+ ```
162
+ ~/.pi/agent/npm/node_modules/@cuzfrog/pi-module-gates/src/claude/pre-tool-use.ts
163
+ ```
164
+
165
+ ### System prompt
166
+ You need to add [system-prompt.md](src/context/system-prompt.template.md) manually to your context.
167
+
168
+ ### Configuration
169
+
170
+ Claude Code uses the same `.pi/settings.json#module-gates` block as the pi extension. See the Configuration section above.
171
+
172
+ ### Troubleshooting
173
+ Prompt:
174
+ ```
175
+ Check if PreToolUse hook `pi-module-gates` is triggered and runs expectedly.
176
+ ```
177
+
133
178
  ## License
134
179
 
135
180
  MIT
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bun
2
+ // Bin entrypoint: executed by `bun` (see package.json#bin) and dispatches to
3
+ // src/cli/*.ts modules. Lives in .mjs for npm-install shebang compatibility on
4
+ // platforms that resolve `bin` to a file path directly; `bun` will load this as
5
+ // JavaScript since it has no TypeScript-specific syntax.
6
+ import { resolve, isAbsolute } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const PKG_ROOT = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
10
+
11
+ function printUsage() {
12
+ process.stderr.write(`Usage: pi-module-gates <command> [...args]
13
+
14
+ Commands:
15
+ install-claude [--project-dir <dir>] Install Claude Code PreToolUse hooks into <dir>/.claude/settings.json
16
+ uninstall-claude [--project-dir <dir>] Remove Claude Code PreToolUse hooks from <dir>/.claude/settings.json
17
+
18
+ Environment:
19
+ CLAUDE_PROJECT_DIR Default --project-dir when running inside Claude Code.
20
+
21
+ Examples:
22
+ pi-module-gates install-claude
23
+ pi-module-gates install-claude --project-dir /path/to/project
24
+ pi-module-gates uninstall-claude
25
+ `);
26
+ }
27
+
28
+ function parseProjectDir(argv) {
29
+ let projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
30
+ for (let i = 0; i < argv.length; i++) {
31
+ if (argv[i] === "--project-dir" && i + 1 < argv.length) {
32
+ projectDir = argv[++i];
33
+ }
34
+ }
35
+ return isAbsolute(projectDir) ? projectDir : resolve(process.cwd(), projectDir);
36
+ }
37
+
38
+ async function main() {
39
+ const [cmd, ...rest] = process.argv.slice(2);
40
+ if (!cmd) {
41
+ printUsage();
42
+ process.exit(1);
43
+ }
44
+ if (cmd === "-h" || cmd === "--help") {
45
+ printUsage();
46
+ process.exit(0);
47
+ }
48
+
49
+ const projectDir = parseProjectDir(rest);
50
+
51
+ if (cmd === "install-claude") {
52
+ const mod = await import(resolve(PKG_ROOT, "src/cli/install-claude.ts"));
53
+ const result = mod.installClaude({ projectDir });
54
+ if (!result.ok) {
55
+ process.stderr.write(`${result.reason}\n`);
56
+ process.exit(1);
57
+ }
58
+ process.exit(0);
59
+ }
60
+
61
+ if (cmd === "uninstall-claude") {
62
+ const mod = await import(resolve(PKG_ROOT, "src/cli/uninstall-claude.ts"));
63
+ const result = mod.uninstallClaude({ projectDir });
64
+ if (!result.ok) {
65
+ process.stderr.write(`${result.reason}\n`);
66
+ process.exit(1);
67
+ }
68
+ process.exit(0);
69
+ }
70
+
71
+ process.stderr.write(`Unknown command: ${cmd}\n`);
72
+ printUsage();
73
+ process.exit(2);
74
+ }
75
+
76
+ main().catch((err) => {
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ process.stderr.write(`[pi-module-gates] Unexpected error: ${message}\n`);
79
+ process.exit(1);
80
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cuzfrog/pi-module-gates",
3
- "version": "0.13.3",
3
+ "version": "0.16.3",
4
4
  "description": "pi extension that controls the entropy of the codebase by enforcing code module boundaries.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -10,6 +10,9 @@
10
10
  "check": "tsc --noEmit",
11
11
  "test": "vitest run"
12
12
  },
13
+ "bin": {
14
+ "pi-module-gates": "./bin/pi-module-gates.mjs"
15
+ },
13
16
  "pi": {
14
17
  "extensions": [
15
18
  "./src/index.ts"
@@ -18,12 +21,16 @@
18
21
  "./skills"
19
22
  ]
20
23
  },
24
+ "dependencies": {
25
+ "yaml": "2.8.1"
26
+ },
21
27
  "peerDependencies": {
22
28
  "@earendil-works/pi-coding-agent": "*"
23
29
  },
24
30
  "devDependencies": {
25
31
  "@earendil-works/pi-coding-agent": "0.79.8",
26
32
  "@types/node": "22.19.19",
33
+ "bun-types": "1.3.14",
27
34
  "typescript": "5.9.3",
28
35
  "vitest": "4.1.7"
29
36
  },
@@ -40,11 +47,12 @@
40
47
  "files": [
41
48
  "src/",
42
49
  "skills/",
50
+ "bin/",
43
51
  "README.md",
44
52
  "LICENSE"
45
53
  ],
46
54
  "engines": {
47
- "node": ">=18"
55
+ "bun": ">=1.3.0"
48
56
  },
49
57
  "publishConfig": {
50
58
  "access": "public",
@@ -0,0 +1,40 @@
1
+ import type { ModuleIndex } from "../types.ts";
2
+ import type { ModuleGateConfig } from "../config.ts";
3
+ import { loadConfig } from "../config.ts";
4
+ import { buildModuleIndex } from "../graph/index.ts";
5
+
6
+ export type IndexContext = {
7
+ cwd: string;
8
+ ui: { notify: (msg: string) => void };
9
+ };
10
+
11
+ export type LoadIndexResult = {
12
+ index: ModuleIndex;
13
+ config: ModuleGateConfig;
14
+ };
15
+
16
+ export async function loadIndexForHook(cwd: string): Promise<LoadIndexResult> {
17
+ const config = loadConfig(cwd);
18
+ const ctx: IndexContext = {
19
+ cwd,
20
+ ui: {
21
+ notify: (m) => process.stderr.write(`[Module Gate] ${m}\n`),
22
+ },
23
+ };
24
+
25
+ try {
26
+ const index = await buildModuleIndex(ctx, config);
27
+ return { index, config };
28
+ } catch (err) {
29
+ const message = err instanceof Error ? err.message : String(err);
30
+ process.stderr.write(`[Module Gate] index build failed: ${message}\n`);
31
+ return {
32
+ index: { contracts: [], dirToModule: new Map() },
33
+ config,
34
+ };
35
+ }
36
+ }
37
+
38
+ export function notifyNoContracts(ctx: IndexContext): void {
39
+ ctx.ui.notify("No module descriptor files found. Gates are not active.");
40
+ }
@@ -0,0 +1,99 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { applyEdits, readFileSafe } from "../utils.ts";
4
+ import { runGates, type GateEdit } from "../gates/run-gates.ts";
5
+ import { loadIndexForHook, notifyNoContracts } from "./index-loader.ts";
6
+ import "../gates/checkers/index.ts";
7
+
8
+ type HookEvent = {
9
+ hook_event_name?: string;
10
+ tool_name?: string;
11
+ tool_input?: {
12
+ file_path?: string;
13
+ old_string?: string;
14
+ new_string?: string;
15
+ edits?: { old_string?: string; new_string?: string }[];
16
+ content?: string;
17
+ };
18
+ cwd?: string;
19
+ };
20
+
21
+ const notifyCtx = (): { cwd: string; ui: { notify: (m: string) => void } } => ({
22
+ cwd: process.cwd(),
23
+ ui: {
24
+ notify: (m) => process.stderr.write(`[Module Gate] ${m}\n`),
25
+ },
26
+ });
27
+
28
+ async function main(): Promise<void> {
29
+ let raw: string;
30
+ try {
31
+ raw = fs.readFileSync(0, "utf-8");
32
+ } catch {
33
+ process.exit(0);
34
+ }
35
+
36
+ let event: HookEvent;
37
+ try {
38
+ event = JSON.parse(raw);
39
+ } catch {
40
+ process.stderr.write("[Module Gate] hook: invalid JSON input; allowing tool call.\n");
41
+ process.exit(0);
42
+ }
43
+
44
+ if (event.hook_event_name !== "PreToolUse") process.exit(0);
45
+ if (
46
+ event.tool_name !== "Edit" &&
47
+ event.tool_name !== "MultiEdit" &&
48
+ event.tool_name !== "Write"
49
+ ) {
50
+ process.exit(0);
51
+ }
52
+
53
+ const cwd: string = event.cwd ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
54
+ const toolInput = event.tool_input ?? {};
55
+ const filePath: string | undefined = toolInput.file_path;
56
+ if (!filePath) process.exit(0);
57
+
58
+ const absPath = path.resolve(cwd, filePath);
59
+ const before = readFileSafe(absPath);
60
+ let after: string;
61
+
62
+ if (event.tool_name === "Edit") {
63
+ after = applyEdits(before, [
64
+ { oldText: toolInput.old_string ?? "", newText: toolInput.new_string ?? "" },
65
+ ]);
66
+ } else if (event.tool_name === "MultiEdit") {
67
+ const edits: GateEdit[] = Array.isArray(toolInput.edits)
68
+ ? toolInput.edits.map((e) => ({
69
+ oldText: e.old_string ?? "",
70
+ newText: e.new_string ?? "",
71
+ }))
72
+ : [];
73
+ after = applyEdits(before, edits);
74
+ } else {
75
+ after = toolInput.content ?? "";
76
+ }
77
+
78
+ const { index, config } = await loadIndexForHook(cwd);
79
+
80
+ if (index.contracts.length === 0) {
81
+ notifyNoContracts(notifyCtx());
82
+ process.exit(0);
83
+ }
84
+
85
+ const result = runGates(filePath, [{ oldText: before, newText: after }], cwd, index, config, before);
86
+
87
+ if (result?.block) {
88
+ process.stderr.write(`${result.reason}\n`);
89
+ process.exit(2);
90
+ }
91
+
92
+ process.exit(0);
93
+ }
94
+
95
+ main().catch((err) => {
96
+ const message = err instanceof Error ? err.message : String(err);
97
+ process.stderr.write(`[Module Gate] hook internal error: ${message}\n`);
98
+ process.exit(0);
99
+ });
@@ -0,0 +1,91 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export const HOOK_MARKER = "@cuzfrog/pi-module-gates";
5
+ export const PRE_TOOL_USE_MATCHER = "Edit|MultiEdit|Write";
6
+
7
+ export type ClaudeHookCommand = {
8
+ type: "command";
9
+ command: string;
10
+ statusMessage?: string;
11
+ };
12
+
13
+ export type ClaudeHook = ClaudeHookCommand | {
14
+ type: string;
15
+ command?: string;
16
+ statusMessage?: string;
17
+ [key: string]: unknown;
18
+ };
19
+
20
+ export type HookMatcher = {
21
+ matcher: string;
22
+ hooks: ClaudeHook[];
23
+ };
24
+
25
+ export type ClaudeSettings = {
26
+ hooks?: Record<string, HookMatcher[]>;
27
+ [key: string]: unknown;
28
+ };
29
+
30
+ export function readSettings(projectDir: string): ClaudeSettings {
31
+ const settingsPath = path.join(projectDir, ".claude", "settings.json");
32
+ try {
33
+ const raw = fs.readFileSync(settingsPath, "utf-8");
34
+ const parsed = JSON.parse(raw);
35
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
36
+ return parsed as ClaudeSettings;
37
+ }
38
+ return {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ export function buildPreToolUseEntry(): HookMatcher {
45
+ return {
46
+ matcher: PRE_TOOL_USE_MATCHER,
47
+ hooks: [
48
+ {
49
+ type: "command",
50
+ command: `bun \${CLAUDE_PROJECT_DIR}/node_modules/@cuzfrog/pi-module-gates/src/claude/pre-tool-use.ts`,
51
+ statusMessage: "Module gate checking edit...",
52
+ },
53
+ ],
54
+ };
55
+ }
56
+
57
+ export function upsertPreToolUse(settings: ClaudeSettings): ClaudeSettings {
58
+ const next: ClaudeSettings = JSON.parse(JSON.stringify(settings));
59
+ next.hooks = next.hooks ?? {};
60
+ const existing = next.hooks.PreToolUse ?? [];
61
+ next.hooks.PreToolUse = existing.filter(
62
+ (m) => !m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)),
63
+ );
64
+ next.hooks.PreToolUse.push(buildPreToolUseEntry());
65
+ return next;
66
+ }
67
+
68
+ export function removePreToolUse(settings: ClaudeSettings): ClaudeSettings {
69
+ const next: ClaudeSettings = JSON.parse(JSON.stringify(settings));
70
+ if (!next.hooks?.PreToolUse) return next;
71
+ const filtered = next.hooks.PreToolUse.filter(
72
+ (m) => !m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)),
73
+ );
74
+ if (filtered.length === 0) {
75
+ delete next.hooks.PreToolUse;
76
+ if (Object.keys(next.hooks).length === 0) {
77
+ delete next.hooks;
78
+ }
79
+ } else {
80
+ next.hooks.PreToolUse = filtered;
81
+ }
82
+ return next;
83
+ }
84
+
85
+ export function writeSettings(projectDir: string, settings: ClaudeSettings): string {
86
+ const claudeDir = path.join(projectDir, ".claude");
87
+ fs.mkdirSync(claudeDir, { recursive: true });
88
+ const target = path.join(claudeDir, "settings.json");
89
+ fs.writeFileSync(target, JSON.stringify(settings, null, 2) + "\n", "utf-8");
90
+ return target;
91
+ }
@@ -0,0 +1,54 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import {
4
+ readSettings,
5
+ upsertPreToolUse,
6
+ writeSettings,
7
+ HOOK_MARKER,
8
+ PRE_TOOL_USE_MATCHER,
9
+ } from "../claude/settings-writer.ts";
10
+
11
+ export type InstallClaudeOptions = {
12
+ projectDir: string;
13
+ };
14
+
15
+ export type InstallClaudeResult =
16
+ | { ok: true; written: string }
17
+ | { ok: false; reason: string };
18
+
19
+ export function installClaude(opts: InstallClaudeOptions): InstallClaudeResult {
20
+ const projectDir = path.resolve(opts.projectDir);
21
+ if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
22
+ return { ok: false, reason: `Project directory does not exist: ${projectDir}` };
23
+ }
24
+
25
+ const settingsPath = path.join(projectDir, ".pi", "settings.json");
26
+ if (!fs.existsSync(settingsPath)) {
27
+ process.stderr.write(
28
+ `[Module Gate] No .pi/settings.json found at ${settingsPath} — using defaults.\n`,
29
+ );
30
+ }
31
+
32
+ const settings = readSettings(projectDir);
33
+ const updated = upsertPreToolUse(settings);
34
+ const written = writeSettings(projectDir, updated);
35
+
36
+ const relPath = path.relative(projectDir, written) || written;
37
+ process.stdout.write(`Wrote ${relPath}\n\n`);
38
+ process.stdout.write("Hook entry inserted under hooks.PreToolUse:\n");
39
+ const matcher = updated.hooks?.PreToolUse?.find((m) =>
40
+ m.hooks.some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)),
41
+ );
42
+ if (matcher) {
43
+ process.stdout.write(` matcher: "${matcher.matcher}"\n`);
44
+ for (const h of matcher.hooks) {
45
+ if (typeof h.command === "string") {
46
+ process.stdout.write(` command: ${h.command}\n`);
47
+ }
48
+ if (h.statusMessage) process.stdout.write(` status: ${h.statusMessage}\n`);
49
+ }
50
+ }
51
+ process.stdout.write(`\nMatcher targets: ${PRE_TOOL_USE_MATCHER}\n`);
52
+
53
+ return { ok: true, written };
54
+ }
@@ -0,0 +1,40 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import {
4
+ readSettings,
5
+ removePreToolUse,
6
+ writeSettings,
7
+ HOOK_MARKER,
8
+ } from "../claude/settings-writer.ts";
9
+
10
+ export type UninstallClaudeOptions = {
11
+ projectDir: string;
12
+ };
13
+
14
+ export type UninstallClaudeResult =
15
+ | { ok: true; removed: boolean; written: string }
16
+ | { ok: false; reason: string };
17
+
18
+ export function uninstallClaude(opts: UninstallClaudeOptions): UninstallClaudeResult {
19
+ const projectDir = path.resolve(opts.projectDir);
20
+ const settingsPath = path.join(projectDir, ".claude", "settings.json");
21
+
22
+ if (!fs.existsSync(settingsPath)) {
23
+ process.stdout.write(`No .claude/settings.json found at ${settingsPath} — nothing to do.\n`);
24
+ return { ok: true, removed: false, written: settingsPath };
25
+ }
26
+
27
+ const before = readSettings(projectDir);
28
+ const beforeHadMarker = JSON.stringify(before).includes(HOOK_MARKER);
29
+ const after = removePreToolUse(before);
30
+ const afterHasMarker = JSON.stringify(after).includes(HOOK_MARKER);
31
+
32
+ if (beforeHadMarker && !afterHasMarker) {
33
+ writeSettings(projectDir, after);
34
+ process.stdout.write(`Removed pi-module-gates hooks from ${settingsPath}.\n`);
35
+ return { ok: true, removed: true, written: settingsPath };
36
+ }
37
+
38
+ process.stdout.write(`No pi-module-gates hooks found in ${settingsPath}.\n`);
39
+ return { ok: true, removed: false, written: settingsPath };
40
+ }
package/src/config.ts CHANGED
@@ -6,6 +6,7 @@ export type ModuleGateConfig = {
6
6
  moduleDescriptorReadonly: "file" | "frontmatter" | "off";
7
7
  sourceRoot: string;
8
8
  disableModuleInterfaceImportGate: boolean;
9
+ disableSystemPrompt: boolean;
9
10
  };
10
11
 
11
12
  const DEFAULTS: ModuleGateConfig = {
@@ -13,6 +14,7 @@ const DEFAULTS: ModuleGateConfig = {
13
14
  moduleDescriptorReadonly: "file",
14
15
  sourceRoot: "src/",
15
16
  disableModuleInterfaceImportGate: false,
17
+ disableSystemPrompt: false,
16
18
  };
17
19
 
18
20
  export function loadConfig(cwd: string): ModuleGateConfig {
@@ -0,0 +1,17 @@
1
+ ## Module gates (boundary enforcement)
2
+ This project uses `{{descriptorFileName}}`(case-insensitive) files to declare visibility, readonly and frozen rules that you should follow.
3
+ If you cannot comply, reconsider your design or raise to the user with tradeoffs if necessary.
4
+ Each `{{descriptorFileName}}` gates its branching point in the tree.
5
+ A `{{descriptorFileName}}` with a `visible` list means only entries in the list are allowed to be visible outside the module.
6
+
7
+ - Violations will be blocked.
8
+ {{#if descriptorReadonly}}- {{descriptorReadonly}}{{/if}}
9
+ {{#if moduleInterfaceImportGate}}- {{moduleInterfaceImportGate}}{{/if}}
10
+
11
+ ### Glossary
12
+ - `module`: a directory containing code;
13
+ - `external files`: files not in the module directory;
14
+ - `module interface`: the file representing the module surface, e.g. `index.ts` in Typescript, `mod.rs` in Rust;
15
+ - `readonly`: files are readonly;
16
+ - `frozen`: files cannot add new exports, but still editable;
17
+ - `visible`: visible from outside the module; files not in the module directory are outside the module;
@@ -1,3 +1,7 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
1
5
  import type { ModuleIndex } from "../types.ts";
2
6
  import type { ModuleGateConfig } from "../config.ts";
3
7
 
@@ -7,22 +11,38 @@ export function buildSystemPromptHint(
7
11
  descriptorFileName: string,
8
12
  config: ModuleGateConfig,
9
13
  ): string {
14
+ if (config.disableSystemPrompt) return systemPrompt;
10
15
  if (index.contracts.length === 0) return systemPrompt;
11
16
 
12
- const descriptorNote =
17
+ const descriptorReadonly =
13
18
  config.moduleDescriptorReadonly === "frontmatter"
14
- ? ` The frontmatter of \`${descriptorFileName}\` is readonly.`
19
+ ? `The frontmatter of \`${descriptorFileName}\` is readonly.`
15
20
  : config.moduleDescriptorReadonly === "file"
16
- ? ` The \`${descriptorFileName}\` file itself is readonly.`
21
+ ? `The \`${descriptorFileName}\` file itself is readonly.`
17
22
  : "";
18
23
 
19
- return systemPrompt + `
24
+ const moduleInterfaceImportGate = config.disableModuleInterfaceImportGate
25
+ ? ""
26
+ : "External files can only import through the module interface (e.g. `index.ts` in TypeScript, `mod.rs` in Rust).";
27
+
28
+ const section = applyTemplate(TEMPLATE, {
29
+ descriptorFileName,
30
+ descriptorReadonly,
31
+ moduleInterfaceImportGate,
32
+ });
20
33
 
21
- ## Module gates (boundary enforcement)
22
- This project uses \`${descriptorFileName}\`(case-insensitive) files to declare visibility, readonly and frozen rules that you should follow.
23
- If you cannot comply, reconsider your design, if impossible, raise to the user with tradeoffs.
24
- Each \`${descriptorFileName}\` gates its branching point in the tree.
25
- A \`${descriptorFileName}\` with a \`visible\` list means only entries in the list are allowed to be visible outside the module.
26
- \`readonly\` files are readonly; \`frozen\` files cannot add new exports.${descriptorNote}
27
- Violations will be blocked.`;
34
+ return systemPrompt + "\n\n" + section;
28
35
  }
36
+
37
+ const TEMPLATE = fs.readFileSync(
38
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "system-prompt.template.md"),
39
+ "utf-8",
40
+ );
41
+
42
+ function applyTemplate(template: string, vars: Record<string, string>): string {
43
+ const ifBlock = /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g;
44
+ const variable = /\{\{(\w+)\}\}/g;
45
+ return template
46
+ .replace(ifBlock, (_match, name: string, body: string) => (vars[name] ? body : ""))
47
+ .replace(variable, (_match, name: string) => vars[name] ?? "");
48
+ }
@@ -5,4 +5,5 @@ import "./go.ts";
5
5
  import "./kotlin.ts";
6
6
  import "./scala.ts";
7
7
 
8
- export { registerChecker, getChecker, ExportChecker } from "./registry.ts";
8
+ export { registerChecker, getChecker } from "./registry.ts";
9
+ export type { ExportChecker } from "./registry.ts";
@@ -0,0 +1,112 @@
1
+ import * as path from "node:path";
2
+ import { parseFrontmatter } from "../utils/frontmatter.ts";
3
+ import type { ModuleIndex } from "../types.ts";
4
+ import type { ModuleGateConfig } from "../config.ts";
5
+ import { readFileSafe, applyEdits, isWithinSourceRoot, findOwningModule } from "../utils.ts";
6
+ import {
7
+ checkReadonly,
8
+ checkFrozen,
9
+ checkExports,
10
+ checkModuleInterfaceImports,
11
+ } from "./index.ts";
12
+ import "./checkers/index.ts";
13
+
14
+ export type GateEdit = { oldText: string; newText: string };
15
+
16
+ export type GateDenial = { block: true; reason: string };
17
+
18
+ export function runGates(
19
+ filePath: string,
20
+ edits: GateEdit[],
21
+ cwd: string,
22
+ index: ModuleIndex,
23
+ config: ModuleGateConfig,
24
+ beforeOverride?: string,
25
+ ): GateDenial | undefined {
26
+ const absPath = path.resolve(cwd, filePath);
27
+
28
+ const before = beforeOverride ?? readFileSafe(absPath);
29
+ const after = applyEdits(before, edits);
30
+ const srcRoot = path.resolve(cwd, config.sourceRoot);
31
+
32
+ if (!isWithinSourceRoot(absPath, srcRoot)) return undefined;
33
+
34
+ const readonlyResult = checkReadonly(filePath, index, cwd, config.moduleDescriptorFileName);
35
+ if (readonlyResult.blocked) {
36
+ if (
37
+ config.moduleDescriptorReadonly === "frontmatter" &&
38
+ isDescriptorFile(absPath, config.moduleDescriptorFileName)
39
+ ) {
40
+ const fmBefore = extractFrontmatter(before);
41
+ const fmAfter = extractFrontmatter(after);
42
+ if (JSON.stringify(fmBefore) === JSON.stringify(fmAfter)) {
43
+ return undefined;
44
+ }
45
+ return {
46
+ block: true,
47
+ reason: formatDenial(
48
+ filePath,
49
+ `Readonly rule: frontmatter of ${config.moduleDescriptorFileName} is readonly`,
50
+ absPath,
51
+ index,
52
+ cwd,
53
+ config.moduleDescriptorFileName,
54
+ ),
55
+ };
56
+ }
57
+ return { block: true, reason: formatDenial(filePath, readonlyResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
58
+ }
59
+
60
+ const frozenResult = checkFrozen(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
61
+ if (frozenResult.blocked) {
62
+ return { block: true, reason: formatDenial(filePath, frozenResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
63
+ }
64
+
65
+ const exportResult = checkExports(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
66
+ if (exportResult.blocked) {
67
+ return { block: true, reason: formatDenial(filePath, exportResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
68
+ }
69
+
70
+ const importResult = checkModuleInterfaceImports(filePath, after, index, cwd, config.disableModuleInterfaceImportGate, config.sourceRoot);
71
+ if (importResult.blocked) {
72
+ return { block: true, reason: formatDenial(filePath, importResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
73
+ }
74
+
75
+ return undefined;
76
+ }
77
+
78
+ export function formatDenial(
79
+ relPath: string,
80
+ reason: string,
81
+ absPath: string,
82
+ index: ModuleIndex,
83
+ cwd: string,
84
+ descriptorFileName: string,
85
+ ): string {
86
+ const modulePath = findOwningModule(absPath, index);
87
+ const contract = modulePath
88
+ ? index.contracts.find((c) => c.modulePath === modulePath)
89
+ : undefined;
90
+
91
+ let message = `[Module Gate] Write blocked — ${relPath}\n\n${reason}`;
92
+
93
+ if (contract && contract.prose) {
94
+ const relModuleMd = path.relative(cwd, path.join(contract.modulePath, descriptorFileName));
95
+ message += `\n\nModule contract (${relModuleMd}):\n${contract.prose}`;
96
+ }
97
+
98
+ return message;
99
+ }
100
+
101
+ export function isDescriptorFile(absPath: string, descriptorFileName: string): boolean {
102
+ const basename = path.basename(absPath);
103
+ return basename.toLowerCase() === descriptorFileName.toLowerCase();
104
+ }
105
+
106
+ export function extractFrontmatter(content: string): Record<string, unknown> {
107
+ try {
108
+ return parseFrontmatter(content).frontmatter;
109
+ } catch {
110
+ return {};
111
+ }
112
+ }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { readdir } from "node:fs/promises";
4
- import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
4
+ import { parseFrontmatter } from "../utils/frontmatter.ts";
5
5
  import type { ModuleContract, ModuleIndex } from "../types.ts";
6
6
  import type { ModuleGateConfig } from "../config.ts";
7
7
 
package/src/index.ts CHANGED
@@ -4,18 +4,13 @@ import type {
4
4
  ToolCallEventResult,
5
5
  BeforeAgentStartEventResult,
6
6
  } from "@earendil-works/pi-coding-agent";
7
- import { isToolCallEventType, parseFrontmatter } from "@earendil-works/pi-coding-agent";
7
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
8
8
  import type { ModuleIndex } from "./types.ts";
9
9
  import { loadConfig } from "./config.ts";
10
10
  import type { ModuleGateConfig } from "./config.ts";
11
11
  import { buildModuleIndex } from "./graph/index.ts";
12
- import { findOwningModule, readFileSafe, applyEdits, isWithinSourceRoot } from "./utils.ts";
13
- import {
14
- checkReadonly,
15
- checkExports,
16
- checkFrozen,
17
- checkModuleInterfaceImports,
18
- } from "./gates/index.ts";
12
+ import { readFileSafe } from "./utils.ts";
13
+ import { runGates, type GateEdit } from "./gates/run-gates.ts";
19
14
  import { buildSystemPromptHint } from "./context/index.ts";
20
15
  import "./gates/checkers/index.ts";
21
16
 
@@ -43,107 +38,13 @@ export default function (pi: ExtensionAPI): void {
43
38
 
44
39
  pi.on("tool_call", async (event, ctx): Promise<ToolCallEventResult | void> => {
45
40
  if (isToolCallEventType("edit", event)) {
46
- return handleEdit(event.input.path, event.input.edits, ctx.cwd, index, config);
41
+ return runGates(event.input.path, event.input.edits, ctx.cwd, index, config);
47
42
  }
48
43
  if (isToolCallEventType("write", event)) {
49
44
  const absPath = path.resolve(ctx.cwd, event.input.path);
50
45
  const before = readFileSafe(absPath);
51
- return handleEdit(event.input.path, [{ oldText: before, newText: event.input.content }], ctx.cwd, index, config);
46
+ const edits: GateEdit[] = [{ oldText: before, newText: event.input.content }];
47
+ return runGates(event.input.path, edits, ctx.cwd, index, config);
52
48
  }
53
49
  });
54
- }
55
-
56
- function handleEdit(
57
- filePath: string,
58
- edits: { oldText: string; newText: string }[],
59
- cwd: string,
60
- index: ModuleIndex,
61
- config: ModuleGateConfig,
62
- ): ToolCallEventResult | undefined {
63
- const absPath = path.resolve(cwd, filePath);
64
-
65
- const before = readFileSafe(absPath);
66
- const after = applyEdits(before, edits);
67
- const srcRoot = path.resolve(cwd, config.sourceRoot);
68
-
69
- if (!isWithinSourceRoot(absPath, srcRoot)) return undefined;
70
-
71
- const readonlyResult = checkReadonly(filePath, index, cwd, config.moduleDescriptorFileName);
72
- if (readonlyResult.blocked) {
73
- if (
74
- config.moduleDescriptorReadonly === "frontmatter" &&
75
- isDescriptorFile(absPath, config.moduleDescriptorFileName)
76
- ) {
77
- const fmBefore = extractFrontmatter(before);
78
- const fmAfter = extractFrontmatter(after);
79
- if (JSON.stringify(fmBefore) === JSON.stringify(fmAfter)) {
80
- return undefined;
81
- }
82
- return {
83
- block: true,
84
- reason: formatDenial(
85
- filePath,
86
- `Readonly rule: frontmatter of ${config.moduleDescriptorFileName} is readonly`,
87
- absPath,
88
- index,
89
- cwd,
90
- config.moduleDescriptorFileName,
91
- ),
92
- };
93
- }
94
- return { block: true, reason: formatDenial(filePath, readonlyResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
95
- }
96
-
97
- const frozenResult = checkFrozen(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
98
- if (frozenResult.blocked) {
99
- return { block: true, reason: formatDenial(filePath, frozenResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
100
- }
101
-
102
- const exportResult = checkExports(filePath, before, after, index, cwd, config.moduleDescriptorFileName);
103
- if (exportResult.blocked) {
104
- return { block: true, reason: formatDenial(filePath, exportResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
105
- }
106
-
107
- const importResult = checkModuleInterfaceImports(filePath, after, index, cwd, config.disableModuleInterfaceImportGate, config.sourceRoot);
108
- if (importResult.blocked) {
109
- return { block: true, reason: formatDenial(filePath, importResult.reason, absPath, index, cwd, config.moduleDescriptorFileName) };
110
- }
111
-
112
- return undefined;
113
- }
114
-
115
- function formatDenial(
116
- relPath: string,
117
- reason: string,
118
- absPath: string,
119
- index: ModuleIndex,
120
- cwd: string,
121
- descriptorFileName: string,
122
- ): string {
123
- const modulePath = findOwningModule(absPath, index);
124
- const contract = modulePath
125
- ? index.contracts.find((c) => c.modulePath === modulePath)
126
- : undefined;
127
-
128
- let message = `[Module Gate] Write blocked — ${relPath}\n\n${reason}`;
129
-
130
- if (contract && contract.prose) {
131
- const relModuleMd = path.relative(cwd, path.join(contract.modulePath, descriptorFileName));
132
- message += `\n\nModule contract (${relModuleMd}):\n${contract.prose}`;
133
- }
134
-
135
- return message;
136
- }
137
-
138
- function isDescriptorFile(absPath: string, descriptorFileName: string): boolean {
139
- const basename = path.basename(absPath);
140
- return basename.toLowerCase() === descriptorFileName.toLowerCase();
141
- }
142
-
143
- function extractFrontmatter(content: string): Record<string, unknown> {
144
- try {
145
- return parseFrontmatter(content).frontmatter;
146
- } catch {
147
- return {};
148
- }
149
- }
50
+ }
@@ -0,0 +1,41 @@
1
+ import { parse } from "yaml";
2
+
3
+ type ParsedFrontmatter<T extends Record<string, unknown>> = {
4
+ frontmatter: T;
5
+ body: string;
6
+ };
7
+
8
+ const normalizeNewlines = (value: string): string =>
9
+ value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
10
+
11
+ const extractFrontmatter = (
12
+ content: string,
13
+ ): { yamlString: string | null; body: string } => {
14
+ const normalized = normalizeNewlines(content);
15
+ if (!normalized.startsWith("---")) {
16
+ return { yamlString: null, body: normalized };
17
+ }
18
+ const endIndex = normalized.indexOf("\n---", 3);
19
+ if (endIndex === -1) {
20
+ return { yamlString: null, body: normalized };
21
+ }
22
+ return {
23
+ yamlString: normalized.slice(4, endIndex),
24
+ body: normalized.slice(endIndex + 4).trim(),
25
+ };
26
+ };
27
+
28
+ export function parseFrontmatter<T extends Record<string, unknown> = Record<string, unknown>>(
29
+ content: string,
30
+ ): ParsedFrontmatter<T> {
31
+ const { yamlString, body } = extractFrontmatter(content);
32
+ if (!yamlString) {
33
+ return { frontmatter: {} as T, body };
34
+ }
35
+ const parsed = parse(yamlString) as T | null | undefined;
36
+ return { frontmatter: (parsed ?? ({} as T)), body };
37
+ }
38
+
39
+ export function stripFrontmatter(content: string): string {
40
+ return parseFrontmatter(content).body;
41
+ }