@akinet/akidevrule 3.6.0 → 3.8.0

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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // PreToolUse route gate, fail-open. Design: docs/research/rule-delivery-second-hop-sep29.md
3
- import { readFileSync, existsSync } from "node:fs";
3
+ import { readFileSync, existsSync, statSync, realpathSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join, dirname, basename, extname } from "node:path";
6
6
 
@@ -8,50 +8,51 @@ const HOME = homedir();
8
8
  const RULE_DIR = join(HOME, ".aki", "akidevrule");
9
9
  const CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || join(HOME, ".claude");
10
10
  const MARK = "aki-route-guard";
11
- const MAX_DENIALS_PER_RULE = 3; // a detection bug must never lock a session; after this many denials the rule is treated as read
11
+ const COMPACT = "compact_boundary";
12
+ const MAX_DENIALS_PER_RULE = 3; // a detection bug must never lock a session; after this many denials in one compaction segment the rule is treated as read
12
13
 
13
- const CODE_EXT = new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "vue", "svelte", "rs", "py", "go", "rb", "php", "java", "kt", "swift", "c", "cc", "cpp", "h", "hpp", "cs", "sh", "bash", "zsh", "ps1", "sql", "css", "scss", "lua", "dart"]);
14
- const FRONTEND_EXT = new Set(["vue", "svelte", "css", "scss", "tsx", "jsx"]);
14
+ const MAX_WIDEN_BYTES = 60000; // the Read tool refuses files over ~25k tokens; above this a partial read is the only possible one
15
15
 
16
16
  function allow() {
17
17
  process.exit(0);
18
18
  }
19
19
 
20
- /** Rule files a path routes to; only routes with an artifact signature (docs/research/rule-delivery-second-hop-sep29.md §2). */
21
- function routesFor(filePath, cwd) {
20
+ /** payload/route-map.json (installed to RULE_DIR), the one path-to-rule map; null when missing or invalid so the gate fails open. */
21
+ function loadMap() {
22
+ try {
23
+ const m = JSON.parse(readFileSync(join(RULE_DIR, "route-map.json"), "utf8"));
24
+ return Array.isArray(m.routes) && m.ungated ? m : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** Rule files a path routes to, interpreting the map's routes. */
31
+ function routesFor(map, filePath, cwd) {
22
32
  const p = filePath.replace(/\\/g, "/");
23
33
  const name = basename(p);
24
34
  const ext = extname(name).slice(1).toLowerCase();
25
35
  const rules = new Set();
26
- if (CODE_EXT.has(ext)) {
27
- rules.add("RULE-coding.md");
28
- rules.add("RULE-pattern-core.md");
36
+ const root = cwd ? cwd.replace(/\\/g, "/").replace(/\/+$/, "") + "/" : "";
37
+ const rel = "/" + (root && p.startsWith(root) ? p.slice(root.length) : p.replace(/^\//, "")); // directory routes match inside the project only: a project under ~/tests/ or ~/lang/ must not route every file
38
+ for (const r of map.routes) {
39
+ const hit = (r.ext || []).includes(ext) || (r.name || []).includes(name) || (r.nameRe && new RegExp(r.nameRe).test(name)) || (r.dirRe && !(r.dirSkipExt || []).includes(ext) && new RegExp(r.dirRe).test(rel));
40
+ if (hit && (!r.projectHas || (cwd && r.projectHas.some((f) => existsSync(join(cwd, f)))))) for (const x of r.rules) rules.add(x);
29
41
  }
30
- if (ext === "md") rules.add("RULE-docs.md");
31
- if (name === "CHANGELOG.md" || name === "releases.json") rules.add("RULE-release.md");
32
- if (FRONTEND_EXT.has(ext)) rules.add("RULE-ui-pattern.md");
33
- if (ext === "sql" || /\/migrations\//.test(p)) rules.add("RULE-db-design.md");
34
- if (/\/(locales|i18n|lang)\//.test(p)) rules.add("RULE-content-write.md");
35
- if (ext === "rs" || /\/src-tauri\//.test(p) || name === "tauri.conf.json") rules.add("RULE-stack-tauri.md");
36
- if ((ext === "vue" || ext === "ts") && isNuxtProject(cwd)) rules.add("RULE-stack-akiNuxtCf.md");
37
42
  return rules;
38
43
  }
39
44
 
40
45
  /** Scratchpad, harness state and the corpus itself: files no project rule governs, so a Q&A session that writes a throwaway script or a memory note stays ungated. */
41
- function isUngatedPath(filePath, cwd) {
46
+ function isUngatedPath(map, filePath, cwd) {
42
47
  const p = filePath.replace(/\\/g, "/");
43
- if (p.includes("/.aki/")) return true;
48
+ const u = map.ungated;
49
+ if ((u.segments || []).some((s) => p.includes(s))) return true;
44
50
  if (p.startsWith(CONFIG_DIR.replace(/\\/g, "/") + "/")) return true;
45
- if (/^\/tmp\//.test(p) || /\/scratchpad\//.test(p)) return true;
46
- if (cwd && !p.startsWith(cwd.replace(/\\/g, "/") + "/")) return true;
51
+ if ((u.prefixes || []).some((s) => p.startsWith(s)) || (u.dirs || []).some((d) => p.includes(`/${d}/`))) return true;
52
+ if (cwd && !p.startsWith(cwd.replace(/\\/g, "/").replace(/\/+$/, "") + "/")) return true;
47
53
  return false;
48
54
  }
49
55
 
50
- function isNuxtProject(cwd) {
51
- if (!cwd) return false;
52
- return ["nuxt.config.ts", "nuxt.config.js", "nuxt.config.mjs"].some((f) => existsSync(join(cwd, f)));
53
- }
54
-
55
56
  /** Rule files the harness already embeds via `@` imports in the global CLAUDE.md; never gated. */
56
57
  function residentRules() {
57
58
  const out = new Set();
@@ -76,66 +77,125 @@ function transcriptFor(input) {
76
77
  return main;
77
78
  }
78
79
 
79
- /** Scan a transcript once: which rule files were Read (Read tool, or a Bash cat/sed/head/bat of the file), and how many times this hook already denied for each. */
80
+ /** Scan a transcript once: which rule files were read whole (Read tool without offset/limit short of the file, or a Bash/Shell cat/bat/less of it; head/sed/tail never count) since the last compaction, and how many times this hook already denied for each in that segment. A compaction drops the rule text from the model's context, so reads and denials before it do not count (agent.B7 row 2). Claude Code stores the turn as type "assistant" and the Read target as file_path. Cursor stores role "assistant", the Read target as path, and the shell tool as Shell. */
80
81
  function scanTranscript(path, wanted) {
81
82
  const read = new Set();
83
+ const partial = new Set();
82
84
  const denials = new Map();
85
+ const pending = new Map(); // partial Read tool_use id -> rules, resolved when the hook's updatedInput attachment for that id appears
83
86
  let text;
84
87
  try {
85
88
  text = readFileSync(path, "utf8");
86
89
  } catch {
87
- return { read, denials, unreadable: true };
90
+ return { read, partial, denials, unreadable: true };
88
91
  }
89
92
  for (const line of text.split("\n")) {
90
- if (!line.includes("akidevrule") && !line.includes(MARK)) continue;
93
+ if (!line.includes("akidevrule") && !line.includes(MARK) && !line.includes(COMPACT)) continue;
91
94
  let d;
92
95
  try {
93
96
  d = JSON.parse(line);
94
97
  } catch {
95
98
  continue;
96
99
  }
97
- if (d.type === "assistant") {
100
+ const assistant = d.type === "assistant" || d.role === "assistant";
101
+ if (d.type === "system" && d.subtype === COMPACT) {
102
+ read.clear();
103
+ partial.clear();
104
+ denials.clear();
105
+ pending.clear();
106
+ } else if (assistant) {
98
107
  const content = d.message && Array.isArray(d.message.content) ? d.message.content : [];
99
108
  for (const c of content) {
100
109
  if (!c || c.type !== "tool_use" || !c.input) continue;
101
110
  if (c.name === "Read") {
102
- const target = String(c.input.file_path || "");
103
- for (const r of wanted) if (target.endsWith(r)) read.add(r);
104
- } else if (c.name === "Bash") {
111
+ const target = String(c.input.file_path || c.input.path || "");
112
+ for (const r of wanted) {
113
+ if (!target.endsWith(r)) continue;
114
+ const whole = !(c.input.offset > 1) && (!c.input.limit || c.input.limit >= lineCount(r));
115
+ (whole ? read : partial).add(r);
116
+ if (!whole && c.id) pending.set(c.id, [...(pending.get(c.id) || []), r]);
117
+ }
118
+ } else if (c.name === "Bash" || c.name === "Shell") {
105
119
  const cmd = String(c.input.command || "");
106
- for (const r of wanted) if (new RegExp(`\\b(cat|sed|head|bat|less)\\b[^|;&\\n]*${r.replace(".", "\\.")}`).test(cmd)) read.add(r);
120
+ for (const r of wanted) {
121
+ const name = r.replace(".", "\\.");
122
+ for (const m of cmd.matchAll(new RegExp(`(?:^|[;&|(\\n])\\s*(?:cat|bat|less)\\s[^|;&\\n]*?${name}[^|;&\\n]*(\\|(?!\\|))?`, "g"))) (m[1] ? partial : read).add(r);
123
+ if (new RegExp(`\\b(head|sed|tail)\\b[^|;&\\n]*${name}`).test(cmd)) partial.add(r);
124
+ }
107
125
  }
108
126
  }
109
127
  } else if (d.attachment && typeof d.attachment.stdout === "string" && d.attachment.stdout.includes(MARK)) {
110
- for (const r of wanted) if (d.attachment.stdout.includes(r)) denials.set(r, (denials.get(r) || 0) + 1);
128
+ const a = d.attachment;
129
+ const widened = String(a.hookName || "").startsWith("PreToolUse") && a.stdout.includes('"updatedInput"') && pending.has(a.toolUseID);
130
+ if (widened) {
131
+ for (const r of pending.get(a.toolUseID)) {
132
+ partial.delete(r);
133
+ read.add(r);
134
+ }
135
+ pending.delete(a.toolUseID);
136
+ } else if (a.stdout.includes('"permissionDecision":"deny"')) {
137
+ for (const r of wanted) if (a.stdout.includes(r)) denials.set(r, (denials.get(r) || 0) + 1);
138
+ }
111
139
  }
112
140
  }
113
- return { read, denials, unreadable: false };
141
+ return { read, partial, denials, unreadable: false };
142
+ }
143
+
144
+ function lineCount(rule) {
145
+ try {
146
+ return readFileSync(join(RULE_DIR, rule), "utf8").replace(/\n$/, "").split("\n").length;
147
+ } catch {
148
+ return Infinity;
149
+ }
114
150
  }
115
151
 
116
- function deny(missing, filePath) {
152
+ function deny(missing, partial, filePath) {
117
153
  const files = missing.map((r) => `~/.aki/akidevrule/${r}`).join(" and ");
118
- const reason = `[${MARK}] Editing ${basename(filePath)} is gated on rule files not yet read in this session: ${missing.join(", ")}. Read ${files} in full with the Read tool, add them to the [RULES] receipt, then retry this edit.`;
154
+ const reason = `[${MARK}] Editing ${basename(filePath)} is gated on rule files not read since the last compaction of this session: ${missing.join(", ")}. Read ${files} in full with the Read tool, add them to the [RULES] receipt, then retry this edit.${missing.some((r) => partial.has(r)) ? " A Read with limit/offset or a head/sed/tail print does not count — read the whole file." : ""}`;
119
155
  process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } }) + "\n");
120
156
  process.exit(0);
121
157
  }
122
158
 
159
+ /** A partial Read of a rule file is rewritten to a whole one; above the Read tool's size cap a partial read is the only one possible, so it passes. */
160
+ function widenRuleRead(ti) {
161
+ if (ti.limit == null && ti.offset == null) allow();
162
+ // permissionDecision allow skips the permission prompt, so it applies only to the corpus's own files, judged on the resolved path
163
+ const real = (x) => {
164
+ try {
165
+ return realpathSync(x).replace(/\\/g, "/");
166
+ } catch {
167
+ return null;
168
+ }
169
+ };
170
+ const p = real(String(ti.file_path || ti.path || ""));
171
+ if (!p) allow();
172
+ const inside = (dir) => dir !== null && p.startsWith(dir + "/");
173
+ const isRule = (inside(real(RULE_DIR)) && p.endsWith(".md")) || (basename(p) === "SKILL.md" && inside(real(join(CONFIG_DIR, "skills"))));
174
+ if (!isRule || statSync(p).size > MAX_WIDEN_BYTES) allow();
175
+ const { limit, offset, ...updatedInput } = ti;
176
+ const reason = `[${MARK}] rule files are read whole: limit/offset removed`;
177
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: reason, updatedInput } }) + "\n");
178
+ process.exit(0);
179
+ }
180
+
123
181
  function main() {
124
182
  if (process.env.AKI_ROUTE_GUARD === "0") allow();
125
183
  const input = JSON.parse(readFileSync(0, "utf8"));
126
184
  const ti = input.tool_input || {};
185
+ if (input.tool_name === "Read") widenRuleRead(ti);
127
186
  const filePath = ti.file_path || ti.notebook_path;
128
187
  if (!filePath || !input.transcript_path) allow();
129
- if (isUngatedPath(filePath, input.cwd)) allow();
130
- const wanted = [...routesFor(filePath, input.cwd)].filter((r) => existsSync(join(RULE_DIR, r)));
188
+ const map = loadMap();
189
+ if (!map || isUngatedPath(map, filePath, input.cwd)) allow();
190
+ const wanted = [...routesFor(map, filePath, input.cwd)].filter((r) => existsSync(join(RULE_DIR, r)));
131
191
  const resident = residentRules();
132
192
  const gated = wanted.filter((r) => !resident.has(r));
133
193
  if (gated.length === 0) allow();
134
- const { read, denials, unreadable } = scanTranscript(transcriptFor(input), gated);
194
+ const { read, partial, denials, unreadable } = scanTranscript(transcriptFor(input), gated);
135
195
  if (unreadable) allow();
136
196
  const missing = gated.filter((r) => !read.has(r) && (denials.get(r) || 0) < MAX_DENIALS_PER_RULE);
137
197
  if (missing.length === 0) allow();
138
- deny(missing, filePath);
198
+ deny(missing, partial, filePath);
139
199
  }
140
200
 
141
201
  try {