@akinet/akidevrule 3.7.0 → 3.9.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
 
@@ -11,53 +11,48 @@ const MARK = "aki-route-guard";
11
11
  const COMPACT = "compact_boundary";
12
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
13
13
 
14
- 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"]);
15
- const FRONTEND_EXT = new Set(["vue", "svelte", "css", "scss", "tsx", "jsx"]);
16
- const TEST_NAME = /\.(test|spec)\.[a-z]+$|_test\.[a-z]+$|^test_.*\.py$|^conftest\.py$/;
17
- const TEST_DIR = /(^|\/)(test|tests|__tests__|spec)\//;
14
+ const MAX_WIDEN_BYTES = 60000; // the Read tool refuses files over ~25k tokens; above this a partial read is the only possible one
18
15
 
19
16
  function allow() {
20
17
  process.exit(0);
21
18
  }
22
19
 
23
- /** Rule files a path routes to; only routes with an artifact signature (docs/research/rule-delivery-second-hop-sep29.md §2). */
24
- 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) {
25
32
  const p = filePath.replace(/\\/g, "/");
26
33
  const name = basename(p);
27
34
  const ext = extname(name).slice(1).toLowerCase();
28
35
  const rules = new Set();
29
- const root = cwd ? cwd.replace(/\\/g, "/") + "/" : "";
36
+ const root = cwd ? cwd.replace(/\\/g, "/").replace(/\/+$/, "") + "/" : "";
30
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
31
- if (TEST_NAME.test(name) || (TEST_DIR.test(rel) && ext !== "md")) rules.add("RULE-test.md");
32
- if (CODE_EXT.has(ext)) {
33
- rules.add("RULE-coding.md");
34
- rules.add("RULE-pattern-core.md");
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);
35
41
  }
36
- if (ext === "md") rules.add("RULE-docs.md");
37
- if (name === "CHANGELOG.md" || name === "releases.json") rules.add("RULE-release.md");
38
- if (FRONTEND_EXT.has(ext)) rules.add("RULE-ui-pattern.md");
39
- if (ext === "sql" || /\/migrations\//.test(rel)) rules.add("RULE-db-design.md");
40
- if (/\/(locales|i18n|lang)\//.test(rel)) rules.add("RULE-content-write.md");
41
- if (ext === "rs" || /\/src-tauri\//.test(rel) || name === "tauri.conf.json") rules.add("RULE-stack-tauri.md");
42
- if ((ext === "vue" || ext === "ts") && isNuxtProject(cwd)) rules.add("RULE-stack-akiNuxtCf.md");
43
42
  return rules;
44
43
  }
45
44
 
46
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. */
47
- function isUngatedPath(filePath, cwd) {
46
+ function isUngatedPath(map, filePath, cwd) {
48
47
  const p = filePath.replace(/\\/g, "/");
49
- if (p.includes("/.aki/")) return true;
48
+ const u = map.ungated;
49
+ if ((u.segments || []).some((s) => p.includes(s))) return true;
50
50
  if (p.startsWith(CONFIG_DIR.replace(/\\/g, "/") + "/")) return true;
51
- if (/^\/tmp\//.test(p) || /\/scratchpad\//.test(p)) return true;
52
- 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;
53
53
  return false;
54
54
  }
55
55
 
56
- function isNuxtProject(cwd) {
57
- if (!cwd) return false;
58
- return ["nuxt.config.ts", "nuxt.config.js", "nuxt.config.mjs"].some((f) => existsSync(join(cwd, f)));
59
- }
60
-
61
56
  /** Rule files the harness already embeds via `@` imports in the global CLAUDE.md; never gated. */
62
57
  function residentRules() {
63
58
  const out = new Set();
@@ -82,15 +77,17 @@ function transcriptFor(input) {
82
77
  return main;
83
78
  }
84
79
 
85
- /** Scan a transcript once: which rule files were Read (Read tool, or a Bash cat/sed/head/bat of the file) 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). */
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. */
86
81
  function scanTranscript(path, wanted) {
87
82
  const read = new Set();
83
+ const partial = new Set();
88
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
89
86
  let text;
90
87
  try {
91
88
  text = readFileSync(path, "utf8");
92
89
  } catch {
93
- return { read, denials, unreadable: true };
90
+ return { read, partial, denials, unreadable: true };
94
91
  }
95
92
  for (const line of text.split("\n")) {
96
93
  if (!line.includes("akidevrule") && !line.includes(MARK) && !line.includes(COMPACT)) continue;
@@ -100,51 +97,105 @@ function scanTranscript(path, wanted) {
100
97
  } catch {
101
98
  continue;
102
99
  }
100
+ const assistant = d.type === "assistant" || d.role === "assistant";
103
101
  if (d.type === "system" && d.subtype === COMPACT) {
104
102
  read.clear();
103
+ partial.clear();
105
104
  denials.clear();
106
- } else if (d.type === "assistant") {
105
+ pending.clear();
106
+ } else if (assistant) {
107
107
  const content = d.message && Array.isArray(d.message.content) ? d.message.content : [];
108
108
  for (const c of content) {
109
109
  if (!c || c.type !== "tool_use" || !c.input) continue;
110
110
  if (c.name === "Read") {
111
- const target = String(c.input.file_path || "");
112
- for (const r of wanted) if (target.endsWith(r)) read.add(r);
113
- } 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") {
114
119
  const cmd = String(c.input.command || "");
115
- 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
+ }
116
125
  }
117
126
  }
118
127
  } else if (d.attachment && typeof d.attachment.stdout === "string" && d.attachment.stdout.includes(MARK)) {
119
- 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
+ }
120
139
  }
121
140
  }
122
- 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
+ }
123
150
  }
124
151
 
125
- function deny(missing, filePath) {
152
+ function deny(missing, partial, filePath) {
126
153
  const files = missing.map((r) => `~/.aki/akidevrule/${r}`).join(" and ");
127
- 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.`;
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." : ""}`;
128
155
  process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } }) + "\n");
129
156
  process.exit(0);
130
157
  }
131
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
+
132
181
  function main() {
133
182
  if (process.env.AKI_ROUTE_GUARD === "0") allow();
134
183
  const input = JSON.parse(readFileSync(0, "utf8"));
135
184
  const ti = input.tool_input || {};
185
+ if (input.tool_name === "Read") widenRuleRead(ti);
136
186
  const filePath = ti.file_path || ti.notebook_path;
137
187
  if (!filePath || !input.transcript_path) allow();
138
- if (isUngatedPath(filePath, input.cwd)) allow();
139
- 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)));
140
191
  const resident = residentRules();
141
192
  const gated = wanted.filter((r) => !resident.has(r));
142
193
  if (gated.length === 0) allow();
143
- const { read, denials, unreadable } = scanTranscript(transcriptFor(input), gated);
194
+ const { read, partial, denials, unreadable } = scanTranscript(transcriptFor(input), gated);
144
195
  if (unreadable) allow();
145
196
  const missing = gated.filter((r) => !read.has(r) && (denials.get(r) || 0) < MAX_DENIALS_PER_RULE);
146
197
  if (missing.length === 0) allow();
147
- deny(missing, filePath);
198
+ deny(missing, partial, filePath);
148
199
  }
149
200
 
150
201
  try {