@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.
Files changed (32) hide show
  1. package/.agents/skills/setup-workflow/SKILL.md +26 -6
  2. package/.agents/skills/setup-workflow/workflow-advisories.md +97 -0
  3. package/.claude/hooks/_hook_utils.py +35 -0
  4. package/.claude/hooks/_safety_guard.py +24 -0
  5. package/.claude/hooks/baseline-capture-hint.py +25 -0
  6. package/.claude/hooks/block-bg-double-background.py +30 -0
  7. package/.claude/hooks/block-npm-install-in-pnpm.py +30 -0
  8. package/.claude/hooks/block-secrets.py +30 -0
  9. package/.claude/hooks/convention-drift-hint.py +27 -0
  10. package/.claude/hooks/grep-shim-guard.py +30 -0
  11. package/.claude/hooks/loc-offender-forewarn.py +56 -0
  12. package/.claude/hooks/migration-snapshot-reminder.py +24 -0
  13. package/.claude/hooks/pre-refactor-sweep.py +27 -0
  14. package/.claude/hooks/recon-size-hint.py +24 -0
  15. package/.claude/hooks/skill-drift-hint.py +9 -45
  16. package/.claude/hooks/typecheck-on-stop.py +27 -0
  17. package/.claude/hooks/typecheck-on-stop.sh +2 -0
  18. package/.claude/skills/setup-workflow/SKILL.md +26 -6
  19. package/.claude/skills/setup-workflow/workflow-advisories.md +97 -0
  20. package/README.md +27 -0
  21. package/agent-workflow-kit.package.json +94 -6
  22. package/package.json +1 -1
  23. package/scripts/loc_offender_gate.py +24 -0
  24. package/scripts/safety-guardrails/core.py +221 -0
  25. package/scripts/safety-guardrails/search.py +92 -0
  26. package/scripts/security/audit-gate.mjs +122 -0
  27. package/scripts/security/ensure-gitleaks.mjs +101 -0
  28. package/scripts/security/gitleaks-profile.json +14 -0
  29. package/scripts/security/install-git-hooks.mjs +52 -0
  30. package/scripts/workflow-advisories/capabilities.json +33 -0
  31. package/scripts/workflow-advisories/core.py +285 -0
  32. 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
+ )
@@ -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