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.
Files changed (62) hide show
  1. package/.claude/agents/architect.md +69 -0
  2. package/.claude/agents/auditor.md +44 -0
  3. package/.claude/agents/developer.md +86 -0
  4. package/.claude/agents/devops-engineer.md +38 -0
  5. package/.claude/agents/docs-librarian.md +36 -0
  6. package/.claude/agents/ideation-strategist.md +54 -0
  7. package/.claude/agents/product-manager.md +54 -0
  8. package/.claude/agents/qa-engineer.md +58 -0
  9. package/.claude/agents/security-reviewer.md +44 -0
  10. package/.claude/agents/tech-lead.md +80 -0
  11. package/.claude/hooks/_common.py +209 -0
  12. package/.claude/hooks/gate_stamp.py +77 -0
  13. package/.claude/hooks/gates_detect.py +387 -0
  14. package/.claude/hooks/guard_commit.py +123 -0
  15. package/.claude/hooks/guard_frozen.py +135 -0
  16. package/.claude/hooks/guard_spec.py +95 -0
  17. package/.claude/hooks/guard_tests.py +124 -0
  18. package/.claude/hooks/no_slop.py +134 -0
  19. package/.claude/hooks/session_start.py +63 -0
  20. package/.claude/hooks/stop_gate.py +59 -0
  21. package/.claude/settings.json +70 -0
  22. package/.claude/skills/autopilot/SKILL.md +65 -0
  23. package/.claude/skills/brainstorm/SKILL.md +61 -0
  24. package/.claude/skills/company-init/SKILL.md +51 -0
  25. package/.claude/skills/cr/SKILL.md +44 -0
  26. package/.claude/skills/feature/SKILL.md +42 -0
  27. package/.claude/skills/gates/SKILL.md +33 -0
  28. package/.claude/skills/onboard/SKILL.md +52 -0
  29. package/.claude/skills/orchestrator/SKILL.md +84 -0
  30. package/.claude/skills/standup/SKILL.md +38 -0
  31. package/.mcp.json +1 -0
  32. package/LICENSE +21 -0
  33. package/ORCHESTRATOR.md +191 -0
  34. package/README.md +236 -0
  35. package/bin/claude-company.js +112 -0
  36. package/company/EXTENDING.md +58 -0
  37. package/company/GATES.md +59 -0
  38. package/company/GIT.md +119 -0
  39. package/company/IDEATION.md +102 -0
  40. package/company/LOOPS.md +108 -0
  41. package/company/METHOD.md +151 -0
  42. package/company/frozen-surfaces.json +17 -0
  43. package/company/gates.config +5 -0
  44. package/company/run-gates.sh +158 -0
  45. package/company/state/DECISIONS.md +9 -0
  46. package/company/state/RESUME.md +28 -0
  47. package/company/state/STATUS.md +30 -0
  48. package/company/state/WORRIES.md +12 -0
  49. package/company/state/adherence.log +1 -0
  50. package/company/templates/BRIEF-TEMPLATE.md +69 -0
  51. package/company/templates/CR-TEMPLATE.md +28 -0
  52. package/company/templates/MODULE-TEMPLATE.md +23 -0
  53. package/company/templates/OPTIONS-TEMPLATE.md +42 -0
  54. package/company/templates/REPORT-TEMPLATE.md +35 -0
  55. package/company/templates/SPEC-TEMPLATE.md +69 -0
  56. package/docs/customizing.md +88 -0
  57. package/docs/getting-started.md +99 -0
  58. package/docs/how-it-works.md +153 -0
  59. package/install +4 -0
  60. package/install.sh +331 -0
  61. package/lib/install-tui.js +1600 -0
  62. 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()