claude-company 0.1.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/.claude/agents/architect.md +69 -0
- package/.claude/agents/auditor.md +44 -0
- package/.claude/agents/developer.md +86 -0
- package/.claude/agents/devops-engineer.md +38 -0
- package/.claude/agents/docs-librarian.md +36 -0
- package/.claude/agents/ideation-strategist.md +54 -0
- package/.claude/agents/product-manager.md +54 -0
- package/.claude/agents/qa-engineer.md +58 -0
- package/.claude/agents/security-reviewer.md +44 -0
- package/.claude/agents/tech-lead.md +80 -0
- package/.claude/hooks/_common.py +209 -0
- package/.claude/hooks/gate_stamp.py +77 -0
- package/.claude/hooks/gates_detect.py +387 -0
- package/.claude/hooks/guard_commit.py +123 -0
- package/.claude/hooks/guard_frozen.py +135 -0
- package/.claude/hooks/guard_spec.py +95 -0
- package/.claude/hooks/guard_tests.py +124 -0
- package/.claude/hooks/no_slop.py +134 -0
- package/.claude/hooks/session_start.py +63 -0
- package/.claude/hooks/stop_gate.py +59 -0
- package/.claude/settings.json +70 -0
- package/.claude/skills/autopilot/SKILL.md +65 -0
- package/.claude/skills/brainstorm/SKILL.md +61 -0
- package/.claude/skills/company-init/SKILL.md +51 -0
- package/.claude/skills/cr/SKILL.md +44 -0
- package/.claude/skills/feature/SKILL.md +42 -0
- package/.claude/skills/gates/SKILL.md +33 -0
- package/.claude/skills/onboard/SKILL.md +52 -0
- package/.claude/skills/orchestrator/SKILL.md +84 -0
- package/.claude/skills/standup/SKILL.md +38 -0
- package/.mcp.json +1 -0
- package/LICENSE +21 -0
- package/ORCHESTRATOR.md +191 -0
- package/README.md +236 -0
- package/bin/claude-company.js +112 -0
- package/company/EXTENDING.md +58 -0
- package/company/GATES.md +59 -0
- package/company/GIT.md +119 -0
- package/company/IDEATION.md +102 -0
- package/company/LOOPS.md +108 -0
- package/company/METHOD.md +151 -0
- package/company/frozen-surfaces.json +17 -0
- package/company/gates.config +5 -0
- package/company/run-gates.sh +158 -0
- package/company/state/DECISIONS.md +9 -0
- package/company/state/RESUME.md +28 -0
- package/company/state/STATUS.md +30 -0
- package/company/state/WORRIES.md +12 -0
- package/company/state/adherence.log +1 -0
- package/company/templates/BRIEF-TEMPLATE.md +69 -0
- package/company/templates/CR-TEMPLATE.md +28 -0
- package/company/templates/MODULE-TEMPLATE.md +23 -0
- package/company/templates/OPTIONS-TEMPLATE.md +42 -0
- package/company/templates/REPORT-TEMPLATE.md +35 -0
- package/company/templates/SPEC-TEMPLATE.md +69 -0
- package/docs/customizing.md +88 -0
- package/docs/getting-started.md +99 -0
- package/docs/how-it-works.md +153 -0
- package/install +4 -0
- package/install.sh +331 -0
- package/lib/install-tui.js +1600 -0
- package/package.json +47 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse (Bash): gate git commit / merge / push commands.
|
|
3
|
+
|
|
4
|
+
- push to protected branch (main/master), explicit or bare push while on it:
|
|
5
|
+
BLOCK (owner-only).
|
|
6
|
+
- commit / merge: require a green, fresh, valid gates.status stamp. If
|
|
7
|
+
gates.config is missing or has zero gates, ALLOW + log BYPASS. If the
|
|
8
|
+
active task is a hotfix, ALLOW + log BYPASS.
|
|
9
|
+
- everything else: allow.
|
|
10
|
+
|
|
11
|
+
Fails open on any internal error.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
import _common as c # noqa: E402
|
|
21
|
+
|
|
22
|
+
HOOK = "guard_commit"
|
|
23
|
+
PROTECTED = {"main", "master"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def segments(command):
|
|
27
|
+
parts = re.split(r"&&|\|\||;|\|", command)
|
|
28
|
+
return [p.strip() for p in parts if p.strip()]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def git_subcmd(segment):
|
|
32
|
+
"""Return (subcommand, args) for a `git ...` segment, else (None, [])."""
|
|
33
|
+
try:
|
|
34
|
+
toks = shlex.split(segment)
|
|
35
|
+
except Exception:
|
|
36
|
+
toks = segment.split()
|
|
37
|
+
if not toks or toks[0] != "git":
|
|
38
|
+
return None, []
|
|
39
|
+
i = 1
|
|
40
|
+
while i < len(toks) and toks[i].startswith("-"):
|
|
41
|
+
i += 1
|
|
42
|
+
if i >= len(toks):
|
|
43
|
+
return None, []
|
|
44
|
+
return toks[i], toks[i + 1:]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def push_targets_protected(root, args):
|
|
48
|
+
non_opt = [a for a in args if not a.startswith("-")]
|
|
49
|
+
# first non-option is the remote; the rest are refspecs
|
|
50
|
+
refspecs = non_opt[1:]
|
|
51
|
+
for ref in refspecs:
|
|
52
|
+
dst = ref.split(":")[-1]
|
|
53
|
+
if dst in PROTECTED:
|
|
54
|
+
return True
|
|
55
|
+
if not refspecs:
|
|
56
|
+
cur = c.current_branch(root)
|
|
57
|
+
if cur in PROTECTED:
|
|
58
|
+
return True
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main():
|
|
63
|
+
payload = c.read_stdin_json()
|
|
64
|
+
if payload is None:
|
|
65
|
+
sys.exit(0)
|
|
66
|
+
if payload.get("tool_name") != "Bash":
|
|
67
|
+
sys.exit(0)
|
|
68
|
+
|
|
69
|
+
root = c.project_root(payload)
|
|
70
|
+
command = (payload.get("tool_input") or {}).get("command") or ""
|
|
71
|
+
if not command:
|
|
72
|
+
sys.exit(0)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
for seg in segments(command):
|
|
76
|
+
sub, args = git_subcmd(seg)
|
|
77
|
+
if sub is None:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
if sub == "push":
|
|
81
|
+
if push_targets_protected(root, args):
|
|
82
|
+
c.block(
|
|
83
|
+
root, HOOK, "git push", "protected branch push",
|
|
84
|
+
"BLOCKED: push to a protected branch (main/master) is "
|
|
85
|
+
"owner-only. Open a PR or ask the owner to push.",
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if sub in ("commit", "merge"):
|
|
90
|
+
task = c.active_task(root)
|
|
91
|
+
if isinstance(task, dict) and task.get("type") == "hotfix":
|
|
92
|
+
c.log_bypass(root, HOOK, "git " + sub, "hotfix mode")
|
|
93
|
+
continue
|
|
94
|
+
cfg = c.gates_config(root)
|
|
95
|
+
if not isinstance(cfg, dict) or not cfg.get("gates"):
|
|
96
|
+
c.log_bypass(
|
|
97
|
+
root, HOOK, "git " + sub, "no gates configured"
|
|
98
|
+
)
|
|
99
|
+
continue
|
|
100
|
+
ok, reason = c.check_stamp(root)
|
|
101
|
+
if not ok:
|
|
102
|
+
c.block(
|
|
103
|
+
root, HOOK, "git " + sub, reason,
|
|
104
|
+
"BLOCKED: git {} requires green, fresh gates. {}.\n"
|
|
105
|
+
"Fix: run `bash company/run-gates.sh` until green, "
|
|
106
|
+
"then retry.\n"
|
|
107
|
+
"If company/gates.config still has only CONFIGURE-ME "
|
|
108
|
+
"placeholders, run `python3 "
|
|
109
|
+
".claude/hooks/gates_detect.py --write` first to "
|
|
110
|
+
"auto-configure real gates, then rerun the "
|
|
111
|
+
"suite.".format(sub, reason),
|
|
112
|
+
)
|
|
113
|
+
continue
|
|
114
|
+
except SystemExit:
|
|
115
|
+
raise
|
|
116
|
+
except Exception:
|
|
117
|
+
sys.exit(0)
|
|
118
|
+
|
|
119
|
+
sys.exit(0)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
main()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse (Edit|Write|MultiEdit): block edits to frozen surfaces.
|
|
3
|
+
|
|
4
|
+
Frozen surfaces are declared in company/frozen-surfaces.json. When that file is
|
|
5
|
+
missing only the hardcoded `always` defaults apply. Also blocks edits to
|
|
6
|
+
git-TRACKED files under any migrations/ or alembic/versions/ directory (a new,
|
|
7
|
+
untracked migration stays editable; on git uncertainty we treat the file as
|
|
8
|
+
tracked and block). Fails open on any internal error.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import fnmatch
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
import _common as c # noqa: E402
|
|
17
|
+
|
|
18
|
+
HOOK = "guard_frozen"
|
|
19
|
+
|
|
20
|
+
ALWAYS_DEFAULTS = [
|
|
21
|
+
".env",
|
|
22
|
+
".env.*",
|
|
23
|
+
"*.lock",
|
|
24
|
+
"package-lock.json",
|
|
25
|
+
"yarn.lock",
|
|
26
|
+
"pnpm-lock.yaml",
|
|
27
|
+
"poetry.lock",
|
|
28
|
+
"Cargo.lock",
|
|
29
|
+
"company/state/gates.status",
|
|
30
|
+
"company/state/adherence.log",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
ENV_ALLOW_SUFFIXES = (".example", ".sample", ".template")
|
|
34
|
+
|
|
35
|
+
CR_NOTE = (
|
|
36
|
+
"Frozen surfaces change only through the change-request protocol "
|
|
37
|
+
"(company/change-requests/), never a local edit. If this change is "
|
|
38
|
+
"genuinely needed, file a CR: copy company/templates/CR-TEMPLATE.md to "
|
|
39
|
+
"company/change-requests/CR-<n>-<slug>.md and surface it in your report. "
|
|
40
|
+
"The CEO applies approved CRs."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config(root):
|
|
45
|
+
cfg = c.read_json_file(os.path.join(root, "company", "frozen-surfaces.json"))
|
|
46
|
+
surfaces = []
|
|
47
|
+
always = list(ALWAYS_DEFAULTS)
|
|
48
|
+
if isinstance(cfg, dict):
|
|
49
|
+
for s in cfg.get("surfaces", []) or []:
|
|
50
|
+
if isinstance(s, dict) and s.get("pattern"):
|
|
51
|
+
surfaces.append(s)
|
|
52
|
+
extra = cfg.get("always")
|
|
53
|
+
if isinstance(extra, list):
|
|
54
|
+
for pat in extra:
|
|
55
|
+
if pat not in always:
|
|
56
|
+
always.append(pat)
|
|
57
|
+
return surfaces, always
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def matches(pattern, rel, base):
|
|
61
|
+
return fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(base, pattern)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main():
|
|
65
|
+
payload = c.read_stdin_json()
|
|
66
|
+
if payload is None:
|
|
67
|
+
sys.exit(0)
|
|
68
|
+
if payload.get("tool_name") not in ("Edit", "Write", "MultiEdit"):
|
|
69
|
+
sys.exit(0)
|
|
70
|
+
|
|
71
|
+
root = c.project_root(payload)
|
|
72
|
+
tool_input = payload.get("tool_input") or {}
|
|
73
|
+
file_path = tool_input.get("file_path") or ""
|
|
74
|
+
if not file_path:
|
|
75
|
+
sys.exit(0)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
rel = c.rel_path(root, file_path)
|
|
79
|
+
base = os.path.basename(rel) or os.path.basename(file_path)
|
|
80
|
+
|
|
81
|
+
# Documented placeholder env files are always editable.
|
|
82
|
+
if base.startswith(".env") and base.endswith(ENV_ALLOW_SUFFIXES):
|
|
83
|
+
sys.exit(0)
|
|
84
|
+
|
|
85
|
+
surfaces, always = load_config(root)
|
|
86
|
+
|
|
87
|
+
for pat in always:
|
|
88
|
+
if matches(pat, rel, base):
|
|
89
|
+
c.block(
|
|
90
|
+
root, HOOK, rel, "always-frozen: " + pat,
|
|
91
|
+
"BLOCKED: '{}' is a frozen surface ({}). {}".format(
|
|
92
|
+
rel, pat, CR_NOTE
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
for s in surfaces:
|
|
97
|
+
pat = s.get("pattern")
|
|
98
|
+
if matches(pat, rel, base):
|
|
99
|
+
why = s.get("why", "declared frozen")
|
|
100
|
+
via = s.get("change_via", "CR")
|
|
101
|
+
c.block(
|
|
102
|
+
root, HOOK, rel, "frozen: " + pat,
|
|
103
|
+
"BLOCKED: '{}' is a frozen surface. WHY: {}. "
|
|
104
|
+
"Change via: {}. {}".format(rel, why, via, CR_NOTE),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# git-tracked migrations are immutable once shipped.
|
|
108
|
+
segs = rel.split("/")
|
|
109
|
+
in_migrations = "migrations" in segs[:-1]
|
|
110
|
+
in_alembic_versions = False
|
|
111
|
+
for i in range(len(segs) - 1):
|
|
112
|
+
if segs[i] == "alembic" and i + 1 < len(segs) and segs[i + 1] == "versions":
|
|
113
|
+
in_alembic_versions = True
|
|
114
|
+
break
|
|
115
|
+
if in_migrations or in_alembic_versions:
|
|
116
|
+
abs_path = file_path
|
|
117
|
+
if not os.path.isabs(abs_path):
|
|
118
|
+
abs_path = os.path.join(root, rel)
|
|
119
|
+
if c.is_git_tracked(abs_path):
|
|
120
|
+
c.block(
|
|
121
|
+
root, HOOK, rel, "shipped migration",
|
|
122
|
+
"BLOCKED: '{}' is a shipped (git-tracked) migration. "
|
|
123
|
+
"Migrations are immutable once committed; create a NEW "
|
|
124
|
+
"revision instead. {}".format(rel, CR_NOTE),
|
|
125
|
+
)
|
|
126
|
+
except SystemExit:
|
|
127
|
+
raise
|
|
128
|
+
except Exception:
|
|
129
|
+
sys.exit(0)
|
|
130
|
+
|
|
131
|
+
sys.exit(0)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
main()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse (Edit|Write|MultiEdit): enforce spec-before-code.
|
|
3
|
+
|
|
4
|
+
Writing SOURCE CODE requires an active brief. Non-source files (docs, config,
|
|
5
|
+
data, dotfiles, and anything under company/, .claude/, docs/, .github/) are
|
|
6
|
+
exempt. A hotfix task bypasses the check (logged). Fails open on error.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
13
|
+
import _common as c # noqa: E402
|
|
14
|
+
|
|
15
|
+
HOOK = "guard_spec"
|
|
16
|
+
|
|
17
|
+
NON_SOURCE_EXT = {
|
|
18
|
+
".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".cfg", ".ini",
|
|
19
|
+
}
|
|
20
|
+
EXEMPT_DIRS = {"company", ".claude", "docs", ".github"}
|
|
21
|
+
|
|
22
|
+
NO_BRIEF_MSG = (
|
|
23
|
+
"BLOCKED: no active brief. Self-serve fix:\n"
|
|
24
|
+
"1) Write company/briefs/brief-<slug>.md from "
|
|
25
|
+
"company/templates/BRIEF-TEMPLATE.md covering what you are about to build.\n"
|
|
26
|
+
"2) Write company/state/active-task.json exactly like this:\n"
|
|
27
|
+
" {\"task\": \"<slug>\", \"type\": \"feature\", "
|
|
28
|
+
"\"brief\": \"company/briefs/brief-<slug>.md\", \"test_scope\": false}\n"
|
|
29
|
+
"3) Retry the edit.\n"
|
|
30
|
+
"If you are the CEO handling a production emergency, set type to hotfix "
|
|
31
|
+
"(logged, not silent)."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_source(rel, base):
|
|
36
|
+
if not base:
|
|
37
|
+
return False
|
|
38
|
+
if base.startswith("."):
|
|
39
|
+
return False
|
|
40
|
+
segs = rel.split("/")
|
|
41
|
+
if any(s in EXEMPT_DIRS for s in segs[:-1]):
|
|
42
|
+
return False
|
|
43
|
+
ext = os.path.splitext(base)[1].lower()
|
|
44
|
+
if ext in NON_SOURCE_EXT:
|
|
45
|
+
return False
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main():
|
|
50
|
+
payload = c.read_stdin_json()
|
|
51
|
+
if payload is None:
|
|
52
|
+
sys.exit(0)
|
|
53
|
+
if payload.get("tool_name") not in ("Edit", "Write", "MultiEdit"):
|
|
54
|
+
sys.exit(0)
|
|
55
|
+
|
|
56
|
+
root = c.project_root(payload)
|
|
57
|
+
file_path = (payload.get("tool_input") or {}).get("file_path") or ""
|
|
58
|
+
if not file_path:
|
|
59
|
+
sys.exit(0)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
rel = c.rel_path(root, file_path)
|
|
63
|
+
base = os.path.basename(rel) or os.path.basename(file_path)
|
|
64
|
+
if not is_source(rel, base):
|
|
65
|
+
sys.exit(0)
|
|
66
|
+
|
|
67
|
+
task = c.active_task(root)
|
|
68
|
+
if isinstance(task, dict) and task.get("type") == "hotfix":
|
|
69
|
+
c.log_bypass(root, HOOK, rel, "hotfix mode")
|
|
70
|
+
sys.exit(0)
|
|
71
|
+
|
|
72
|
+
brief = task.get("brief") if isinstance(task, dict) else None
|
|
73
|
+
if not brief:
|
|
74
|
+
c.block(root, HOOK, rel, "no active brief", NO_BRIEF_MSG)
|
|
75
|
+
|
|
76
|
+
brief_path = brief
|
|
77
|
+
if not os.path.isabs(brief_path):
|
|
78
|
+
brief_path = os.path.join(root, brief)
|
|
79
|
+
if not os.path.exists(brief_path):
|
|
80
|
+
c.block(
|
|
81
|
+
root, HOOK, rel, "brief file missing: " + str(brief),
|
|
82
|
+
"BLOCKED: active brief '{}' does not exist. {}".format(
|
|
83
|
+
brief, NO_BRIEF_MSG
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
except SystemExit:
|
|
87
|
+
raise
|
|
88
|
+
except Exception:
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
|
|
91
|
+
sys.exit(0)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
main()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse (Edit|Write|MultiEdit|Bash): anti-reward-hacking on tests.
|
|
3
|
+
|
|
4
|
+
Tests are the oracle. Editing or deleting them is out of scope unless the
|
|
5
|
+
active brief explicitly opened test scope (active-task.json "test_scope": true).
|
|
6
|
+
|
|
7
|
+
- Edit/Write/MultiEdit on a test file: allow only when test_scope is true.
|
|
8
|
+
- Bash `rm` / `git rm` of a test path: block unless test_scope is true.
|
|
9
|
+
|
|
10
|
+
Fails open on any internal error.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import fnmatch
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
import _common as c # noqa: E402
|
|
21
|
+
|
|
22
|
+
HOOK = "guard_tests"
|
|
23
|
+
|
|
24
|
+
TEST_DIR_SEGMENTS = {"tests", "test", "__tests__", "e2e"}
|
|
25
|
+
OUT_OF_SCOPE = (
|
|
26
|
+
"BLOCKED: editing tests is out of scope for this brief. Tests are the "
|
|
27
|
+
"oracle; changing them to pass is reward-hacking. Open test scope in "
|
|
28
|
+
"company/state/active-task.json (\"test_scope\": true) only if the brief "
|
|
29
|
+
"calls for it."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_test_path(path):
|
|
34
|
+
norm = (path or "").replace("\\", "/")
|
|
35
|
+
segs = [s for s in norm.split("/") if s]
|
|
36
|
+
if not segs:
|
|
37
|
+
return False
|
|
38
|
+
base = segs[-1]
|
|
39
|
+
if any(s in TEST_DIR_SEGMENTS for s in segs[:-1]):
|
|
40
|
+
return True
|
|
41
|
+
if re.match(r"test_.*\.py$", base):
|
|
42
|
+
return True
|
|
43
|
+
if fnmatch.fnmatch(base, "*_test.*"):
|
|
44
|
+
return True
|
|
45
|
+
if fnmatch.fnmatch(base, "*.test.*"):
|
|
46
|
+
return True
|
|
47
|
+
if fnmatch.fnmatch(base, "*.spec.*"):
|
|
48
|
+
return True
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_scope_open(root):
|
|
53
|
+
task = c.active_task(root)
|
|
54
|
+
return isinstance(task, dict) and task.get("test_scope") is True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def segments(command):
|
|
58
|
+
parts = re.split(r"&&|\|\||;|\|", command)
|
|
59
|
+
return [p.strip() for p in parts if p.strip()]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def rm_targets(segment):
|
|
63
|
+
"""Paths a segment tries to remove via rm or git rm, else []."""
|
|
64
|
+
try:
|
|
65
|
+
toks = shlex.split(segment)
|
|
66
|
+
except Exception:
|
|
67
|
+
toks = segment.split()
|
|
68
|
+
if not toks:
|
|
69
|
+
return []
|
|
70
|
+
if toks[0] == "rm":
|
|
71
|
+
rest = toks[1:]
|
|
72
|
+
elif len(toks) >= 2 and toks[0] == "git" and toks[1] == "rm":
|
|
73
|
+
rest = toks[2:]
|
|
74
|
+
else:
|
|
75
|
+
return []
|
|
76
|
+
return [t for t in rest if not t.startswith("-")]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main():
|
|
80
|
+
payload = c.read_stdin_json()
|
|
81
|
+
if payload is None:
|
|
82
|
+
sys.exit(0)
|
|
83
|
+
tool = payload.get("tool_name")
|
|
84
|
+
if tool not in ("Edit", "Write", "MultiEdit", "Bash"):
|
|
85
|
+
sys.exit(0)
|
|
86
|
+
|
|
87
|
+
root = c.project_root(payload)
|
|
88
|
+
tool_input = payload.get("tool_input") or {}
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
if tool == "Bash":
|
|
92
|
+
if test_scope_open(root):
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
command = tool_input.get("command") or ""
|
|
95
|
+
for seg in segments(command):
|
|
96
|
+
for target in rm_targets(seg):
|
|
97
|
+
if is_test_path(target):
|
|
98
|
+
c.block(
|
|
99
|
+
root, HOOK, target, "rm of test file",
|
|
100
|
+
"BLOCKED: removing test file '{}'. {}".format(
|
|
101
|
+
target, OUT_OF_SCOPE
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
sys.exit(0)
|
|
105
|
+
|
|
106
|
+
file_path = tool_input.get("file_path") or ""
|
|
107
|
+
if not file_path:
|
|
108
|
+
sys.exit(0)
|
|
109
|
+
rel = c.rel_path(root, file_path)
|
|
110
|
+
if not is_test_path(rel):
|
|
111
|
+
sys.exit(0)
|
|
112
|
+
if test_scope_open(root):
|
|
113
|
+
sys.exit(0)
|
|
114
|
+
c.block(root, HOOK, rel, "test edit out of scope", OUT_OF_SCOPE)
|
|
115
|
+
except SystemExit:
|
|
116
|
+
raise
|
|
117
|
+
except Exception:
|
|
118
|
+
sys.exit(0)
|
|
119
|
+
|
|
120
|
+
sys.exit(0)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
main()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse (Edit|Write|MultiEdit): block AI-slop tells in inserted text.
|
|
3
|
+
|
|
4
|
+
Ported from budget-manager's no-ai-slop.py. Scans only the text being written
|
|
5
|
+
(never the old text). Punctuation tells are near-zero false positive; phrase
|
|
6
|
+
tells are deliberately multi-word so they will not trip on identifiers. Files
|
|
7
|
+
under company/state/ (logs) and binary-ish extensions are skipped. Exit 2 on a
|
|
8
|
+
hit, exit 0 otherwise; fails open on malformed input.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
import _common as c # noqa: E402
|
|
17
|
+
|
|
18
|
+
HOOK = "no_slop"
|
|
19
|
+
|
|
20
|
+
# --- 1. Punctuation tells (char, human-readable name, suggested fix) ---
|
|
21
|
+
PUNCT_TELLS = [
|
|
22
|
+
("—", "em dash", "use ' - ', ', ', or split the sentence"),
|
|
23
|
+
("–", "en dash", "use a hyphen '-' (or 'to' for ranges)"),
|
|
24
|
+
("―", "horizontal bar", "use '-' or rewrite"),
|
|
25
|
+
("“", "smart double quote (open)", "use a straight quote \""),
|
|
26
|
+
("”", "smart double quote (close)", "use a straight quote \""),
|
|
27
|
+
("‘", "smart single quote (open)", "use a straight quote '"),
|
|
28
|
+
("’", "smart single quote (close)", "use a straight apostrophe '"),
|
|
29
|
+
("…", "ellipsis char", "use three dots '...'"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# --- 2. Phrase tells (regex, case-insensitive). Multi-word on purpose. ---
|
|
33
|
+
PHRASE_TELLS = [
|
|
34
|
+
r"\bdelve(s|d|ing)?\b",
|
|
35
|
+
r"\bit'?s worth noting\b",
|
|
36
|
+
r"\bit'?s important to note\b",
|
|
37
|
+
r"\b(a )?testament to\b",
|
|
38
|
+
r"\bnavigat(e|ing) the (complexit|landscape|world)",
|
|
39
|
+
r"\bin the (realm|world) of\b",
|
|
40
|
+
r"\btapestry\b",
|
|
41
|
+
r"\bat the end of the day\b",
|
|
42
|
+
r"\bneedless to say\b",
|
|
43
|
+
r"\bin today'?s (fast-paced|digital|ever-changing)\b",
|
|
44
|
+
r"\bunlock(ing)? the (power|potential)\b",
|
|
45
|
+
r"\bsupercharg(e|ed|ing)\b",
|
|
46
|
+
r"\bseamless(ly)?\b",
|
|
47
|
+
r"\bbustling\b",
|
|
48
|
+
r"\bgame.?chang(er|ing)\b",
|
|
49
|
+
r"\bparadigm shift\b",
|
|
50
|
+
r"\bnot only\b[^.\n]{0,60}\bbut also\b",
|
|
51
|
+
r"\bwhen it comes to\b",
|
|
52
|
+
r"\belevate your\b",
|
|
53
|
+
r"\bdive (deep|into)\b",
|
|
54
|
+
]
|
|
55
|
+
COMPILED_PHRASES = [re.compile(p, re.IGNORECASE) for p in PHRASE_TELLS]
|
|
56
|
+
|
|
57
|
+
BINARY_EXT = {
|
|
58
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip",
|
|
59
|
+
".gz", ".tar", ".woff", ".woff2", ".ttf", ".otf", ".mp4", ".mp3",
|
|
60
|
+
".wav", ".bin", ".so", ".dylib", ".o", ".class", ".lockb",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def text_under_edit(tool_name, tool_input):
|
|
65
|
+
if tool_name == "Write":
|
|
66
|
+
return tool_input.get("content", "") or ""
|
|
67
|
+
if tool_name == "Edit":
|
|
68
|
+
return tool_input.get("new_string", "") or ""
|
|
69
|
+
parts = []
|
|
70
|
+
for edit in tool_input.get("edits", []) or []:
|
|
71
|
+
parts.append(edit.get("new_string", "") or "")
|
|
72
|
+
return "\n".join(parts)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def scan(text):
|
|
76
|
+
hits = []
|
|
77
|
+
for ch, name, fix in PUNCT_TELLS:
|
|
78
|
+
if ch in text:
|
|
79
|
+
n = text.count(ch)
|
|
80
|
+
hits.append(" - {} x{} -> {}".format(name, n, fix))
|
|
81
|
+
for pat in COMPILED_PHRASES:
|
|
82
|
+
m = pat.search(text)
|
|
83
|
+
if m:
|
|
84
|
+
hits.append(
|
|
85
|
+
" - slop phrase: \"{}\" -> rewrite plainly".format(m.group(0))
|
|
86
|
+
)
|
|
87
|
+
return hits
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def skip_path(file_path):
|
|
91
|
+
norm = (file_path or "").replace("\\", "/")
|
|
92
|
+
if "company/state/" in norm:
|
|
93
|
+
return True
|
|
94
|
+
ext = os.path.splitext(norm)[1].lower()
|
|
95
|
+
return ext in BINARY_EXT
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main():
|
|
99
|
+
try:
|
|
100
|
+
data = json.load(sys.stdin)
|
|
101
|
+
except Exception:
|
|
102
|
+
return 0
|
|
103
|
+
tool_name = data.get("tool_name", "")
|
|
104
|
+
if tool_name not in ("Write", "Edit", "MultiEdit"):
|
|
105
|
+
return 0
|
|
106
|
+
tool_input = data.get("tool_input", {}) or {}
|
|
107
|
+
file_path = tool_input.get("file_path", "") or ""
|
|
108
|
+
if skip_path(file_path):
|
|
109
|
+
return 0
|
|
110
|
+
text = text_under_edit(tool_name, tool_input)
|
|
111
|
+
if not text:
|
|
112
|
+
return 0
|
|
113
|
+
hits = scan(text)
|
|
114
|
+
if not hits:
|
|
115
|
+
return 0
|
|
116
|
+
try:
|
|
117
|
+
root = c.project_root(data)
|
|
118
|
+
c.adherence_log(
|
|
119
|
+
root, HOOK, "BLOCK", c.rel_path(root, file_path),
|
|
120
|
+
"slop tells: {}".format(len(hits)),
|
|
121
|
+
)
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
print(
|
|
125
|
+
"Blocked: AI-slop tells found in the text you are writing to "
|
|
126
|
+
"{}.\n".format(file_path or "<file>") + "\n".join(hits) + "\n"
|
|
127
|
+
"Rewrite the offending text and retry.",
|
|
128
|
+
file=sys.stderr,
|
|
129
|
+
)
|
|
130
|
+
return 2
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
sys.exit(main())
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart hook: print a compact claude-company state digest.
|
|
3
|
+
|
|
4
|
+
If company/state/RESUME.md or STATUS.md exist, emit a plain-text digest (<= 60
|
|
5
|
+
lines): the first 40 lines of RESUME.md, the first 20 of STATUS.md, and one
|
|
6
|
+
line for the active task. Always exits 0.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
13
|
+
import _common as c # noqa: E402
|
|
14
|
+
|
|
15
|
+
MAX_LINES = 60
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def head_lines(path, n):
|
|
19
|
+
try:
|
|
20
|
+
with open(path) as f:
|
|
21
|
+
out = []
|
|
22
|
+
for _ in range(n):
|
|
23
|
+
line = f.readline()
|
|
24
|
+
if not line:
|
|
25
|
+
break
|
|
26
|
+
out.append(line.rstrip("\n"))
|
|
27
|
+
return out
|
|
28
|
+
except Exception:
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main():
|
|
33
|
+
payload = c.read_stdin_json() or {}
|
|
34
|
+
try:
|
|
35
|
+
root = c.project_root(payload)
|
|
36
|
+
state = os.path.join(root, "company", "state")
|
|
37
|
+
resume = os.path.join(state, "RESUME.md")
|
|
38
|
+
status = os.path.join(state, "STATUS.md")
|
|
39
|
+
if not (os.path.exists(resume) or os.path.exists(status)):
|
|
40
|
+
sys.exit(0)
|
|
41
|
+
|
|
42
|
+
out = ["claude-company state digest"]
|
|
43
|
+
if os.path.exists(resume):
|
|
44
|
+
out.append("-- RESUME.md --")
|
|
45
|
+
out.extend(head_lines(resume, 40))
|
|
46
|
+
if os.path.exists(status):
|
|
47
|
+
out.append("-- STATUS.md --")
|
|
48
|
+
out.extend(head_lines(status, 20))
|
|
49
|
+
task = c.active_task(root)
|
|
50
|
+
if isinstance(task, dict):
|
|
51
|
+
out.append(
|
|
52
|
+
"active-task: {} ({}) brief={}".format(
|
|
53
|
+
task.get("task"), task.get("type"), task.get("brief")
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
print("\n".join(out[:MAX_LINES]))
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
sys.exit(0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|