@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.
- package/CHANGELOG.md +38 -0
- package/README.md +53 -16
- package/claude/CLAUDE.md +1 -1
- package/claude/agents/aki-conduct.md +3 -1
- package/claude/agents/aki-hands.md +1 -1
- package/claude/agents/aki-judge.md +1 -1
- package/claude/agents/aki-maker.md +2 -1
- package/claude/hooks/aki-compact-reread.mjs +16 -0
- package/claude/hooks/aki-route-guard.mjs +101 -41
- package/install.mjs +291 -22
- package/package.json +1 -1
- package/payload/GEMINI.md +4 -4
- package/payload/METHOD-audit-subtraction.md +1 -0
- package/payload/METHOD-audit-zero-trust.md +1 -1
- package/payload/RULE-agent-behavior.md +41 -18
- package/payload/RULE-coding.md +5 -2
- package/payload/RULE-pattern-core.md +2 -2
- package/payload/RULE-release.md +3 -3
- package/payload/RULE-stack-akiNuxtCf.md +1 -1
- package/payload/RULE-test.md +53 -0
- package/payload/route-map.json +14 -0
- package/skills/akiflow/SKILL.md +1 -1
- package/skills/akiflow/scripts/scythe.py +1 -1
- package/skills/akiflow/scripts/suite_lint.py +168 -0
- package/skills/akihelp/SKILL.md +1 -0
- package/skills/akilint/SKILL.md +1 -1
- package/skills/akirule/SKILL.md +4 -2
- package/skills/akiship/SKILL.md +3 -3
- package/skills/akithink/SKILL.md +2 -2
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
21
|
-
function
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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)
|
|
104
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
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 {
|