@ikon85/agent-workflow-kit 0.24.0 → 0.25.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.
@@ -0,0 +1,221 @@
1
+ """Profile-driven decisions for independently activatable Safety Guardrails."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shlex
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from search import scan as scan_search
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class GuardDecision:
17
+ action: str
18
+ message: str = ""
19
+ log_message: str = ""
20
+
21
+
22
+ def load_profile(path: Path) -> dict[str, Any]:
23
+ document = json.loads(path.read_text(encoding="utf-8"))
24
+ section = document.get("safetyGuardrails")
25
+ if not isinstance(section, dict):
26
+ return {}
27
+ return section
28
+
29
+
30
+ def inactive() -> GuardDecision:
31
+ return GuardDecision("allow")
32
+
33
+
34
+ def _secret_name_is_sensitive(name: str, policy: dict[str, Any]) -> bool:
35
+ suffixes = tuple(policy.get("safeTemplateSuffixes") or ())
36
+ if name.startswith(".env"):
37
+ return not any(name.endswith(suffix) for suffix in suffixes)
38
+ return name in set(policy.get("sensitiveNames") or ())
39
+
40
+
41
+ def _secret_path_is_sensitive(path: str, policy: dict[str, Any]) -> bool:
42
+ candidate = Path(path)
43
+ if _secret_name_is_sensitive(candidate.name, policy):
44
+ return True
45
+ normalized = str(candidate).replace("\\", "/")
46
+ return any(fragment in normalized for fragment in policy.get("sensitivePathFragments") or ())
47
+
48
+
49
+ def secret_decision(policy: dict[str, Any], payload: dict[str, Any]) -> GuardDecision:
50
+ if policy.get("enabled") is not True:
51
+ return inactive()
52
+ tool = payload.get("tool_name")
53
+ tool_input = payload.get("tool_input") or {}
54
+ if tool == "Bash":
55
+ return _secret_bash_decision(policy, str(tool_input.get("command") or ""))
56
+ path = str(tool_input.get("file_path") or "")
57
+ if not path or tool not in {"Read", "Edit", "Write", "MultiEdit"}:
58
+ return inactive()
59
+ if tool == "Write" and Path(path).name.startswith(".env"):
60
+ return inactive()
61
+ if not _secret_path_is_sensitive(path, policy):
62
+ return inactive()
63
+ return GuardDecision(
64
+ "block",
65
+ f"BLOCKED: access to sensitive path '{path}' is not allowed.",
66
+ f"blocked tool={tool} path={path!r}",
67
+ )
68
+
69
+
70
+ _DUMP_COMMANDS = {
71
+ "cat", "head", "tail", "less", "more", "strings", "nl", "xxd", "od",
72
+ "bat", "tac", "rev", "grep", "egrep", "fgrep", "rg", "ag", "awk", "sed", "cut",
73
+ }
74
+ _QUIET_GREP = {"grep", "egrep", "fgrep", "rg", "ag"}
75
+ _INSTALL_RE = re.compile(
76
+ r"\b(?P<manager>npm|pnpm|yarn|bun)\s+(?:install|i|ci)\b"
77
+ )
78
+ _BACKGROUND_RE = re.compile(r"(?<![<>&])&[ \t]*(?:;|$)", re.MULTILINE)
79
+
80
+
81
+ def _secret_bash_decision(policy: dict[str, Any], command: str) -> GuardDecision:
82
+ if not command:
83
+ return inactive()
84
+ try:
85
+ segments = re.split(r"\n|;|&&|\|\||\|", command)
86
+ for segment in segments:
87
+ tokens = shlex.split(segment)
88
+ dumps = set(tokens) & _DUMP_COMMANDS
89
+ paths = [token for token in tokens if _secret_path_is_sensitive(token, policy)]
90
+ if not dumps or not paths:
91
+ continue
92
+ if dumps <= _QUIET_GREP and any(flag == "--quiet" or flag.startswith("-q") for flag in tokens):
93
+ continue
94
+ return GuardDecision(
95
+ "block",
96
+ f"BLOCKED: command would print sensitive path '{paths[0]}' to the transcript.",
97
+ f"blocked bash secret dump path={paths[0]!r}",
98
+ )
99
+ except ValueError:
100
+ return inactive()
101
+ return inactive()
102
+
103
+
104
+ def _effective_cwd(command: str, match_pos: int, fallback: Path) -> Path:
105
+ target = None
106
+ for match in re.finditer(r"(?:^|[;&|]\s*)cd\s+([^\s;&|]+)", command):
107
+ if match.end() <= match_pos:
108
+ target = match.group(1).strip("'\"")
109
+ if target is None:
110
+ return fallback
111
+ path = Path(target).expanduser()
112
+ return path if path.is_absolute() else fallback / path
113
+
114
+
115
+ def _detected_manager(policy: dict[str, Any], start: Path) -> tuple[str, Path] | None:
116
+ lockfiles = policy.get("lockfiles") or {}
117
+ try:
118
+ current = start.resolve()
119
+ except OSError:
120
+ return None
121
+ while True:
122
+ for filename, manager in lockfiles.items():
123
+ candidate = current / filename
124
+ if candidate.is_file():
125
+ return str(manager), candidate
126
+ if current.parent == current:
127
+ return None
128
+ current = current.parent
129
+
130
+
131
+ def package_manager_decision(
132
+ policy: dict[str, Any],
133
+ payload: dict[str, Any],
134
+ cwd: Path,
135
+ ) -> GuardDecision:
136
+ if policy.get("enabled") is not True or payload.get("tool_name") != "Bash":
137
+ return inactive()
138
+ command = str((payload.get("tool_input") or {}).get("command") or "")
139
+ match = _INSTALL_RE.search(command)
140
+ if match is None:
141
+ return inactive()
142
+ requested = match.group("manager")
143
+ detected = _detected_manager(policy, _effective_cwd(command, match.start(), cwd))
144
+ if detected is None or detected[0] == requested:
145
+ return inactive()
146
+ manager, lockfile = detected
147
+ return GuardDecision(
148
+ "block",
149
+ f"BLOCKED: `{match.group(0)}` conflicts with {lockfile.name}. "
150
+ f"Use `{manager} install` in this project.",
151
+ f"blocked package-manager requested={requested} expected={manager} lock={lockfile}",
152
+ )
153
+
154
+
155
+ def double_background_decision(
156
+ policy: dict[str, Any],
157
+ payload: dict[str, Any],
158
+ ) -> GuardDecision:
159
+ if policy.get("enabled") is not True or payload.get("tool_name") != "Bash":
160
+ return inactive()
161
+ surface = str(payload.get("surface") or "claude")
162
+ if surface not in set(policy.get("surfaces") or ()):
163
+ return inactive()
164
+ tool_input = payload.get("tool_input") or {}
165
+ is_background = tool_input.get("run_in_background") is True or (
166
+ str(tool_input.get("run_in_background") or "").lower() == "true"
167
+ )
168
+ command = str(tool_input.get("command") or "")
169
+ if not is_background or not _BACKGROUND_RE.search(command):
170
+ return inactive()
171
+ return GuardDecision(
172
+ "block",
173
+ "BLOCKED: run_in_background already detaches and captures this command; "
174
+ "remove the inner `&` background operator.",
175
+ "blocked double-background command",
176
+ )
177
+
178
+
179
+ def search_shim_decision(
180
+ policy: dict[str, Any],
181
+ payload: dict[str, Any],
182
+ ) -> GuardDecision:
183
+ if (
184
+ policy.get("enabled") is not True
185
+ or policy.get("detected") is not True
186
+ or payload.get("tool_name") != "Bash"
187
+ ):
188
+ return inactive()
189
+ command = str((payload.get("tool_input") or {}).get("command") or "")
190
+ finding = scan_search(command, set(policy.get("commandNames") or ()))
191
+ if finding is None:
192
+ return inactive()
193
+ reason, pattern = finding
194
+ return GuardDecision(
195
+ "block",
196
+ f"BLOCKED: search shim cannot safely evaluate pattern `{pattern}` ({reason}). "
197
+ "Use `--fixed-strings` or `command grep`.",
198
+ f"blocked search-shim breaker reason={reason}",
199
+ )
200
+
201
+
202
+ def evaluate(kind: str, profile: dict[str, Any], payload: dict[str, Any]) -> GuardDecision:
203
+ if kind == "secrets":
204
+ return secret_decision(profile.get("secrets") or {}, payload)
205
+ if kind == "package-manager":
206
+ return package_manager_decision(
207
+ profile.get("packageManager") or {},
208
+ payload,
209
+ Path.cwd(),
210
+ )
211
+ if kind == "double-background":
212
+ return double_background_decision(
213
+ profile.get("doubleBackground") or {},
214
+ payload,
215
+ )
216
+ if kind == "search-shim":
217
+ return search_shim_decision(
218
+ profile.get("searchShim") or {},
219
+ payload,
220
+ )
221
+ return inactive()
@@ -0,0 +1,92 @@
1
+ """Verified search-shim breaker detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+
7
+ OPERATORS = {"|", "||", "&&", ";", "&", "|&"}
8
+
9
+
10
+ def _count_unescaped(value: str, character: str) -> int:
11
+ count = index = 0
12
+ while index < len(value):
13
+ if value[index] == "\\":
14
+ index += 2
15
+ continue
16
+ if value[index] == character:
17
+ count += 1
18
+ index += 1
19
+ return count
20
+
21
+
22
+ def _pattern_and_flags(args: list[str]) -> tuple[str | None, set[str]]:
23
+ pattern = None
24
+ flags = set()
25
+ after_options = False
26
+ index = 0
27
+ while index < len(args):
28
+ value = args[index]
29
+ if value == "--":
30
+ after_options = True
31
+ elif not after_options and value in {"-e", "--regexp"}:
32
+ index += 1
33
+ if index < len(args) and pattern is None:
34
+ pattern = args[index]
35
+ elif not after_options and value.startswith("--regexp="):
36
+ if pattern is None:
37
+ pattern = value.split("=", 1)[1]
38
+ elif not after_options and value.startswith("-"):
39
+ flags.add(value)
40
+ elif pattern is None:
41
+ pattern = value
42
+ index += 1
43
+ return pattern, flags
44
+
45
+
46
+ def _has_flag(flags: set[str], letter: str, long: str) -> bool:
47
+ return long in flags or any(
48
+ flag.startswith("-") and not flag.startswith("--") and letter in flag[1:]
49
+ for flag in flags
50
+ )
51
+
52
+
53
+ def _breaker(pattern: str, flags: set[str]) -> str | None:
54
+ if _has_flag(flags, "F", "--fixed-strings"):
55
+ return None
56
+ if _count_unescaped(pattern, "(") != _count_unescaped(pattern, ")"):
57
+ return "unbalanced parentheses"
58
+ extended = _has_flag(flags, "E", "--extended-regexp") or _has_flag(
59
+ flags, "P", "--perl-regexp"
60
+ )
61
+ if not extended and _count_unescaped(pattern, "|"):
62
+ return "bare alternation without extended-regexp mode"
63
+ return None
64
+
65
+
66
+ def scan(command: str, command_names: set[str]) -> tuple[str, str] | None:
67
+ try:
68
+ tokens = shlex.split(command, posix=True)
69
+ except ValueError:
70
+ return None
71
+ index = 0
72
+ while index < len(tokens):
73
+ token = tokens[index]
74
+ shim = token in command_names
75
+ if token == "grep" and index and tokens[index - 1] in {"command", "git"}:
76
+ shim = False
77
+ if token == "grep" and index and tokens[index - 1] == "rtk":
78
+ shim = True
79
+ if not shim:
80
+ index += 1
81
+ continue
82
+ end = index + 1
83
+ args = []
84
+ while end < len(tokens) and tokens[end] not in OPERATORS:
85
+ args.append(tokens[end])
86
+ end += 1
87
+ pattern, flags = _pattern_and_flags(args)
88
+ reason = _breaker(pattern, flags) if pattern is not None else None
89
+ if reason:
90
+ return reason, pattern
91
+ index = end
92
+ return None
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ const SEVERITIES = ['info', 'low', 'moderate', 'high', 'critical'];
5
+
6
+ function emptyCounts() {
7
+ return Object.fromEntries(SEVERITIES.map((severity) => [severity, 0]));
8
+ }
9
+
10
+ function normalizedCounts(input) {
11
+ const counts = emptyCounts();
12
+ for (const severity of SEVERITIES) {
13
+ const value = Number(input?.[severity] ?? 0);
14
+ if (!Number.isInteger(value) || value < 0) throw new Error('invalid vulnerability count');
15
+ counts[severity] = value;
16
+ }
17
+ return counts;
18
+ }
19
+
20
+ function parseJson(input) {
21
+ return JSON.parse(input);
22
+ }
23
+
24
+ function countsFromAdvisories(data) {
25
+ const counts = emptyCounts();
26
+ for (const advisories of Object.values(data)) {
27
+ if (!Array.isArray(advisories)) throw new Error('invalid advisory collection');
28
+ for (const advisory of advisories) {
29
+ const severity = advisory?.severity;
30
+ if (!SEVERITIES.includes(severity)) throw new Error('invalid advisory severity');
31
+ counts[severity] += 1;
32
+ }
33
+ }
34
+ return counts;
35
+ }
36
+
37
+ function parseAudit(manager, input) {
38
+ let data;
39
+ try {
40
+ data = parseJson(input);
41
+ } catch (error) {
42
+ if (manager !== 'yarn') throw error;
43
+ const events = input.trim().split(/\r?\n/).map(parseJson);
44
+ const summary = events.find((event) => event?.type === 'auditSummary');
45
+ if (!summary?.data?.vulnerabilities) throw error;
46
+ return {
47
+ counts: normalizedCounts(summary.data.vulnerabilities),
48
+ data: summary,
49
+ };
50
+ }
51
+ if (manager === 'npm' || manager === 'pnpm') {
52
+ if (data?.error && typeof data.error === 'object') {
53
+ return { counts: emptyCounts(), data };
54
+ }
55
+ if (!data?.metadata?.vulnerabilities) throw new Error('missing metadata.vulnerabilities');
56
+ return { counts: normalizedCounts(data.metadata.vulnerabilities), data };
57
+ }
58
+ if (manager === 'yarn' || manager === 'bun') {
59
+ if (data?.error && typeof data.error === 'object') {
60
+ return { counts: emptyCounts(), data };
61
+ }
62
+ return { counts: countsFromAdvisories(data), data };
63
+ }
64
+ throw new Error(`unsupported package manager: ${manager}`);
65
+ }
66
+
67
+ export function auditCommand(manager) {
68
+ const commands = {
69
+ npm: ['npm', 'audit', '--omit=dev', '--json'],
70
+ pnpm: ['pnpm', 'audit', '--prod', '--json'],
71
+ yarn: ['yarn', 'npm', 'audit', '--environment', 'production', '--recursive', '--json'],
72
+ bun: ['bun', 'audit', '--prod', '--json'],
73
+ };
74
+ if (!commands[manager]) throw new Error(`unsupported package manager: ${manager}`);
75
+ return commands[manager];
76
+ }
77
+
78
+ export function evaluateAudit({
79
+ manager,
80
+ input,
81
+ serviceOutage = 'warn',
82
+ blockingSeverities = ['high', 'critical'],
83
+ }) {
84
+ let parsed;
85
+ try {
86
+ parsed = parseAudit(manager, input);
87
+ } catch (error) {
88
+ return { status: 'malformed', exitCode: 2, message: error.message };
89
+ }
90
+
91
+ if (parsed.data?.error && typeof parsed.data.error === 'object') {
92
+ return serviceOutage === 'block'
93
+ ? { status: 'service-outage-blocked', exitCode: 3 }
94
+ : { status: 'service-outage-warning', exitCode: 0 };
95
+ }
96
+
97
+ const isBlocking = blockingSeverities.some((severity) => parsed.counts[severity] > 0);
98
+ return {
99
+ status: isBlocking ? 'blocking-findings' : 'passed',
100
+ exitCode: isBlocking ? 1 : 0,
101
+ counts: parsed.counts,
102
+ };
103
+ }
104
+
105
+ async function main() {
106
+ const manager = process.argv[2];
107
+ const serviceOutageArg = process.argv.find((arg) => arg.startsWith('--service-outage='));
108
+ const serviceOutage = serviceOutageArg?.split('=', 2)[1] ?? 'warn';
109
+ let input = '';
110
+ for await (const chunk of process.stdin) input += chunk;
111
+ const verdict = evaluateAudit({ manager, input, serviceOutage });
112
+ const output = JSON.stringify(verdict);
113
+ (verdict.exitCode === 0 ? console.log : console.error)(output);
114
+ process.exitCode = verdict.exitCode;
115
+ }
116
+
117
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
118
+ main().catch((error) => {
119
+ console.error(JSON.stringify({ status: 'malformed', message: error.message }));
120
+ process.exitCode = 2;
121
+ });
122
+ }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import {
5
+ chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm, unlink, writeFile,
6
+ } from 'node:fs/promises';
7
+ import { homedir, tmpdir } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+ import { promisify } from 'node:util';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const exec = promisify(execFile);
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ export const DEFAULT_GITLEAKS_PROFILE = JSON.parse(
15
+ await readFile(join(HERE, 'gitleaks-profile.json'), 'utf8'),
16
+ );
17
+
18
+ async function defaultIsAvailable() {
19
+ try {
20
+ await exec('gitleaks', ['version']);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ async function defaultFetchArchive(url) {
28
+ const response = await fetch(url);
29
+ if (!response.ok) throw new Error(`download returned HTTP ${response.status}`);
30
+ return Buffer.from(await response.arrayBuffer());
31
+ }
32
+
33
+ async function defaultInstallArchive({ bytes, destination }) {
34
+ const scratch = await mkdtemp(join(tmpdir(), 'awkit-gitleaks-'));
35
+ const archive = join(scratch, 'gitleaks.tar.gz');
36
+ const extracted = join(scratch, 'gitleaks');
37
+ const staged = `${destination}.tmp-${process.pid}`;
38
+ try {
39
+ await writeFile(archive, bytes);
40
+ await exec('tar', ['-xzf', archive, '-C', scratch, 'gitleaks']);
41
+ await mkdir(dirname(destination), { recursive: true });
42
+ await copyFile(extracted, staged);
43
+ await chmod(staged, 0o755);
44
+ await rename(staged, destination);
45
+ } finally {
46
+ await unlink(staged).catch(() => {});
47
+ await rm(scratch, { recursive: true, force: true });
48
+ }
49
+ }
50
+
51
+ export async function ensureGitleaks({
52
+ profile = DEFAULT_GITLEAKS_PROFILE,
53
+ platform = process.platform,
54
+ arch = process.arch,
55
+ destination = join(homedir(), '.local', 'bin', 'gitleaks'),
56
+ isAvailable = defaultIsAvailable,
57
+ fetchArchive = defaultFetchArchive,
58
+ installArchive = defaultInstallArchive,
59
+ } = {}) {
60
+ if (await isAvailable()) return { status: 'already-available' };
61
+
62
+ const target = profile.platforms?.[`${platform}-${arch}`];
63
+ if (!target?.asset || !/^[a-f0-9]{64}$/i.test(target.sha256 ?? '')) {
64
+ return { status: 'unsupported-platform', platform, arch };
65
+ }
66
+
67
+ const url = `${profile.baseUrl ?? ''}/${target.asset}`;
68
+ let bytes;
69
+ try {
70
+ bytes = await fetchArchive(url);
71
+ } catch (error) {
72
+ return { status: 'offline', message: error.message };
73
+ }
74
+
75
+ const actual = createHash('sha256').update(bytes).digest('hex');
76
+ if (actual !== target.sha256.toLowerCase()) {
77
+ return { status: 'checksum-mismatch', expected: target.sha256, actual };
78
+ }
79
+
80
+ try {
81
+ await installArchive({ bytes, destination, asset: target.asset });
82
+ } catch (error) {
83
+ return { status: 'unwritable', message: error.message };
84
+ }
85
+ return { status: 'installed', destination, version: profile.version };
86
+ }
87
+
88
+ async function main() {
89
+ const result = await ensureGitleaks({ destination: process.argv[2] });
90
+ if (result.status === 'installed' || result.status === 'already-available') {
91
+ console.log(`Gitleaks provisioning: ${result.status}.`);
92
+ } else {
93
+ console.error(`Gitleaks provisioning skipped safely: ${result.status}.`);
94
+ }
95
+ }
96
+
97
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
98
+ main().catch((error) => {
99
+ console.error(`Gitleaks provisioning failed safely: ${error.message}`);
100
+ });
101
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": "8.30.1",
3
+ "baseUrl": "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1",
4
+ "platforms": {
5
+ "linux-x64": {
6
+ "asset": "gitleaks_8.30.1_linux_x64.tar.gz",
7
+ "sha256": "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"
8
+ },
9
+ "linux-arm64": {
10
+ "asset": "gitleaks_8.30.1_linux_arm64.tar.gz",
11
+ "sha256": "e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080"
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const exec = promisify(execFile);
7
+
8
+ async function defaultRunGit(cwd, args) {
9
+ const result = await exec('git', args, { cwd });
10
+ return result.stdout.trim();
11
+ }
12
+
13
+ export async function installGitHooks({
14
+ cwd = process.cwd(),
15
+ hooksPath = '.githooks',
16
+ runGit = defaultRunGit,
17
+ } = {}) {
18
+ try {
19
+ await runGit(cwd, ['rev-parse', '--is-inside-work-tree']);
20
+ } catch (error) {
21
+ if (error?.code === 128) return { status: 'not-a-repository' };
22
+ throw error;
23
+ }
24
+
25
+ let current = '';
26
+ try {
27
+ current = await runGit(cwd, ['config', '--get', 'core.hooksPath']);
28
+ } catch (error) {
29
+ if (error?.code !== 1) throw error;
30
+ }
31
+ if (current === hooksPath) return { status: 'unchanged', hooksPath };
32
+
33
+ await runGit(cwd, ['config', 'core.hooksPath', hooksPath]);
34
+ return { status: 'wired', hooksPath };
35
+ }
36
+
37
+ async function main() {
38
+ const hooksPath = process.argv[2] ?? '.githooks';
39
+ const result = await installGitHooks({ hooksPath });
40
+ if (result.status === 'not-a-repository') {
41
+ console.log('Git hooks not wired: current directory is not a Git work tree.');
42
+ return;
43
+ }
44
+ console.log(`Git hooks ${result.status}: core.hooksPath=${result.hooksPath}`);
45
+ }
46
+
47
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
48
+ main().catch((error) => {
49
+ console.error(`Git hook wiring failed: ${error.message}`);
50
+ process.exitCode = 1;
51
+ });
52
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "version": 1,
3
+ "capabilities": [
4
+ {
5
+ "profileKey": "largeRead",
6
+ "artifact": ".claude/hooks/recon-size-hint.py"
7
+ },
8
+ {
9
+ "profileKey": "baseline",
10
+ "artifact": ".claude/hooks/baseline-capture-hint.py"
11
+ },
12
+ {
13
+ "profileKey": "preRefactor",
14
+ "artifact": ".claude/hooks/pre-refactor-sweep.py"
15
+ },
16
+ {
17
+ "profileKey": "stopChecks",
18
+ "artifact": ".claude/hooks/typecheck-on-stop.sh"
19
+ },
20
+ {
21
+ "profileKey": "freshness",
22
+ "artifact": ".claude/hooks/convention-drift-hint.py"
23
+ },
24
+ {
25
+ "profileKey": "migration",
26
+ "artifact": ".claude/hooks/migration-snapshot-reminder.py"
27
+ },
28
+ {
29
+ "profileKey": "locForewarn",
30
+ "artifact": ".claude/hooks/loc-offender-forewarn.py"
31
+ }
32
+ ]
33
+ }