@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.
- package/CHANGELOG.md +50 -0
- package/README.md +61 -20
- package/claude/CLAUDE.md +1 -1
- package/claude/agents/aki-hands.md +1 -1
- package/claude/agents/aki-maker.md +2 -2
- package/claude/fragments/settings.akidoc.fragment.json +5 -0
- package/claude/hooks/aki-route-guard.mjs +94 -43
- package/install.mjs +247 -61
- package/package.json +1 -1
- package/payload/GEMINI.md +7 -7
- package/payload/RULE-agent-behavior.md +24 -23
- package/payload/RULE-coding.md +8 -6
- package/payload/RULE-docs.md +1 -0
- package/payload/RULE-release.md +6 -5
- package/payload/RULE-stack-akiNuxtCf.md +3 -2
- package/payload/RULE-stack-tauri.md +11 -1
- package/payload/RULE-test.md +1 -1
- package/payload/RULE-ui-pattern.md +9 -2
- package/payload/route-map.json +14 -0
- package/skills/akiflow/SKILL.md +4 -2
- package/skills/akiflow/references/harness-facts.md +26 -5
- package/skills/akiflow/scripts/plan_lint.py +123 -0
- package/skills/akiflow/scripts/scythe.py +12 -10
- package/skills/akiflow/scripts/ship_gate.py +65 -0
- package/skills/akiflow/scripts/{test_lint.py → suite_lint.py} +18 -5
- package/skills/akigitcommit/SKILL.md +1 -1
- package/skills/akihelp/SKILL.md +3 -2
- package/skills/akilint/SKILL.md +1 -1
- package/skills/akirule/SKILL.md +6 -5
- package/skills/akiship/SKILL.md +7 -7
- package/skills/akithink/SKILL.md +2 -2
- package/skills/noyap/SKILL.md +44 -0
|
@@ -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
|
|
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
|
-
/**
|
|
24
|
-
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) {
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
rules.add(
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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)
|
|
113
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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 {
|