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.
- package/.claude/agents/architect.md +10 -0
- package/.claude/agents/devops-engineer.md +10 -0
- package/.claude/agents/docs-librarian.md +8 -0
- package/.claude/agents/tech-lead.md +4 -2
- package/.claude/hooks/context_pin.py +82 -0
- package/.claude/hooks/cost_capture.py +221 -0
- package/.claude/hooks/gates_detect.py +140 -4
- package/.claude/hooks/guard_commit.py +83 -6
- package/.claude/hooks/guard_frozen.py +96 -5
- package/.claude/hooks/guard_models.py +221 -0
- package/.claude/hooks/guard_provenance.py +655 -0
- package/.claude/hooks/guard_secrets.py +278 -0
- package/.claude/hooks/no_slop.py +1 -2
- package/.claude/hooks/risk_score.py +443 -0
- package/.claude/hooks/session_start.py +11 -0
- package/.claude/hooks/trace_check.py +200 -0
- package/.claude/hooks/witness_check.py +279 -0
- package/.claude/settings.json +77 -0
- package/.claude/skills/company-init/SKILL.md +7 -1
- package/.claude/skills/orchestrator/SKILL.md +6 -1
- package/.claude/skills/release/SKILL.md +106 -0
- package/.claude/skills/standup/SKILL.md +10 -0
- package/ORCHESTRATOR.md +89 -28
- package/README.md +49 -21
- package/bin/claude-company.js +43 -0
- package/company/GATES.md +40 -1
- package/company/GIT.md +36 -9
- package/company/LOOPS.md +1 -0
- package/company/METHOD.md +24 -5
- package/company/RELEASE.md +160 -0
- package/company/adr/ADR-0001-adr-system.md +50 -0
- package/company/adr/README.md +43 -0
- package/company/briefs/shipped/brief-adr-hardening.md +100 -0
- package/company/briefs/shipped/brief-cli-self-update.md +168 -0
- package/company/briefs/shipped/brief-cli-update.md +219 -0
- package/company/briefs/shipped/brief-delegation-enforcement.md +484 -0
- package/company/briefs/shipped/brief-docs-sync.md +113 -0
- package/company/briefs/shipped/brief-wave1-enforcement.md +184 -0
- package/company/briefs/shipped/brief-wave2-doctrine.md +162 -0
- package/company/briefs/shipped/brief-wave2-enforce.md +200 -0
- package/company/briefs/shipped/brief-wave3-sdlc.md +180 -0
- package/company/change-requests/CR-UPD-1-freeze-update-artifacts.md +54 -0
- package/company/frozen-surfaces.json +6 -1
- package/company/models.json +14 -0
- package/company/provenance.json +6 -0
- package/company/specs/shipped/spec-cli-self-update.md +409 -0
- package/company/specs/shipped/spec-cli-update.md +431 -0
- package/company/state/DECISIONS.md +8 -1
- package/company/state/RESUME.md +104 -8
- package/company/state/STATUS.md +16 -6
- package/company/state/WORRIES.md +11 -1
- package/company/templates/ADR-TEMPLATE.md +48 -0
- package/company/templates/POSTMORTEM-TEMPLATE.md +72 -0
- package/company/templates/RELEASE-TEMPLATE.md +94 -0
- package/company/templates/REPORT-TEMPLATE.md +12 -0
- package/company/witnesses.json +151 -0
- package/docs/customizing.md +26 -1
- package/docs/getting-started.md +25 -0
- package/docs/glossary.md +346 -0
- package/docs/how-it-works.md +42 -5
- package/install.sh +25 -0
- package/lib/MODULE.md +44 -0
- package/lib/install-tui.js +12 -0
- package/lib/manifest.py +204 -0
- package/lib/payload_paths.sh +48 -0
- package/lib/update.js +408 -0
- package/package.json +3 -2
- 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
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|