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,209 @@
1
+ #!/usr/bin/env python3
2
+ """Shared helpers for the claude-company enforcement hooks.
3
+
4
+ Python 3 stdlib only. Everything here fails open: on any internal error the
5
+ callers should treat the result as "allow" rather than bricking the session.
6
+ The one deliberate exception is git-tracked uncertainty in the immutability
7
+ checks, which fail safe (treat as tracked) per the frozen-surface contract.
8
+ """
9
+
10
+ import datetime
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+
17
+ # Documented anti-accident salt (not anti-adversary). Bump the suffix only on a
18
+ # real stamp-format change.
19
+ CHECKSUM_SALT = "claude-company.gates.v1"
20
+
21
+
22
+ def read_stdin_json():
23
+ """Parse the hook JSON payload from stdin. None on any failure."""
24
+ try:
25
+ return json.load(sys.stdin)
26
+ except Exception:
27
+ return None
28
+
29
+
30
+ def project_root(payload):
31
+ """Resolve the project root: CLAUDE_PROJECT_DIR, else stdin cwd, else cwd."""
32
+ root = os.environ.get("CLAUDE_PROJECT_DIR")
33
+ if root:
34
+ return root
35
+ if isinstance(payload, dict):
36
+ cwd = payload.get("cwd")
37
+ if cwd:
38
+ return cwd
39
+ return os.getcwd()
40
+
41
+
42
+ def iso_now():
43
+ return datetime.datetime.now(datetime.timezone.utc).strftime(
44
+ "%Y-%m-%dT%H:%M:%SZ"
45
+ )
46
+
47
+
48
+ def adherence_log(root, hook_name, action, target, reason):
49
+ """Append one line to company/state/adherence.log. Never raises."""
50
+ try:
51
+ state_dir = os.path.join(root, "company", "state")
52
+ os.makedirs(state_dir, exist_ok=True)
53
+ target = (target or "").replace("\n", " ")
54
+ reason = (reason or "").replace("\n", " ")
55
+ line = "{} | {} | {} | {} | {}\n".format(
56
+ iso_now(), hook_name, action, target, reason
57
+ )
58
+ with open(os.path.join(state_dir, "adherence.log"), "a") as f:
59
+ f.write(line)
60
+ except Exception:
61
+ pass
62
+
63
+
64
+ def block(root, hook_name, target, short_reason, message):
65
+ """Log a BLOCK line, print the human message to stderr, exit 2."""
66
+ adherence_log(root, hook_name, "BLOCK", target, short_reason)
67
+ print(message, file=sys.stderr)
68
+ sys.exit(2)
69
+
70
+
71
+ def log_bypass(root, hook_name, target, short_reason):
72
+ adherence_log(root, hook_name, "BYPASS", target, short_reason)
73
+
74
+
75
+ def read_json_file(path):
76
+ try:
77
+ with open(path) as f:
78
+ return json.load(f)
79
+ except Exception:
80
+ return None
81
+
82
+
83
+ def active_task(root):
84
+ return read_json_file(
85
+ os.path.join(root, "company", "state", "active-task.json")
86
+ )
87
+
88
+
89
+ def gates_config(root):
90
+ return read_json_file(os.path.join(root, "company", "gates.config"))
91
+
92
+
93
+ def rel_path(root, file_path):
94
+ """Project-relative, forward-slash path for file_path under root.
95
+
96
+ Falls back to the input (minus a leading slash) when file_path is outside
97
+ the project tree.
98
+ """
99
+ if not file_path:
100
+ return ""
101
+ norm = file_path.replace("\\", "/")
102
+ try:
103
+ root_norm = os.path.abspath(root).replace("\\", "/").rstrip("/")
104
+ if norm.startswith("/"):
105
+ candidate = norm
106
+ else:
107
+ candidate = root_norm + "/" + norm
108
+ candidate = os.path.normpath(candidate).replace("\\", "/")
109
+ if candidate == root_norm:
110
+ return ""
111
+ if candidate.startswith(root_norm + "/"):
112
+ return candidate[len(root_norm) + 1:]
113
+ except Exception:
114
+ pass
115
+ return norm.lstrip("/")
116
+
117
+
118
+ def _git(root, args):
119
+ try:
120
+ result = subprocess.run(
121
+ ["git", "-C", root] + args, capture_output=True, timeout=5
122
+ )
123
+ except Exception:
124
+ return None
125
+ if result.returncode != 0:
126
+ return None
127
+ return result.stdout.decode("utf-8", "replace")
128
+
129
+
130
+ def current_branch(root):
131
+ """Current branch name, or None on git uncertainty."""
132
+ out = _git(root, ["symbolic-ref", "--short", "HEAD"])
133
+ if out is None:
134
+ return None
135
+ return out.strip() or None
136
+
137
+
138
+ def is_git_tracked(file_path):
139
+ """True if committed/staged in git (shipped, immutable).
140
+
141
+ Returns True on any uncertainty (git missing, not a repo) so immutability
142
+ checks fail safe. Returncode 1 is a real untracked file inside a repo,
143
+ which is the freshly generated artifact we want to leave editable.
144
+ """
145
+ directory = os.path.dirname(file_path) or "."
146
+ try:
147
+ result = subprocess.run(
148
+ ["git", "-C", directory, "ls-files", "--error-unmatch", file_path],
149
+ capture_output=True,
150
+ timeout=5,
151
+ )
152
+ except Exception:
153
+ return True
154
+ return result.returncode != 1
155
+
156
+
157
+ def work_hash(root):
158
+ """Fingerprint the working tree. Fail-open to 'no-git'.
159
+
160
+ company/state/ is excluded from the fingerprint: the stamp and adherence
161
+ log live there and would otherwise self-invalidate the hash the moment they
162
+ are written.
163
+ """
164
+ exclude = ["--", ".", ":(exclude)company/state"]
165
+ head = _git(root, ["rev-parse", "HEAD"])
166
+ status = _git(root, ["status", "--porcelain"] + exclude)
167
+ diff = _git(root, ["diff"] + exclude)
168
+ cached = _git(root, ["diff", "--cached"] + exclude)
169
+ if head is None and status is None and diff is None and cached is None:
170
+ return "no-git"
171
+ digest = hashlib.sha256()
172
+ for part in (head, status, diff, cached):
173
+ digest.update((part or "").encode("utf-8", "replace"))
174
+ digest.update(b"\x00")
175
+ return digest.hexdigest()
176
+
177
+
178
+ def stamp_checksum(payload_without_checksum):
179
+ """sha256 of canonical stamp payload plus the salt."""
180
+ canonical = json.dumps(
181
+ payload_without_checksum, sort_keys=True, separators=(",", ":")
182
+ )
183
+ return hashlib.sha256(
184
+ (canonical + CHECKSUM_SALT).encode("utf-8")
185
+ ).hexdigest()
186
+
187
+
188
+ def read_stamp(root):
189
+ return read_json_file(
190
+ os.path.join(root, "company", "state", "gates.status")
191
+ )
192
+
193
+
194
+ def check_stamp(root):
195
+ """Return (ok, reason). ok iff the stamp is green, fresh, and valid."""
196
+ stamp = read_stamp(root)
197
+ if stamp is None:
198
+ return False, "no gates.status stamp (gates have not been run)"
199
+ if not isinstance(stamp, dict):
200
+ return False, "gates.status is malformed"
201
+ stored = stamp.get("checksum")
202
+ payload = {k: v for k, v in stamp.items() if k != "checksum"}
203
+ if stored != stamp_checksum(payload):
204
+ return False, "gates.status checksum invalid (stamp edited by hand)"
205
+ if stamp.get("status") != "green":
206
+ return False, "gates are red (last run had failing gates)"
207
+ if stamp.get("work_hash") != work_hash(root):
208
+ return False, "gates.status is stale (work changed since gates ran)"
209
+ return True, "green"
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env python3
2
+ """Gate stamp CLI (NOT a hook). Called by the gate runner.
3
+
4
+ python3 gate_stamp.py --results '{"gates":[{"name":"tests","ok":true}]}'
5
+ Compute overall status (green iff every gate ok), the work hash, and a
6
+ checksum, then write company/state/gates.status.
7
+
8
+ python3 gate_stamp.py --check
9
+ Exit 0 if the stamp is green + fresh + valid, else exit 1 with a reason.
10
+
11
+ Project root comes from CLAUDE_PROJECT_DIR, falling back to the cwd.
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
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
+
23
+ def resolve_root():
24
+ return os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
25
+
26
+
27
+ def write_stamp(root, results_json):
28
+ data = json.loads(results_json)
29
+ gates = data.get("gates", []) or []
30
+ all_ok = all(bool(g.get("ok")) for g in gates)
31
+ status = "green" if all_ok else "red"
32
+ payload = {
33
+ "status": status,
34
+ "ran_at": c.iso_now(),
35
+ "work_hash": c.work_hash(root),
36
+ "gates": gates,
37
+ }
38
+ payload["checksum"] = c.stamp_checksum(
39
+ {k: v for k, v in payload.items() if k != "checksum"}
40
+ )
41
+ state_dir = os.path.join(root, "company", "state")
42
+ os.makedirs(state_dir, exist_ok=True)
43
+ with open(os.path.join(state_dir, "gates.status"), "w") as f:
44
+ json.dump(payload, f, indent=2)
45
+ f.write("\n")
46
+ return status
47
+
48
+
49
+ def main():
50
+ ap = argparse.ArgumentParser(description="claude-company gate stamp")
51
+ ap.add_argument("--results", help="JSON gate results")
52
+ ap.add_argument("--check", action="store_true", help="verify stamp")
53
+ args = ap.parse_args()
54
+
55
+ root = resolve_root()
56
+
57
+ if args.check:
58
+ ok, reason = c.check_stamp(root)
59
+ print(reason)
60
+ sys.exit(0 if ok else 1)
61
+
62
+ if args.results:
63
+ try:
64
+ status = write_stamp(root, args.results)
65
+ except Exception as exc:
66
+ print("gate_stamp: failed to write stamp: {}".format(exc),
67
+ file=sys.stderr)
68
+ sys.exit(1)
69
+ print("wrote gates.status: {}".format(status))
70
+ sys.exit(0)
71
+
72
+ ap.print_help()
73
+ sys.exit(2)
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env python3
2
+ """gates_detect.py - sniff a project and propose real mechanical gates.
3
+
4
+ CLI (not a hook): `python3 .claude/hooks/gates_detect.py [--write]`.
5
+
6
+ Detects the project stack (JS/TS, Python, Go, Rust, Makefile) and proposes
7
+ concrete gate commands ordered cheap-to-expensive (lint, typecheck, tests,
8
+ build). Only commands whose tool is actually invocable (shutil.which on the
9
+ binary) are proposed; the rest are reported as "detected_but_missing_tool" and
10
+ skipped on --write.
11
+
12
+ Without --write: prints a human table plus a machine-readable JSON line
13
+ (prefixed `GATES_JSON: `) and leaves company/gates.config untouched.
14
+
15
+ With --write: writes company/gates.config in the existing
16
+ `{"gates": [{"name", "command", "blocking": true}]}` shape, UNLESS the existing
17
+ config already holds a non-placeholder gate (a gate whose command contains
18
+ "CONFIGURE ME" is a placeholder and may be replaced). If no stack is detected
19
+ the config is left untouched either way.
20
+
21
+ Stdlib only. Exits 0 in the normal case.
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ import shutil
28
+ import sys
29
+
30
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+ import _common as c # noqa: E402
32
+
33
+ # cheap-to-expensive ordering buckets
34
+ ORDER = {"lint": 0, "typecheck": 1, "tests": 2, "build": 3, "other": 4}
35
+
36
+ PLACEHOLDER_MARK = "configure me"
37
+
38
+
39
+ def project_dir():
40
+ root = os.environ.get("CLAUDE_PROJECT_DIR")
41
+ if root:
42
+ return root
43
+ return os.getcwd()
44
+
45
+
46
+ def read_text(path):
47
+ try:
48
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
49
+ return f.read()
50
+ except Exception:
51
+ return ""
52
+
53
+
54
+ def exists(root, name):
55
+ return os.path.exists(os.path.join(root, name))
56
+
57
+
58
+ def gate(name, command, binary, kind):
59
+ """Build a proposed-gate record."""
60
+ return {
61
+ "name": name,
62
+ "command": command,
63
+ "binary": binary,
64
+ "kind": kind,
65
+ "order": ORDER.get(kind, ORDER["other"]),
66
+ }
67
+
68
+
69
+ def detect_node(root, gates, stacks):
70
+ pkg_path = os.path.join(root, "package.json")
71
+ if not os.path.exists(pkg_path):
72
+ return None
73
+ try:
74
+ pkg = json.loads(read_text(pkg_path))
75
+ except Exception:
76
+ pkg = {}
77
+ if not isinstance(pkg, dict):
78
+ pkg = {}
79
+ stacks.append("package.json")
80
+
81
+ # package manager from lockfiles
82
+ if exists(root, "pnpm-lock.yaml"):
83
+ pm = "pnpm"
84
+ elif exists(root, "yarn.lock"):
85
+ pm = "yarn"
86
+ else:
87
+ pm = "npm"
88
+
89
+ scripts = pkg.get("scripts") or {}
90
+ if not isinstance(scripts, dict):
91
+ scripts = {}
92
+
93
+ def run_cmd(script):
94
+ # `npm test` is idiomatic; other scripts go through `run`.
95
+ if script == "test":
96
+ return "{} test".format(pm)
97
+ return "{} run {}".format(pm, script)
98
+
99
+ if scripts.get("lint"):
100
+ gates.append(gate("lint", run_cmd("lint"), pm, "lint"))
101
+ if scripts.get("typecheck"):
102
+ gates.append(gate("typecheck", run_cmd("typecheck"), pm, "typecheck"))
103
+ else:
104
+ deps = {}
105
+ for key in ("dependencies", "devDependencies"):
106
+ d = pkg.get(key)
107
+ if isinstance(d, dict):
108
+ deps.update(d)
109
+ if "typescript" in deps:
110
+ gates.append(
111
+ gate("typecheck", "tsc --noEmit", "tsc", "typecheck")
112
+ )
113
+ if scripts.get("test"):
114
+ gates.append(gate("tests", run_cmd("test"), pm, "tests"))
115
+ if scripts.get("build"):
116
+ gates.append(gate("build", run_cmd("build"), pm, "build"))
117
+ return pm
118
+
119
+
120
+ def detect_python(root, gates, stacks):
121
+ pyproject = os.path.join(root, "pyproject.toml")
122
+ pyproject_txt = read_text(pyproject) if os.path.exists(pyproject) else ""
123
+ setup_cfg_txt = (
124
+ read_text(os.path.join(root, "setup.cfg"))
125
+ if exists(root, "setup.cfg")
126
+ else ""
127
+ )
128
+ tox_txt = (
129
+ read_text(os.path.join(root, "tox.ini"))
130
+ if exists(root, "tox.ini")
131
+ else ""
132
+ )
133
+
134
+ test_signals = any(
135
+ exists(root, f)
136
+ for f in ("pyproject.toml", "setup.cfg", "pytest.ini", "conftest.py")
137
+ )
138
+ if not test_signals:
139
+ return
140
+ stacks.append("python")
141
+
142
+ # lint: ruff, then flake8
143
+ ruff_cfg = (
144
+ "[tool.ruff" in pyproject_txt
145
+ or exists(root, "ruff.toml")
146
+ or exists(root, ".ruff.toml")
147
+ )
148
+ flake8_cfg = (
149
+ exists(root, ".flake8")
150
+ or "[flake8]" in setup_cfg_txt
151
+ or "[flake8]" in tox_txt
152
+ )
153
+ if ruff_cfg:
154
+ gates.append(gate("lint", "ruff check .", "ruff", "lint"))
155
+ elif flake8_cfg:
156
+ gates.append(gate("lint", "flake8", "flake8", "lint"))
157
+
158
+ # typecheck: mypy
159
+ mypy_cfg = (
160
+ "[tool.mypy" in pyproject_txt
161
+ or exists(root, "mypy.ini")
162
+ or exists(root, ".mypy.ini")
163
+ or "[mypy]" in setup_cfg_txt
164
+ )
165
+ if mypy_cfg:
166
+ gates.append(gate("typecheck", "mypy .", "mypy", "typecheck"))
167
+
168
+ # tests: pytest
169
+ gates.append(gate("tests", "python3 -m pytest", "python3", "tests"))
170
+
171
+
172
+ def detect_go(root, gates, stacks):
173
+ if not exists(root, "go.mod"):
174
+ return
175
+ stacks.append("go.mod")
176
+ gates.append(gate("vet", "go vet ./...", "go", "lint"))
177
+ gates.append(gate("tests", "go test ./...", "go", "tests"))
178
+
179
+
180
+ def detect_rust(root, gates, stacks):
181
+ if not exists(root, "Cargo.toml"):
182
+ return
183
+ stacks.append("Cargo.toml")
184
+ gates.append(
185
+ gate("clippy", "cargo clippy -- -D warnings", "cargo", "lint")
186
+ )
187
+ gates.append(gate("tests", "cargo test", "cargo", "tests"))
188
+
189
+
190
+ def detect_make(root, gates, stacks):
191
+ if not exists(root, "Makefile"):
192
+ return
193
+ txt = read_text(os.path.join(root, "Makefile"))
194
+ targets = set()
195
+ for line in txt.splitlines():
196
+ m = re.match(r"^([A-Za-z0-9_-]+)\s*:", line)
197
+ if m:
198
+ targets.add(m.group(1))
199
+ wanted = [("lint", "lint"), ("gates", "other"), ("test", "tests")]
200
+ found = False
201
+ for target, kind in wanted:
202
+ if target in targets:
203
+ found = True
204
+ name = "tests" if target == "test" else target
205
+ gates.append(
206
+ gate(name, "make {}".format(target), "make", kind)
207
+ )
208
+ if found:
209
+ stacks.append("Makefile")
210
+
211
+
212
+ def dedupe_and_order(gates):
213
+ """Sort cheap-to-expensive, drop duplicate commands, unique-ify names."""
214
+ ordered = sorted(
215
+ enumerate(gates), key=lambda pair: (pair[1]["order"], pair[0])
216
+ )
217
+ seen_cmd = set()
218
+ seen_name = {}
219
+ out = []
220
+ for _, g in ordered:
221
+ cmd = g["command"]
222
+ if cmd in seen_cmd:
223
+ continue
224
+ seen_cmd.add(cmd)
225
+ name = g["name"]
226
+ if name in seen_name:
227
+ seen_name[name] += 1
228
+ name = "{}-{}".format(name, seen_name[name])
229
+ else:
230
+ seen_name[name] = 1
231
+ entry = dict(g)
232
+ entry["name"] = name
233
+ out.append(entry)
234
+ return out
235
+
236
+
237
+ def split_invocable(gates):
238
+ proposed, skipped = [], []
239
+ for g in gates:
240
+ if shutil.which(g["binary"]):
241
+ proposed.append(g)
242
+ else:
243
+ skipped.append(g)
244
+ return proposed, skipped
245
+
246
+
247
+ def config_has_real_gates(cfg):
248
+ if not isinstance(cfg, dict):
249
+ return False
250
+ for g in cfg.get("gates") or []:
251
+ if not isinstance(g, dict):
252
+ continue
253
+ cmd = str(g.get("command", ""))
254
+ if cmd and PLACEHOLDER_MARK not in cmd.lower():
255
+ return True
256
+ return False
257
+
258
+
259
+ def to_config_gates(gates):
260
+ return [
261
+ {"name": g["name"], "command": g["command"], "blocking": True}
262
+ for g in gates
263
+ ]
264
+
265
+
266
+ def write_config(root, config_gates):
267
+ path = os.path.join(root, "company", "gates.config")
268
+ os.makedirs(os.path.dirname(path), exist_ok=True)
269
+ body = {
270
+ "$comment": (
271
+ "Auto-generated by gates_detect.py. Every gate is blocking; "
272
+ "edit freely. See company/GATES.md."
273
+ ),
274
+ "gates": config_gates,
275
+ }
276
+ with open(path, "w", encoding="utf-8") as f:
277
+ f.write(json.dumps(body, indent=2))
278
+ f.write("\n")
279
+ return path
280
+
281
+
282
+ def print_table(proposed, skipped, pm, stacks):
283
+ if stacks:
284
+ print("Detected stack: {}".format(", ".join(stacks)))
285
+ if pm:
286
+ print("Package manager: {}".format(pm))
287
+ print("")
288
+ header = "{:<12} {:<10} {}".format("GATE", "STATUS", "COMMAND")
289
+ print(header)
290
+ print("-" * len(header))
291
+ for g in proposed:
292
+ print("{:<12} {:<10} {}".format(g["name"], "ready", g["command"]))
293
+ for g in skipped:
294
+ print(
295
+ "{:<12} {:<10} {} (missing: {})".format(
296
+ g["name"], "no-tool", g["command"], g["binary"]
297
+ )
298
+ )
299
+ if not proposed and not skipped:
300
+ print("(no gate candidates)")
301
+ print("")
302
+
303
+
304
+ def emit_json(obj):
305
+ print("GATES_JSON: " + json.dumps(obj, sort_keys=True))
306
+
307
+
308
+ def main(argv):
309
+ write = "--write" in argv[1:]
310
+ root = project_dir()
311
+
312
+ gates = []
313
+ stacks = []
314
+ pm = detect_node(root, gates, stacks)
315
+ detect_python(root, gates, stacks)
316
+ detect_go(root, gates, stacks)
317
+ detect_rust(root, gates, stacks)
318
+ detect_make(root, gates, stacks)
319
+
320
+ gates = dedupe_and_order(gates)
321
+ proposed, skipped = split_invocable(gates)
322
+
323
+ proposed_json = to_config_gates(proposed)
324
+ skipped_json = [
325
+ {
326
+ "name": g["name"],
327
+ "command": g["command"],
328
+ "binary": g["binary"],
329
+ "reason": "detected_but_missing_tool",
330
+ }
331
+ for g in skipped
332
+ ]
333
+
334
+ # No stack at all: leave config untouched, report, exit 0.
335
+ if not stacks:
336
+ print("no stack detected - leaving company/gates.config untouched")
337
+ emit_json(
338
+ {
339
+ "stacks": [],
340
+ "package_manager": pm,
341
+ "proposed": [],
342
+ "skipped": [],
343
+ "wrote": False,
344
+ "status": "no_stack",
345
+ }
346
+ )
347
+ return 0
348
+
349
+ print_table(proposed, skipped, pm, stacks)
350
+
351
+ status = "proposed"
352
+ wrote = False
353
+ if write:
354
+ existing = c.gates_config(root)
355
+ if config_has_real_gates(existing):
356
+ status = "preserved_existing"
357
+ print(
358
+ "company/gates.config already has real gates - preserved, "
359
+ "not overwritten."
360
+ )
361
+ elif not proposed:
362
+ status = "nothing_invocable"
363
+ print(
364
+ "no invocable gate commands on this machine - config left "
365
+ "untouched."
366
+ )
367
+ else:
368
+ path = write_config(root, proposed_json)
369
+ wrote = True
370
+ status = "wrote"
371
+ print("wrote {} gate(s) to {}".format(len(proposed_json), path))
372
+
373
+ emit_json(
374
+ {
375
+ "stacks": stacks,
376
+ "package_manager": pm,
377
+ "proposed": proposed_json,
378
+ "skipped": skipped_json,
379
+ "wrote": wrote,
380
+ "status": status,
381
+ }
382
+ )
383
+ return 0
384
+
385
+
386
+ if __name__ == "__main__":
387
+ sys.exit(main(sys.argv))