@ikon85/agent-workflow-kit 0.23.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.
- package/.agents/skills/setup-workflow/SKILL.md +26 -6
- package/.agents/skills/setup-workflow/workflow-advisories.md +97 -0
- package/.claude/hooks/_hook_utils.py +35 -0
- package/.claude/hooks/_safety_guard.py +24 -0
- package/.claude/hooks/baseline-capture-hint.py +25 -0
- package/.claude/hooks/block-bg-double-background.py +30 -0
- package/.claude/hooks/block-npm-install-in-pnpm.py +30 -0
- package/.claude/hooks/block-secrets.py +30 -0
- package/.claude/hooks/convention-drift-hint.py +27 -0
- package/.claude/hooks/grep-shim-guard.py +30 -0
- package/.claude/hooks/loc-offender-forewarn.py +56 -0
- package/.claude/hooks/migration-snapshot-reminder.py +24 -0
- package/.claude/hooks/pre-refactor-sweep.py +27 -0
- package/.claude/hooks/recon-size-hint.py +24 -0
- package/.claude/hooks/skill-drift-hint.py +9 -45
- package/.claude/hooks/typecheck-on-stop.py +27 -0
- package/.claude/hooks/typecheck-on-stop.sh +2 -0
- package/.claude/skills/setup-workflow/SKILL.md +26 -6
- package/.claude/skills/setup-workflow/workflow-advisories.md +97 -0
- package/README.md +27 -0
- package/agent-workflow-kit.package.json +94 -6
- package/package.json +1 -1
- package/scripts/loc_offender_gate.py +24 -0
- package/scripts/safety-guardrails/core.py +221 -0
- package/scripts/safety-guardrails/search.py +92 -0
- package/scripts/security/audit-gate.mjs +122 -0
- package/scripts/security/ensure-gitleaks.mjs +101 -0
- package/scripts/security/gitleaks-profile.json +14 -0
- package/scripts/security/install-git-hooks.mjs +52 -0
- package/scripts/workflow-advisories/capabilities.json +33 -0
- package/scripts/workflow-advisories/core.py +285 -0
- package/src/lib/bundle.mjs +12 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Profile-driven decision core for non-blocking Workflow Advisories."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fnmatch
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Decision:
|
|
14
|
+
context: str | None
|
|
15
|
+
event_name: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_profile(path: Path) -> dict:
|
|
19
|
+
body = json.loads(path.read_text(encoding="utf-8"))
|
|
20
|
+
section = body.get("workflowAdvisories", {})
|
|
21
|
+
return section if section.get("enabled") is True else {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _line_count(path: Path) -> int:
|
|
25
|
+
count = 0
|
|
26
|
+
last = b""
|
|
27
|
+
with path.open("rb") as handle:
|
|
28
|
+
while chunk := handle.read(64 * 1024):
|
|
29
|
+
count += chunk.count(b"\n")
|
|
30
|
+
last = chunk[-1:]
|
|
31
|
+
return count if not last or last == b"\n" else count + 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _bounded(message: str, budget: int) -> str:
|
|
35
|
+
if budget <= 0 or len(message) <= budget:
|
|
36
|
+
return message
|
|
37
|
+
return message[: max(0, budget - 1)] + "…"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def large_read_decision(profile: dict, payload: dict) -> Decision:
|
|
41
|
+
config = profile.get("largeRead", {})
|
|
42
|
+
if payload.get("tool_name") not in config.get("tools", []):
|
|
43
|
+
return Decision(None, "PreToolUse")
|
|
44
|
+
raw_path = payload.get("tool_input", {}).get("file_path")
|
|
45
|
+
if not raw_path:
|
|
46
|
+
return Decision(None, "PreToolUse")
|
|
47
|
+
path = Path(raw_path)
|
|
48
|
+
try:
|
|
49
|
+
lines = _line_count(path)
|
|
50
|
+
except (OSError, ValueError):
|
|
51
|
+
return Decision(None, "PreToolUse")
|
|
52
|
+
threshold = int(config.get("lineThreshold", 0))
|
|
53
|
+
if lines < threshold:
|
|
54
|
+
return Decision(None, "PreToolUse")
|
|
55
|
+
message = (
|
|
56
|
+
f"Large read advisory: {path.name} has {lines} lines "
|
|
57
|
+
f"(profile threshold {threshold}). Prefer a bounded read or delegated recon."
|
|
58
|
+
)
|
|
59
|
+
return Decision(_bounded(message, int(config.get("outputBudget", 500))), "PreToolUse")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _repo_relative(root: Path, raw_path: str) -> str | None:
|
|
63
|
+
try:
|
|
64
|
+
return str(Path(raw_path).resolve().relative_to(root.resolve()))
|
|
65
|
+
except (OSError, ValueError):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _valid_baseline(path: Path, branch: str) -> bool:
|
|
70
|
+
try:
|
|
71
|
+
body = json.loads(path.read_text(encoding="utf-8"))
|
|
72
|
+
except (OSError, ValueError, TypeError):
|
|
73
|
+
return False
|
|
74
|
+
return (
|
|
75
|
+
body.get("branch") == branch
|
|
76
|
+
and bool(body.get("capturedAt"))
|
|
77
|
+
and bool(body.get("sources"))
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def baseline_decision(profile: dict, payload: dict, root: Path, branch: str) -> Decision:
|
|
82
|
+
config = profile.get("baseline", {})
|
|
83
|
+
if payload.get("tool_name") not in {"Edit", "Write", "MultiEdit"}:
|
|
84
|
+
return Decision(None, "PreToolUse")
|
|
85
|
+
if not re.search(config.get("branchRegex", r"$^"), branch):
|
|
86
|
+
return Decision(None, "PreToolUse")
|
|
87
|
+
raw_path = payload.get("tool_input", {}).get("file_path")
|
|
88
|
+
relative = _repo_relative(root, raw_path) if raw_path else None
|
|
89
|
+
if not relative or not any(
|
|
90
|
+
fnmatch.fnmatch(relative, pattern) for pattern in config.get("sourceGlobs", [])
|
|
91
|
+
):
|
|
92
|
+
return Decision(None, "PreToolUse")
|
|
93
|
+
manifest = root / config.get("manifestPath", ".agent/baseline.json")
|
|
94
|
+
if _valid_baseline(manifest, branch):
|
|
95
|
+
return Decision(None, "PreToolUse")
|
|
96
|
+
state_dir = root / config.get("stateDir", ".claude/logs/advisory-state")
|
|
97
|
+
marker = state_dir / f"{re.sub(r'[^A-Za-z0-9._-]', '-', branch)}.hinted"
|
|
98
|
+
if marker.exists():
|
|
99
|
+
return Decision(None, "PreToolUse")
|
|
100
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
marker.write_text(f"{branch}\n", encoding="utf-8")
|
|
102
|
+
message = (
|
|
103
|
+
f"Baseline advisory for {branch}: capture a valid baseline before the first "
|
|
104
|
+
f"impacting edit to {relative}. Empty or stale manifests do not count."
|
|
105
|
+
)
|
|
106
|
+
return Decision(_bounded(message, int(config.get("outputBudget", 500))), "PreToolUse")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def pre_refactor_decision(profile: dict, payload: dict, root: Path) -> Decision:
|
|
110
|
+
config = profile.get("preRefactor", {})
|
|
111
|
+
prompt = payload.get("prompt", "")
|
|
112
|
+
if not any(
|
|
113
|
+
re.search(pattern, prompt, re.IGNORECASE)
|
|
114
|
+
for pattern in config.get("promptMatchers", [])
|
|
115
|
+
):
|
|
116
|
+
return Decision(None, "UserPromptSubmit")
|
|
117
|
+
changed = payload.get("changed_files", [])
|
|
118
|
+
commands: list[list[str]] = []
|
|
119
|
+
seen: set[tuple[str, ...]] = set()
|
|
120
|
+
for surface in config.get("surfaces", []):
|
|
121
|
+
if not any(
|
|
122
|
+
fnmatch.fnmatch(path, pattern)
|
|
123
|
+
for path in changed
|
|
124
|
+
for pattern in surface.get("globs", [])
|
|
125
|
+
):
|
|
126
|
+
continue
|
|
127
|
+
for command in surface.get("commands", []):
|
|
128
|
+
key = tuple(command)
|
|
129
|
+
if key not in seen:
|
|
130
|
+
commands.append(command)
|
|
131
|
+
seen.add(key)
|
|
132
|
+
return _command_decision(
|
|
133
|
+
commands, config, root, "Pre-refactor sweep:", "UserPromptSubmit",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _command_decision(
|
|
138
|
+
commands: list[list[str]], config: dict, root: Path, heading: str, event_name: str,
|
|
139
|
+
) -> Decision:
|
|
140
|
+
if not commands:
|
|
141
|
+
return Decision(None, event_name)
|
|
142
|
+
timeout = float(config.get("timeoutSeconds", 15))
|
|
143
|
+
lines = [heading]
|
|
144
|
+
for command in commands:
|
|
145
|
+
try:
|
|
146
|
+
result = subprocess.run(
|
|
147
|
+
command, cwd=root, capture_output=True, text=True, timeout=timeout,
|
|
148
|
+
)
|
|
149
|
+
detail = (result.stdout + result.stderr).strip()
|
|
150
|
+
verdict = "PASS" if result.returncode == 0 else f"FAIL (exit {result.returncode})"
|
|
151
|
+
except subprocess.TimeoutExpired as error:
|
|
152
|
+
detail = ((error.stdout or "") + (error.stderr or "")).strip()
|
|
153
|
+
verdict = f"FAIL (timeout {timeout:g}s)"
|
|
154
|
+
except OSError as error:
|
|
155
|
+
detail = str(error)
|
|
156
|
+
verdict = "FAIL (exec)"
|
|
157
|
+
lines.append(f"- {verdict}: {' '.join(command)}")
|
|
158
|
+
if detail:
|
|
159
|
+
lines.append(f" {detail}")
|
|
160
|
+
return Decision(
|
|
161
|
+
_bounded("\n".join(lines), int(config.get("outputBudget", 1000))),
|
|
162
|
+
event_name,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def stop_check_decision(profile: dict, payload: dict, root: Path) -> Decision:
|
|
167
|
+
config = profile.get("stopChecks", {})
|
|
168
|
+
changed = payload.get("changed_files", [])
|
|
169
|
+
commands: list[list[str]] = []
|
|
170
|
+
seen: set[tuple[str, ...]] = set()
|
|
171
|
+
for surface in config.get("surfaces", []):
|
|
172
|
+
if not any(
|
|
173
|
+
fnmatch.fnmatch(path, pattern)
|
|
174
|
+
for path in changed
|
|
175
|
+
for pattern in surface.get("globs", [])
|
|
176
|
+
):
|
|
177
|
+
continue
|
|
178
|
+
command = surface.get("command", [])
|
|
179
|
+
key = tuple(command)
|
|
180
|
+
if command and key not in seen:
|
|
181
|
+
commands.append(command)
|
|
182
|
+
seen.add(key)
|
|
183
|
+
return _command_decision(
|
|
184
|
+
commands, config, root, "Changed-surface stop checks:", "Stop",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def git_commit_time(root: Path, relative_path: str) -> int | None:
|
|
189
|
+
try:
|
|
190
|
+
result = subprocess.run(
|
|
191
|
+
["git", "-C", str(root), "log", "-1", "--format=%ct", "--", relative_path],
|
|
192
|
+
capture_output=True, text=True, timeout=5,
|
|
193
|
+
)
|
|
194
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
195
|
+
return None
|
|
196
|
+
value = result.stdout.strip()
|
|
197
|
+
return int(value) if result.returncode == 0 and value.isdigit() else None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def read_source_list(path: Path) -> list[str]:
|
|
201
|
+
try:
|
|
202
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
203
|
+
except OSError:
|
|
204
|
+
return []
|
|
205
|
+
return [
|
|
206
|
+
line.strip() for line in lines
|
|
207
|
+
if line.strip() and not line.strip().startswith("#")
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def collect_stale_maps(
|
|
212
|
+
root: Path, maps: list[tuple[str, list[str]]],
|
|
213
|
+
) -> list[tuple[str, list[str]]]:
|
|
214
|
+
stale_maps: list[tuple[str, list[str]]] = []
|
|
215
|
+
for document, sources in maps:
|
|
216
|
+
document_time = git_commit_time(root, document)
|
|
217
|
+
if document_time is None:
|
|
218
|
+
continue
|
|
219
|
+
stale = [
|
|
220
|
+
source for source in sources
|
|
221
|
+
if (source_time := git_commit_time(root, source)) is not None
|
|
222
|
+
and source_time > document_time
|
|
223
|
+
]
|
|
224
|
+
if stale:
|
|
225
|
+
stale_maps.append((document, stale))
|
|
226
|
+
return stale_maps
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def collect_skill_stale(root: Path, skills_relative: str) -> list[tuple[str, list[str]]]:
|
|
230
|
+
skills_dir = root / skills_relative
|
|
231
|
+
maps = [
|
|
232
|
+
(
|
|
233
|
+
f"{skills_relative}/{sources_file.parent.name}/SKILL.md",
|
|
234
|
+
read_source_list(sources_file),
|
|
235
|
+
)
|
|
236
|
+
for sources_file in sorted(skills_dir.glob("*/SOURCES.txt"))
|
|
237
|
+
] if skills_dir.is_dir() else []
|
|
238
|
+
return [
|
|
239
|
+
(Path(document).parent.name, sources)
|
|
240
|
+
for document, sources in collect_stale_maps(root, maps)
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def convention_freshness_decision(profile: dict, root: Path) -> Decision:
|
|
245
|
+
config = profile.get("freshness", {})
|
|
246
|
+
maps = [
|
|
247
|
+
(entry.get("document", ""), entry.get("sources", []))
|
|
248
|
+
for entry in config.get("documents", [])
|
|
249
|
+
if entry.get("document")
|
|
250
|
+
]
|
|
251
|
+
stale = collect_stale_maps(root, maps)
|
|
252
|
+
if not stale:
|
|
253
|
+
return Decision(None, "SessionStart")
|
|
254
|
+
lines = ["Convention freshness advisory:"]
|
|
255
|
+
for document, sources in stale:
|
|
256
|
+
lines.append(f"- {document} is older than:")
|
|
257
|
+
lines.extend(f" - {source}" for source in sources)
|
|
258
|
+
return Decision(
|
|
259
|
+
_bounded("\n".join(lines), int(config.get("outputBudget", 1000))),
|
|
260
|
+
"SessionStart",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def migration_reminder_decision(profile: dict, payload: dict) -> Decision:
|
|
265
|
+
config = profile.get("migration", {})
|
|
266
|
+
if payload.get("tool_name") != "Bash":
|
|
267
|
+
return Decision(None, "PostToolUse")
|
|
268
|
+
command = payload.get("tool_input", {}).get("command", "")
|
|
269
|
+
if not any(
|
|
270
|
+
re.search(pattern, command)
|
|
271
|
+
for pattern in config.get("commandMatchers", [])
|
|
272
|
+
):
|
|
273
|
+
return Decision(None, "PostToolUse")
|
|
274
|
+
artifact = config.get("artifact")
|
|
275
|
+
refresh = config.get("refreshCommand", [])
|
|
276
|
+
if not artifact or not refresh:
|
|
277
|
+
return Decision(None, "PostToolUse")
|
|
278
|
+
message = (
|
|
279
|
+
f"Migration advisory: refresh {artifact} with `{' '.join(refresh)}` "
|
|
280
|
+
"before treating the migration result as complete."
|
|
281
|
+
)
|
|
282
|
+
return Decision(
|
|
283
|
+
_bounded(message, int(config.get("outputBudget", 500))),
|
|
284
|
+
"PostToolUse",
|
|
285
|
+
)
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -91,6 +91,18 @@ export const HELPER_FILES = [
|
|
|
91
91
|
{ path: '.claude/hooks/enforce-worktree-cwd.py', kind: 'hook', mode: 0o755 },
|
|
92
92
|
{ path: '.claude/hooks/enforce-worktree-discipline.py', kind: 'hook', mode: 0o755 },
|
|
93
93
|
{ path: '.claude/hooks/slice-handoff-hint.py', kind: 'hook', mode: 0o755 },
|
|
94
|
+
// Profile-driven non-blocking change-lifecycle advisories. The shell Stop
|
|
95
|
+
// entry delegates to its sibling Python adapter; decisions stay in core.py.
|
|
96
|
+
{ path: 'scripts/workflow-advisories/core.py', kind: 'script', mode: 0o644 },
|
|
97
|
+
{ path: 'scripts/workflow-advisories/capabilities.json', kind: 'doc', mode: 0o644 },
|
|
98
|
+
{ path: '.claude/hooks/recon-size-hint.py', kind: 'hook', mode: 0o755 },
|
|
99
|
+
{ path: '.claude/hooks/baseline-capture-hint.py', kind: 'hook', mode: 0o755 },
|
|
100
|
+
{ path: '.claude/hooks/pre-refactor-sweep.py', kind: 'hook', mode: 0o755 },
|
|
101
|
+
{ path: '.claude/hooks/typecheck-on-stop.py', kind: 'hook', mode: 0o755 },
|
|
102
|
+
{ path: '.claude/hooks/typecheck-on-stop.sh', kind: 'hook', mode: 0o755 },
|
|
103
|
+
{ path: '.claude/hooks/convention-drift-hint.py', kind: 'hook', mode: 0o755 },
|
|
104
|
+
{ path: '.claude/hooks/migration-snapshot-reminder.py', kind: 'hook', mode: 0o755 },
|
|
105
|
+
{ path: '.claude/hooks/loc-offender-forewarn.py', kind: 'hook', mode: 0o755 },
|
|
94
106
|
{ path: '.claude/hooks/drift-guard.py', kind: 'hook', mode: 0o755 },
|
|
95
107
|
// SessionStart skill-freshness drift-hint (audit-skills names it). For each
|
|
96
108
|
// <skill>/SOURCES.txt it flags sources newer in git than the SKILL.md. Imports
|