claude-company 0.1.1 → 0.2.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 (68) hide show
  1. package/.claude/agents/architect.md +10 -0
  2. package/.claude/agents/devops-engineer.md +10 -0
  3. package/.claude/agents/docs-librarian.md +8 -0
  4. package/.claude/agents/tech-lead.md +4 -2
  5. package/.claude/hooks/context_pin.py +82 -0
  6. package/.claude/hooks/cost_capture.py +221 -0
  7. package/.claude/hooks/gates_detect.py +140 -4
  8. package/.claude/hooks/guard_commit.py +83 -6
  9. package/.claude/hooks/guard_frozen.py +96 -5
  10. package/.claude/hooks/guard_models.py +221 -0
  11. package/.claude/hooks/guard_provenance.py +655 -0
  12. package/.claude/hooks/guard_secrets.py +278 -0
  13. package/.claude/hooks/no_slop.py +1 -2
  14. package/.claude/hooks/risk_score.py +443 -0
  15. package/.claude/hooks/session_start.py +11 -0
  16. package/.claude/hooks/trace_check.py +200 -0
  17. package/.claude/hooks/witness_check.py +279 -0
  18. package/.claude/settings.json +77 -0
  19. package/.claude/skills/company-init/SKILL.md +7 -1
  20. package/.claude/skills/orchestrator/SKILL.md +6 -1
  21. package/.claude/skills/release/SKILL.md +106 -0
  22. package/.claude/skills/standup/SKILL.md +10 -0
  23. package/ORCHESTRATOR.md +89 -28
  24. package/README.md +49 -21
  25. package/bin/claude-company.js +43 -0
  26. package/company/GATES.md +40 -1
  27. package/company/GIT.md +36 -9
  28. package/company/LOOPS.md +1 -0
  29. package/company/METHOD.md +24 -5
  30. package/company/RELEASE.md +160 -0
  31. package/company/adr/ADR-0001-adr-system.md +50 -0
  32. package/company/adr/README.md +43 -0
  33. package/company/briefs/shipped/brief-adr-hardening.md +100 -0
  34. package/company/briefs/shipped/brief-cli-self-update.md +168 -0
  35. package/company/briefs/shipped/brief-cli-update.md +219 -0
  36. package/company/briefs/shipped/brief-delegation-enforcement.md +484 -0
  37. package/company/briefs/shipped/brief-docs-sync.md +113 -0
  38. package/company/briefs/shipped/brief-wave1-enforcement.md +184 -0
  39. package/company/briefs/shipped/brief-wave2-doctrine.md +162 -0
  40. package/company/briefs/shipped/brief-wave2-enforce.md +200 -0
  41. package/company/briefs/shipped/brief-wave3-sdlc.md +180 -0
  42. package/company/change-requests/CR-UPD-1-freeze-update-artifacts.md +54 -0
  43. package/company/frozen-surfaces.json +6 -1
  44. package/company/models.json +14 -0
  45. package/company/provenance.json +6 -0
  46. package/company/specs/shipped/spec-cli-self-update.md +409 -0
  47. package/company/specs/shipped/spec-cli-update.md +431 -0
  48. package/company/state/DECISIONS.md +8 -1
  49. package/company/state/RESUME.md +104 -8
  50. package/company/state/STATUS.md +16 -6
  51. package/company/state/WORRIES.md +11 -1
  52. package/company/templates/ADR-TEMPLATE.md +48 -0
  53. package/company/templates/POSTMORTEM-TEMPLATE.md +72 -0
  54. package/company/templates/RELEASE-TEMPLATE.md +94 -0
  55. package/company/templates/REPORT-TEMPLATE.md +12 -0
  56. package/company/witnesses.json +151 -0
  57. package/docs/customizing.md +26 -1
  58. package/docs/getting-started.md +25 -0
  59. package/docs/glossary.md +346 -0
  60. package/docs/how-it-works.md +42 -5
  61. package/install.sh +25 -0
  62. package/lib/MODULE.md +44 -0
  63. package/lib/install-tui.js +12 -0
  64. package/lib/manifest.py +204 -0
  65. package/lib/payload_paths.sh +48 -0
  66. package/lib/update.js +408 -0
  67. package/package.json +3 -2
  68. package/update.sh +484 -0
@@ -38,6 +38,16 @@ yourself - it is frozen; hand the CEO the JSON to apply):
38
38
  5. **The gate ladder.** Propose `company/gates.config` entries per
39
39
  `company/GATES.md`, including the negative-space tests (generated
40
40
  complement of any allow-table) and the integrator-owned golden-path e2e.
41
+ 6. **Architecture decision records.** For every boundary-shaping decision in
42
+ Phase 0 or program planning - the narrow waist, a single-writer surface, a
43
+ kernel extension point, a frozen path - write a proposed ADR with
44
+ `company/templates/ADR-TEMPLATE.md` (`Status: proposed`, one decision per
45
+ record) under `company/adr/`, and keep `company/adr/README.md` index and
46
+ next-free number honest. The CEO accepts by setting `Status: accepted`;
47
+ from that moment the ADR is immutable (a guard blocks edits) and changes
48
+ only through a superseding ADR. Precedence: an accepted ADR wins on
49
+ architecture (how), the spec wins on scope (what) - so never propose an ADR
50
+ that fights an accepted one; supersede it instead.
41
51
 
42
52
  ## The solutioning gate (mandatory before any program)
43
53
 
@@ -24,6 +24,16 @@ anticlimax.
24
24
  fires on merge, never wrap deploy in a make target an agent could run.
25
25
  Deploys are manual, owner-only, forever. If asked to automate one, decline
26
26
  in your report and say why.
27
+ - **Release preparation is yours; the ship is not.** When the CEO invokes
28
+ `/release`, you assemble the release per `company/RELEASE.md` from integrated
29
+ `main` (never a worktree): the changelog derived from conventional commits and
30
+ their `Task:` trailers since the last tag, a semver bump proposal with its
31
+ one-line reasoning, release notes written as an evidence report (what shipped,
32
+ the gate ladder, known limits), and the filled `RELEASE-TEMPLATE.md`
33
+ checklist. You prepare only from a green readiness board; a red criterion is a
34
+ stop, not a footnote. Your output ends at material the CEO turns into a
35
+ proposal on `DECISIONS.md` - you never tag, push a tag, publish, or deploy,
36
+ and never write a step that does.
27
37
  - **Migrations are forward-only and immutable once shipped.** Your release
28
38
  notes state the migration order and the rollback story explicitly.
29
39
  - **Secrets never enter the repo.** Env templates (`.env.example`) yes,
@@ -30,6 +30,14 @@ code itself is a finding for the CEO, not a judgment call for you.
30
30
  accurate, `MODULE.md` tables current.
31
31
  5. Archive: shipped specs to `company/specs/shipped/`, their briefs to
32
32
  `company/briefs/shipped/`.
33
+ 6. Keep the ADR index true. After a merge, reconcile `company/adr/README.md`
34
+ with the ADRs on disk: every record indexed, every row's title and scope
35
+ accurate, the next-free number correct. Verify each accepted ADR's `Scope`
36
+ paths still exist; a scope pointing at a path the merge deleted or moved is a
37
+ finding for the CEO. You NEVER change an ADR's `Status` and never edit an
38
+ accepted ADR - both are CEO actions applied through a CR, and the guard will
39
+ block you anyway. Index and cross-references are yours; the records
40
+ themselves are not.
33
41
 
34
42
  Report: docs touched (paths), statements corrected (before -> after, the
35
43
  load-bearing ones), conflicts you could not resolve, indexes updated. Facts,
@@ -56,8 +56,10 @@ only by CR - for you and your whole team.
56
56
  explicit paths only - never `git add -A`.
57
57
  - QA evidence is committed at `company/evidence/<task-slug>/` - it ships
58
58
  with the task.
59
- - You never merge to main; the CEO integrates after verifying. Your branch
60
- green and your evidence report complete IS your handoff.
59
+ - You never merge to main and never push anything; the CEO integrates after
60
+ verifying (via a PR carrying your evidence report when the project has a
61
+ remote, via a local --no-ff merge when it does not). Your branch green and
62
+ your evidence report complete IS your handoff.
61
63
 
62
64
  ## Boundaries
63
65
 
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env python3
2
+ """UserPromptSubmit hook: inject a one-line claude-company execution pin.
3
+
4
+ Every user turn gets a compact status line drawn from the active task and the
5
+ provenance ledger - for a feature/program task: execution mode, dispatch
6
+ count, self-authored count, and (in PR mode with no tracking issues) an iss=0
7
+ drift flag; for any other type: just the counts. A second line fires only when
8
+ a feature/program task has an idle team (no execution decision, or a decision
9
+ with zero dispatches), nudging a decision or a dispatch.
10
+
11
+ Pure read: never blocks, always exits 0, fails open on any internal error. The
12
+ roster and the doctrine already live in the system prompt, so this stays
13
+ deliberately under a tight token budget - nothing beyond the pin is injected.
14
+ Python 3.8 stdlib only.
15
+ """
16
+
17
+ import os
18
+ import sys
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
+ import _common as c # noqa: E402
22
+ import guard_provenance as gp # noqa: E402
23
+
24
+ PREFIX = "[company]"
25
+ FEATURE_TYPES = ("feature", "program")
26
+ UNDECIDED = "undecided"
27
+ DRIFT_SEGMENT = " iss=0"
28
+ IDLE_LINE = "[company] team idle - decide execution / dispatch a tech-lead"
29
+
30
+
31
+ def emit(text):
32
+ # OQ-DE-01 assumption: plain stdout + exit 0 is the documented injection
33
+ # contract; a schema change is then a one-function edit.
34
+ print(text)
35
+ sys.exit(0)
36
+
37
+
38
+ def main():
39
+ payload = c.read_stdin_json()
40
+ try:
41
+ root = c.project_root(payload)
42
+ task = c.active_task(root)
43
+ if not isinstance(task, dict):
44
+ sys.exit(0) # no active task -> nothing to pin
45
+
46
+ slug = task.get("task")
47
+ ttype = task.get("type")
48
+ ledger = gp.read_ledger(root)
49
+ disp = len(ledger["dispatches"])
50
+ selfn = len(ledger["self_authored"])
51
+
52
+ if ttype in FEATURE_TYPES:
53
+ decision = gp.execution_decision(task)
54
+ line1 = "{} {} {} exec={} disp={} self={}".format(
55
+ PREFIX, slug, ttype, decision or UNDECIDED, disp, selfn
56
+ )
57
+ # FR-DE-15 drift signal: the tracking gate is armed and nothing is
58
+ # recorded (PR mode, feature/program, no valid issues).
59
+ if gp.tracking_untracked(root, task):
60
+ line1 += DRIFT_SEGMENT
61
+ drifty = decision is None or disp == 0
62
+ else:
63
+ # OQ-DE-04 assumption: ideation treated like quick - no exec, no
64
+ # iss, no idle line.
65
+ line1 = "{} {} {} disp={} self={}".format(
66
+ PREFIX, slug, ttype, disp, selfn
67
+ )
68
+ drifty = False
69
+
70
+ lines = [line1]
71
+ if drifty:
72
+ lines.append(IDLE_LINE)
73
+ emit("\n".join(lines))
74
+ except SystemExit:
75
+ raise
76
+ except Exception:
77
+ pass
78
+ sys.exit(0)
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env python3
2
+ """Stop/SubagentStop hook: log per-invocation token usage and an estimated cost.
3
+
4
+ Registered on both Stop and SubagentStop. On each invocation it reads the
5
+ session transcript (JSONL), computes the token DELTA since the last time this
6
+ session was seen (via a per-session byte-offset cursor), and appends one
7
+ greppable line to company/state/costs.log. It NEVER blocks and ALWAYS exits 0:
8
+ this is passive accounting, not enforcement. Everything fails open - on any
9
+ error the hook exits 0 silently rather than disturbing a stop.
10
+
11
+ The delta cursor is the hard invariant: tokens already attributed to a session
12
+ are never counted twice, including after a transcript compaction shrinks the
13
+ file (OQ-W1-03). Cost is an estimate only, not billing (OQ-W1-01).
14
+ """
15
+
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
+ HOOK = "cost_capture"
23
+
24
+ _ZEROS = {"in": 0, "out": 0, "cache_r": 0, "cache_w": 0}
25
+
26
+
27
+ def _event_kind(payload):
28
+ """Map the hook event name to the log column. Defaults to stop (OQ-W1-04)."""
29
+ event = payload.get("hook_event_name") or ""
30
+ if event == "SubagentStop":
31
+ return "subagent_stop"
32
+ # "Stop" and anything unexpected/missing fall through to stop.
33
+ return "stop"
34
+
35
+
36
+ def _sum_assistant(lines):
37
+ """Aggregate token usage across assistant lines in an iterable of raw bytes-decoded strings.
38
+
39
+ Returns (totals_dict, last_model). Non-parsing lines and lines lacking the
40
+ assistant message/usage shape are skipped silently (read defensively,
41
+ OQ-W1-04). A transcript is JSONL: whole objects appended one per line, so a
42
+ byte range that starts at a prior EOF only ever contains whole lines.
43
+ """
44
+ import json
45
+
46
+ totals = dict(_ZEROS)
47
+ last_model = None
48
+ for line in lines:
49
+ line = line.strip()
50
+ if not line:
51
+ continue
52
+ try:
53
+ obj = json.loads(line)
54
+ except Exception:
55
+ continue
56
+ if not isinstance(obj, dict) or obj.get("type") != "assistant":
57
+ continue
58
+ message = obj.get("message")
59
+ if not isinstance(message, dict):
60
+ continue
61
+ model = message.get("model")
62
+ usage = message.get("usage")
63
+ if not isinstance(usage, dict):
64
+ continue
65
+
66
+ def _n(key):
67
+ v = usage.get(key, 0)
68
+ return v if isinstance(v, int) else 0
69
+
70
+ totals["in"] += _n("input_tokens")
71
+ totals["out"] += _n("output_tokens")
72
+ totals["cache_w"] += _n("cache_creation_input_tokens")
73
+ totals["cache_r"] += _n("cache_read_input_tokens")
74
+ if isinstance(model, str) and model:
75
+ last_model = model
76
+ return totals, last_model
77
+
78
+
79
+ def _read_range(path, start, end):
80
+ """Decode transcript bytes [start, end) into text (utf-8, replace errors)."""
81
+ with open(path, "rb") as f:
82
+ f.seek(start)
83
+ data = f.read(end - start)
84
+ return data.decode("utf-8", "replace")
85
+
86
+
87
+ def _estimate(models_path, model, delta):
88
+ """Return an 'est=$X.XX' string, or None when no pricing applies.
89
+
90
+ Reads company/models.json 'pricing' defensively. USD per MTok. Missing rate
91
+ sub-keys default to 0. OQ-W1-01: estimate only, not billing.
92
+ """
93
+ if not model:
94
+ return None
95
+ models = c.read_json_file(models_path)
96
+ pricing = models.get("pricing") if isinstance(models, dict) else None
97
+ if not isinstance(pricing, dict):
98
+ return None
99
+ rate = None
100
+ for key, value in pricing.items():
101
+ if key == "$comment":
102
+ continue
103
+ if isinstance(key, str) and key.lower() in model.lower():
104
+ rate = value
105
+ break
106
+ if not isinstance(rate, dict):
107
+ return None
108
+
109
+ def _r(name):
110
+ v = rate.get(name, 0)
111
+ return v if isinstance(v, (int, float)) else 0
112
+
113
+ est = (
114
+ delta["in"] * _r("input")
115
+ + delta["out"] * _r("output")
116
+ + delta["cache_w"] * _r("cache_write")
117
+ + delta["cache_r"] * _r("cache_read")
118
+ ) / 1000000.0
119
+ return "est=$%.2f" % est
120
+
121
+
122
+ def main():
123
+ payload = c.read_stdin_json()
124
+ if payload is None:
125
+ sys.exit(0)
126
+
127
+ try:
128
+ import json
129
+
130
+ root = c.project_root(payload)
131
+ kind = _event_kind(payload)
132
+
133
+ transcript_path = payload.get("transcript_path")
134
+ session_id = payload.get("session_id")
135
+ # OQ-W1-03 fail open: no transcript or session, no unreadable file -> no line.
136
+ if not transcript_path or not session_id:
137
+ sys.exit(0)
138
+ if not os.path.isfile(transcript_path):
139
+ sys.exit(0)
140
+
141
+ session8 = session_id[:8]
142
+
143
+ state_dir = os.path.join(root, "company", "state")
144
+ cursor_path = os.path.join(state_dir, ".cost-cursor.json")
145
+ costs_path = os.path.join(state_dir, "costs.log")
146
+ models_path = os.path.join(root, "company", "models.json")
147
+
148
+ cursor = c.read_json_file(cursor_path)
149
+ if not isinstance(cursor, dict):
150
+ cursor = {}
151
+ entry = cursor.get(session_id)
152
+ if not isinstance(entry, dict):
153
+ entry = {"offset": 0, "cum": dict(_ZEROS)}
154
+ prev_offset = entry.get("offset", 0)
155
+ if not isinstance(prev_offset, int) or prev_offset < 0:
156
+ prev_offset = 0
157
+ prev_cum = entry.get("cum")
158
+ if not isinstance(prev_cum, dict):
159
+ prev_cum = dict(_ZEROS)
160
+ prev_cum = {k: (prev_cum.get(k, 0) if isinstance(prev_cum.get(k, 0), int)
161
+ else 0) for k in _ZEROS}
162
+
163
+ size = os.path.getsize(transcript_path)
164
+
165
+ if size >= prev_offset:
166
+ # NORMAL: only the freshly-appended byte range is new.
167
+ text = _read_range(transcript_path, prev_offset, size)
168
+ totals, model = _sum_assistant(text.splitlines())
169
+ delta = dict(totals)
170
+ new_offset = size
171
+ new_cum = {k: prev_cum[k] + delta[k] for k in _ZEROS}
172
+ else:
173
+ # COMPACTION: the transcript shrank. Re-read the whole file and take
174
+ # per-key max(0, file_total - cum). Surviving lines were already
175
+ # counted, so this delta is ~0 - the guard against double-counting.
176
+ text = _read_range(transcript_path, 0, size)
177
+ file_total, model = _sum_assistant(text.splitlines())
178
+ delta = {k: max(0, file_total[k] - prev_cum[k]) for k in _ZEROS}
179
+ new_offset = size
180
+ new_cum = {k: max(prev_cum[k], file_total[k]) for k in _ZEROS}
181
+
182
+ cursor[session_id] = {"offset": new_offset, "cum": new_cum}
183
+
184
+ os.makedirs(state_dir, exist_ok=True)
185
+ with open(cursor_path, "w") as f:
186
+ json.dump(cursor, f)
187
+
188
+ total_delta = delta["in"] + delta["out"] + delta["cache_r"] + delta["cache_w"]
189
+ # Log only genuine assistant activity: a model was found and the delta
190
+ # carries tokens. Empty deltas (no new assistant messages, or a
191
+ # compaction that added nothing) persist the cursor but write no line.
192
+ if model is None or total_delta <= 0:
193
+ sys.exit(0)
194
+
195
+ task = (c.active_task(root) or {})
196
+ task_slug = task.get("task") if isinstance(task, dict) else None
197
+ task_slug = task_slug or "-"
198
+
199
+ tokens = "in=%d out=%d cache_r=%d cache_w=%d" % (
200
+ delta["in"], delta["out"], delta["cache_r"], delta["cache_w"]
201
+ )
202
+ line = "%s | %s | %s | %s | %s | %s" % (
203
+ c.iso_now(), session8, kind, task_slug, model, tokens
204
+ )
205
+ est = _estimate(models_path, model, delta)
206
+ if est is not None:
207
+ line += " | " + est
208
+ line += "\n"
209
+
210
+ with open(costs_path, "a") as f:
211
+ f.write(line)
212
+ except SystemExit:
213
+ raise
214
+ except Exception:
215
+ sys.exit(0)
216
+
217
+ sys.exit(0)
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()
@@ -35,6 +35,48 @@ ORDER = {"lint": 0, "typecheck": 1, "tests": 2, "build": 3, "other": 4}
35
35
 
36
36
  PLACEHOLDER_MARK = "configure me"
37
37
 
38
+ # Stack-independent gate: guard_models --check needs only python3, which the
39
+ # hooks already require, so it is proposed for every project (including a
40
+ # no-stack repo) and ordered before the test gates (it is cheap).
41
+ MODELS_GATE_JSON = {
42
+ "name": "models",
43
+ "command": "python3 .claude/hooks/guard_models.py --check",
44
+ "blocking": True,
45
+ }
46
+ MODELS_GATE_DISPLAY = {
47
+ "name": "models",
48
+ "command": MODELS_GATE_JSON["command"],
49
+ "binary": "python3",
50
+ }
51
+
52
+ # Verification gates (wave 2). Both need only python3, which the hooks already
53
+ # require. witnesses rides FIRST (ahead of models) whenever a seeded registry
54
+ # exists; trace rides after the stack gates (which include tests) and before
55
+ # the audit gate, whenever a spec with FR IDs exists.
56
+ WITNESSES_GATE_JSON = {
57
+ "name": "witnesses",
58
+ "command": "python3 .claude/hooks/witness_check.py",
59
+ "blocking": True,
60
+ }
61
+ WITNESSES_GATE_DISPLAY = {
62
+ "name": "witnesses",
63
+ "command": WITNESSES_GATE_JSON["command"],
64
+ "binary": "python3",
65
+ }
66
+ TRACE_GATE_JSON = {
67
+ "name": "trace",
68
+ "command": "python3 .claude/hooks/trace_check.py",
69
+ "blocking": True,
70
+ }
71
+ TRACE_GATE_DISPLAY = {
72
+ "name": "trace",
73
+ "command": TRACE_GATE_JSON["command"],
74
+ "binary": "python3",
75
+ }
76
+
77
+ # A requirement id looks like FR-AUTH-01 / BR-PAY-3, or the short FR-03 form.
78
+ FR_ID_RE = re.compile(r"\b(?:FR|BR)-[A-Z0-9]+-\d+\b|\bFR-\d+\b")
79
+
38
80
 
39
81
  def project_dir():
40
82
  root = os.environ.get("CLAUDE_PROJECT_DIR")
@@ -305,6 +347,66 @@ def emit_json(obj):
305
347
  print("GATES_JSON: " + json.dumps(obj, sort_keys=True))
306
348
 
307
349
 
350
+ def has_witnesses(root):
351
+ """True when company/witnesses.json exists with >= 1 registered witness."""
352
+ data = c.read_json_file(os.path.join(root, "company", "witnesses.json"))
353
+ if not isinstance(data, dict):
354
+ return False
355
+ w = data.get("witnesses")
356
+ return isinstance(w, list) and len(w) >= 1
357
+
358
+
359
+ def has_fr_spec(root):
360
+ """True when a company/specs/*.md file carries at least one FR/BR id."""
361
+ specs_dir = os.path.join(root, "company", "specs")
362
+ if not os.path.isdir(specs_dir):
363
+ return False
364
+ try:
365
+ names = os.listdir(specs_dir)
366
+ except Exception:
367
+ return False
368
+ for name in names:
369
+ if not name.endswith(".md"):
370
+ continue
371
+ if FR_ID_RE.search(read_text(os.path.join(specs_dir, name))):
372
+ return True
373
+ return False
374
+
375
+
376
+ def audit_candidates(pm, stacks):
377
+ """CVE-audit gate candidates, one per detected stack. Always ordered last.
378
+
379
+ Node command varies by package manager; python audit uses pip-audit and is
380
+ only proposed when the tool is on PATH (the split_invocable idiom).
381
+ """
382
+ out = []
383
+ if pm:
384
+ if pm == "pnpm":
385
+ cmd = "pnpm audit --prod --audit-level=high"
386
+ elif pm == "yarn":
387
+ cmd = "yarn npm audit --severity high"
388
+ else:
389
+ cmd = "npm audit --omit=dev --audit-level=high"
390
+ out.append({"name": "audit", "command": cmd, "binary": pm})
391
+ if "python" in stacks:
392
+ out.append(
393
+ {"name": "audit", "command": "pip-audit", "binary": "pip-audit"}
394
+ )
395
+ # Unique-ify names if a repo carries more than one stack (audit, audit-2).
396
+ seen = {}
397
+ result = []
398
+ for g in out:
399
+ n = g["name"]
400
+ if n in seen:
401
+ seen[n] += 1
402
+ g = dict(g)
403
+ g["name"] = "{}-{}".format(n, seen[n])
404
+ else:
405
+ seen[n] = 1
406
+ result.append(g)
407
+ return result
408
+
409
+
308
410
  def main(argv):
309
411
  write = "--write" in argv[1:]
310
412
  root = project_dir()
@@ -318,9 +420,41 @@ def main(argv):
318
420
  detect_make(root, gates, stacks)
319
421
 
320
422
  gates = dedupe_and_order(gates)
321
- proposed, skipped = split_invocable(gates)
423
+ stack_proposed, stack_skipped = split_invocable(gates)
424
+
425
+ # Front verification gates (stack-independent, python3 only): witnesses
426
+ # first (when a seeded registry exists), then models. Both ride ahead of
427
+ # the stack gates.
428
+ front_display = []
429
+ front_json = []
430
+ if has_witnesses(root):
431
+ front_display.append(dict(WITNESSES_GATE_DISPLAY))
432
+ front_json.append(dict(WITNESSES_GATE_JSON))
433
+ front_display.append(dict(MODELS_GATE_DISPLAY))
434
+ front_json.append(dict(MODELS_GATE_JSON))
435
+
436
+ # Trace rides after the stack gates (which include tests) and before audit,
437
+ # only when a spec with FR ids is present.
438
+ tail_display = []
439
+ tail_json = []
440
+ if has_fr_spec(root):
441
+ tail_display.append(dict(TRACE_GATE_DISPLAY))
442
+ tail_json.append(dict(TRACE_GATE_JSON))
443
+
444
+ # The CVE audit gate is ALWAYS last; the pip-audit variant is skipped when
445
+ # the tool is not on PATH (split_invocable idiom).
446
+ audit_proposed, audit_skipped = split_invocable(
447
+ audit_candidates(pm, stacks)
448
+ )
322
449
 
323
- proposed_json = to_config_gates(proposed)
450
+ proposed = front_display + stack_proposed + tail_display + audit_proposed
451
+ proposed_json = (
452
+ front_json
453
+ + to_config_gates(stack_proposed)
454
+ + tail_json
455
+ + to_config_gates(audit_proposed)
456
+ )
457
+ skipped = stack_skipped + audit_skipped
324
458
  skipped_json = [
325
459
  {
326
460
  "name": g["name"],
@@ -331,14 +465,16 @@ def main(argv):
331
465
  for g in skipped
332
466
  ]
333
467
 
334
- # No stack at all: leave config untouched, report, exit 0.
468
+ # No stack at all: leave config untouched, report, exit 0. The
469
+ # stack-independent gates (witnesses when seeded, models, and trace when a
470
+ # spec exists) are still surfaced in the proposal so the CEO can see them.
335
471
  if not stacks:
336
472
  print("no stack detected - leaving company/gates.config untouched")
337
473
  emit_json(
338
474
  {
339
475
  "stacks": [],
340
476
  "package_manager": pm,
341
- "proposed": [],
477
+ "proposed": front_json + tail_json,
342
478
  "skipped": [],
343
479
  "wrote": False,
344
480
  "status": "no_stack",
@@ -3,9 +3,19 @@
3
3
 
4
4
  - push to protected branch (main/master), explicit or bare push while on it:
5
5
  BLOCK (owner-only).
6
+ - commit on a protected branch (main/master) while a task is active:
7
+ BLOCK with a task-branch recipe (all work happens on task branches).
8
+ Hotfix tasks are exempt (ALLOW + log BYPASS); a commit with no active
9
+ task is a founding commit and is exempt; merge on main is the owner's
10
+ local integration and is exempt.
6
11
  - 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.
12
+ gates.config is missing, has zero gates, or contains ONLY CONFIGURE-ME
13
+ placeholders (a fresh project with nothing to gate yet), ALLOW + log
14
+ BYPASS - unconfigured gates must not deadlock founding commits, and the
15
+ bypass stays visible in the adherence log. Placeholders still fail loudly
16
+ in run-gates.sh and still block task completion via stop_gate; only the
17
+ commit path treats them as not-yet-configured. If the active task is a
18
+ hotfix, ALLOW + log BYPASS.
9
19
  - everything else: allow.
10
20
 
11
21
  Fails open on any internal error.
@@ -44,7 +54,26 @@ def git_subcmd(segment):
44
54
  return toks[i], toks[i + 1:]
45
55
 
46
56
 
47
- def push_targets_protected(root, args):
57
+ def git_cwd(payload, root):
58
+ """Resolve the directory the git command actually runs in (#26).
59
+
60
+ The branch checks must reflect the working tree git operates on, not the
61
+ project root. A commit issued from a worktree checkout of a task branch
62
+ must be judged by that worktree's branch, even when CLAUDE_PROJECT_DIR (and
63
+ thus root) points at the main checkout on a protected branch. Prefer the
64
+ payload's cwd when it is present and inside a git work tree; otherwise fall
65
+ back to root.
66
+ """
67
+ if isinstance(payload, dict):
68
+ cwd = payload.get("cwd")
69
+ if cwd:
70
+ out = c._git(cwd, ["rev-parse", "--is-inside-work-tree"])
71
+ if out is not None and out.strip() == "true":
72
+ return cwd
73
+ return root
74
+
75
+
76
+ def push_targets_protected(branch_dir, args):
48
77
  non_opt = [a for a in args if not a.startswith("-")]
49
78
  # first non-option is the remote; the rest are refspecs
50
79
  refspecs = non_opt[1:]
@@ -53,7 +82,7 @@ def push_targets_protected(root, args):
53
82
  if dst in PROTECTED:
54
83
  return True
55
84
  if not refspecs:
56
- cur = c.current_branch(root)
85
+ cur = c.current_branch(branch_dir)
57
86
  if cur in PROTECTED:
58
87
  return True
59
88
  return False
@@ -71,6 +100,9 @@ def main():
71
100
  if not command:
72
101
  sys.exit(0)
73
102
 
103
+ # #26: branch checks must reflect the tree the git command runs in.
104
+ branch_dir = git_cwd(payload, root)
105
+
74
106
  try:
75
107
  for seg in segments(command):
76
108
  sub, args = git_subcmd(seg)
@@ -78,7 +110,7 @@ def main():
78
110
  continue
79
111
 
80
112
  if sub == "push":
81
- if push_targets_protected(root, args):
113
+ if push_targets_protected(branch_dir, args):
82
114
  c.block(
83
115
  root, HOOK, "git push", "protected branch push",
84
116
  "BLOCKED: push to a protected branch (main/master) is "
@@ -88,15 +120,60 @@ def main():
88
120
 
89
121
  if sub in ("commit", "merge"):
90
122
  task = c.active_task(root)
123
+
124
+ # All work happens on task branches. A plain commit on a
125
+ # protected branch while a task is active is misplaced work.
126
+ # (merge is the owner's local integration and is exempt; a
127
+ # commit with NO active task is a founding commit and is
128
+ # exempt.) Branch message wins over the gate-stamp checks
129
+ # below. Fail open when the branch is unknown.
130
+ if sub == "commit" and isinstance(task, dict):
131
+ branch = c.current_branch(branch_dir)
132
+ if branch in PROTECTED:
133
+ if task.get("type") == "hotfix":
134
+ c.log_bypass(
135
+ root, HOOK, "git commit",
136
+ "hotfix commit on protected branch",
137
+ )
138
+ continue
139
+ slug = task.get("task") or "<task-slug>"
140
+ c.block(
141
+ root, HOOK, "git commit",
142
+ "commit on protected branch",
143
+ "BLOCKED: work belongs on a task branch, never "
144
+ "directly on main.\n"
145
+ "Create the isolated task branch and commit "
146
+ "there:\n"
147
+ " git worktree add .claude/worktrees/{slug} "
148
+ "-b task/{slug}\n"
149
+ "or, if you are already in the right working "
150
+ "tree:\n"
151
+ " git switch -c task/{slug}\n"
152
+ "then retry your commit on that branch.\n"
153
+ "If this task is finished and you are "
154
+ "integrating, use git merge (allowed on main) "
155
+ "- see company/GIT.md.".format(slug=slug),
156
+ )
157
+
91
158
  if isinstance(task, dict) and task.get("type") == "hotfix":
92
159
  c.log_bypass(root, HOOK, "git " + sub, "hotfix mode")
93
160
  continue
94
161
  cfg = c.gates_config(root)
95
- if not isinstance(cfg, dict) or not cfg.get("gates"):
162
+ gates = cfg.get("gates") if isinstance(cfg, dict) else None
163
+ if not gates:
96
164
  c.log_bypass(
97
165
  root, HOOK, "git " + sub, "no gates configured"
98
166
  )
99
167
  continue
168
+ if all(
169
+ "CONFIGURE ME" in (g.get("command") or "")
170
+ for g in gates if isinstance(g, dict)
171
+ ):
172
+ c.log_bypass(
173
+ root, HOOK, "git " + sub,
174
+ "gates.config has only CONFIGURE-ME placeholders",
175
+ )
176
+ continue
100
177
  ok, reason = c.check_stamp(root)
101
178
  if not ok:
102
179
  c.block(