cohorte 2.10.1 → 3.0.0-dev.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/README.md +7 -0
- package/assets/manifest.json +193 -0
- package/assets/migrations/config/README.md +12 -0
- package/assets/migrations/state/0001_init.sql +154 -0
- package/assets/prompts/README.md +11 -0
- package/assets/prompts/agents/fixer.md +7 -0
- package/assets/prompts/agents/implementer.md +12 -0
- package/assets/prompts/agents/reviewer.md +8 -0
- package/assets/prompts/agents/security-reviewer.md +7 -0
- package/assets/prompts/phases/build.md +5 -0
- package/assets/prompts/phases/review.md +5 -0
- package/assets/prompts/phases/test.md +5 -0
- package/assets/prompts/system.md +11 -0
- package/assets/schemas/agent-output.schema.json +225 -0
- package/assets/schemas/auth-status.schema.json +87 -0
- package/assets/schemas/command-result.schema.json +192 -0
- package/assets/schemas/commands.schema.json +952 -0
- package/assets/schemas/config.schema.json +2259 -0
- package/assets/schemas/doctor-report.schema.json +104 -0
- package/assets/schemas/events.schema.json +8514 -0
- package/assets/schemas/fake-script.schema.json +549 -0
- package/assets/schemas/inspect.schema.json +3133 -0
- package/assets/schemas/manifest.schema.json +79 -0
- package/assets/schemas/ownership.schema.json +52 -0
- package/assets/schemas/policy-verdict.schema.json +282 -0
- package/assets/schemas/project-model.schema.json +783 -0
- package/assets/schemas/project-status.schema.json +217 -0
- package/assets/schemas/reconcile-plan.schema.json +155 -0
- package/assets/schemas/run-diff.schema.json +177 -0
- package/assets/schemas/run-snapshot-manifest.schema.json +2104 -0
- package/assets/schemas/run-state.schema.json +1755 -0
- package/assets/schemas/runtime-capabilities.schema.json +1545 -0
- package/assets/schemas/sandbox-capabilities.schema.json +116 -0
- package/assets/schemas/skill.schema.json +87 -0
- package/assets/schemas/spec.schema.json +202 -0
- package/assets/schemas/tool-catalogue.schema.json +655 -0
- package/assets/schemas/trust-record.schema.json +38 -0
- package/assets/skills/README.md +9 -0
- package/assets/skills/inspect-surface.md +4 -0
- package/dist/agent-host.mjs +50 -0
- package/dist/agent-host.mjs.map +1 -0
- package/dist/bundle-manifest.json +102 -0
- package/dist/chunks/__host-DgGPyco9.mjs +18 -0
- package/dist/chunks/__host-DgGPyco9.mjs.map +1 -0
- package/dist/chunks/approve-Cv135UqI.mjs +19 -0
- package/dist/chunks/approve-Cv135UqI.mjs.map +1 -0
- package/dist/chunks/auth-BT5oVp76.mjs +132 -0
- package/dist/chunks/auth-BT5oVp76.mjs.map +1 -0
- package/dist/chunks/auth-D8rCVY33.mjs +84 -0
- package/dist/chunks/auth-D8rCVY33.mjs.map +1 -0
- package/dist/chunks/brainstorm-CU10T1tm.mjs +7 -0
- package/dist/chunks/brainstorm-CU10T1tm.mjs.map +1 -0
- package/dist/chunks/cancel-KV_cpsqS.mjs +15 -0
- package/dist/chunks/cancel-KV_cpsqS.mjs.map +1 -0
- package/dist/chunks/canonical-C8UxeEmB.mjs +57 -0
- package/dist/chunks/canonical-C8UxeEmB.mjs.map +1 -0
- package/dist/chunks/catalogue-BvwSrM_j.mjs +1549 -0
- package/dist/chunks/catalogue-BvwSrM_j.mjs.map +1 -0
- package/dist/chunks/compose-Cr0hP0qm.mjs +7303 -0
- package/dist/chunks/compose-Cr0hP0qm.mjs.map +1 -0
- package/dist/chunks/config-DefHA2Lw.mjs +96 -0
- package/dist/chunks/config-DefHA2Lw.mjs.map +1 -0
- package/dist/chunks/contract-B0ra2-bm.mjs +295 -0
- package/dist/chunks/contract-B0ra2-bm.mjs.map +1 -0
- package/dist/chunks/contract-vYbwYr0Y.mjs +123 -0
- package/dist/chunks/contract-vYbwYr0Y.mjs.map +1 -0
- package/dist/chunks/deny-ClTxSvHJ.mjs +15 -0
- package/dist/chunks/deny-ClTxSvHJ.mjs.map +1 -0
- package/dist/chunks/diff-Qw0II0sj.mjs +101 -0
- package/dist/chunks/diff-Qw0II0sj.mjs.map +1 -0
- package/dist/chunks/discover-30Ciya_0.mjs +20 -0
- package/dist/chunks/discover-30Ciya_0.mjs.map +1 -0
- package/dist/chunks/doctor-DfCrKpfT.mjs +222 -0
- package/dist/chunks/doctor-DfCrKpfT.mjs.map +1 -0
- package/dist/chunks/fix-D4Tj8RVA.mjs +18 -0
- package/dist/chunks/fix-D4Tj8RVA.mjs.map +1 -0
- package/dist/chunks/gc-BBXlBF5w.mjs +161 -0
- package/dist/chunks/gc-BBXlBF5w.mjs.map +1 -0
- package/dist/chunks/init-Okz4mlhW.mjs +30 -0
- package/dist/chunks/init-Okz4mlhW.mjs.map +1 -0
- package/dist/chunks/inspect-Buby4cQD.mjs +22 -0
- package/dist/chunks/inspect-Buby4cQD.mjs.map +1 -0
- package/dist/chunks/logs-Giq5sUau.mjs +25 -0
- package/dist/chunks/logs-Giq5sUau.mjs.map +1 -0
- package/dist/chunks/migrate-DRoFUg53.mjs +19 -0
- package/dist/chunks/migrate-DRoFUg53.mjs.map +1 -0
- package/dist/chunks/models-Btp6X2YZ.mjs +24 -0
- package/dist/chunks/models-Btp6X2YZ.mjs.map +1 -0
- package/dist/chunks/pause-BASBfKLO.mjs +16 -0
- package/dist/chunks/pause-BASBfKLO.mjs.map +1 -0
- package/dist/chunks/policy-DUIgzF3W.mjs +139 -0
- package/dist/chunks/policy-DUIgzF3W.mjs.map +1 -0
- package/dist/chunks/providers-Dx78imKj.mjs +19 -0
- package/dist/chunks/providers-Dx78imKj.mjs.map +1 -0
- package/dist/chunks/reconcile-BfD785gd.mjs +26 -0
- package/dist/chunks/reconcile-BfD785gd.mjs.map +1 -0
- package/dist/chunks/render-DyZr5Lgi.mjs +53 -0
- package/dist/chunks/render-DyZr5Lgi.mjs.map +1 -0
- package/dist/chunks/resume-GJQRb2Eb.mjs +15 -0
- package/dist/chunks/resume-GJQRb2Eb.mjs.map +1 -0
- package/dist/chunks/retry-CLrUem1H.mjs +15 -0
- package/dist/chunks/retry-CLrUem1H.mjs.map +1 -0
- package/dist/chunks/review-BDdjb6kB.mjs +17 -0
- package/dist/chunks/review-BDdjb6kB.mjs.map +1 -0
- package/dist/chunks/run-CgTWYlcd.mjs +82 -0
- package/dist/chunks/run-CgTWYlcd.mjs.map +1 -0
- package/dist/chunks/run-tool-C3rAv4XF.mjs +26 -0
- package/dist/chunks/run-tool-C3rAv4XF.mjs.map +1 -0
- package/dist/chunks/schema-C9QbUW4H.mjs +578 -0
- package/dist/chunks/schema-C9QbUW4H.mjs.map +1 -0
- package/dist/chunks/send-BdnJ85Nz.mjs +19 -0
- package/dist/chunks/send-BdnJ85Nz.mjs.map +1 -0
- package/dist/chunks/ship-DTuLGoaJ.mjs +19 -0
- package/dist/chunks/ship-DTuLGoaJ.mjs.map +1 -0
- package/dist/chunks/shutdown-BM92Iyv2.mjs +15 -0
- package/dist/chunks/shutdown-BM92Iyv2.mjs.map +1 -0
- package/dist/chunks/skip-DZG5kpcH.mjs +18 -0
- package/dist/chunks/skip-DZG5kpcH.mjs.map +1 -0
- package/dist/chunks/spec-Bq0P3jVh.mjs +20 -0
- package/dist/chunks/spec-Bq0P3jVh.mjs.map +1 -0
- package/dist/chunks/src-CId6kMsY.mjs +1689 -0
- package/dist/chunks/src-CId6kMsY.mjs.map +1 -0
- package/dist/chunks/src-CfAOde7C.mjs +5403 -0
- package/dist/chunks/src-CfAOde7C.mjs.map +1 -0
- package/dist/chunks/src-Cu3TxNob.mjs +783 -0
- package/dist/chunks/src-Cu3TxNob.mjs.map +1 -0
- package/dist/chunks/src-DcU4IXMe.mjs +117 -0
- package/dist/chunks/src-DcU4IXMe.mjs.map +1 -0
- package/dist/chunks/src-JxawBQBn.mjs +446 -0
- package/dist/chunks/src-JxawBQBn.mjs.map +1 -0
- package/dist/chunks/src-s7YRvk0t.mjs +2116 -0
- package/dist/chunks/src-s7YRvk0t.mjs.map +1 -0
- package/dist/chunks/status-SMjiOMYy.mjs +29 -0
- package/dist/chunks/status-SMjiOMYy.mjs.map +1 -0
- package/dist/chunks/tail-BuhT-2PH.mjs +25 -0
- package/dist/chunks/tail-BuhT-2PH.mjs.map +1 -0
- package/dist/chunks/update-BXIppsbi.mjs +16 -0
- package/dist/chunks/update-BXIppsbi.mjs.map +1 -0
- package/dist/cli.mjs +175 -0
- package/dist/cli.mjs.map +1 -0
- package/package.json +17 -32
- package/CHANGELOG.md +0 -1461
- package/bin/cli.js +0 -817
- package/bin/report.js +0 -141
- package/core/adapter/render.js +0 -415
- package/core/agents/implementer.template.md +0 -142
- package/core/agents/profile-reader.md +0 -50
- package/core/agents/release.md +0 -58
- package/core/agents/review.md +0 -179
- package/core/commands/cohorte-align-ds.md +0 -33
- package/core/commands/cohorte-audit.md +0 -58
- package/core/commands/cohorte-brainstorm.md +0 -70
- package/core/commands/cohorte-build.md +0 -219
- package/core/commands/cohorte-doctor.md +0 -194
- package/core/commands/cohorte-fix.md +0 -80
- package/core/commands/cohorte-fleet.md +0 -103
- package/core/commands/cohorte-init-pipeline.md +0 -31
- package/core/commands/cohorte-intake.md +0 -92
- package/core/commands/cohorte-patch.md +0 -118
- package/core/commands/cohorte-refactor.md +0 -59
- package/core/commands/cohorte-retro.md +0 -85
- package/core/commands/cohorte-review.md +0 -270
- package/core/commands/cohorte-ship.md +0 -122
- package/core/commands/cohorte-spec.md +0 -101
- package/core/commands/cohorte-update-pipeline.md +0 -165
- package/core/hooks/gate.py +0 -588
- package/core/runtimes/claude.json +0 -74
- package/core/runtimes/codex.json +0 -85
- package/core/runtimes/cursor.json +0 -76
- package/core/runtimes/gemini.json +0 -76
- package/core/runtimes/opencode.json +0 -73
- package/core/templates/agent-handoff.md +0 -34
- package/core/templates/brainstorm-return.md +0 -36
- package/core/templates/decisions.template.md +0 -42
- package/core/templates/design-brief.md +0 -44
- package/core/templates/patch.template.md +0 -86
- package/core/templates/pr-body.md +0 -25
- package/core/templates/review-feedback.md +0 -38
- package/core/templates/spec.template.md +0 -87
- package/core/templates/steps/init-pipeline/01-detect-stack.md +0 -44
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +0 -55
- package/core/templates/steps/init-pipeline/03-draft-profile.md +0 -10
- package/core/templates/steps/init-pipeline/04-write-render.md +0 -145
- package/core/templates/steps/init-pipeline/05-report.md +0 -12
- package/core/workflows/audit.js +0 -240
- package/core/workflows/loop.js +0 -641
- package/core/workflows/refactor.js +0 -276
- package/core/workflows/review.js +0 -456
- package/install.ps1 +0 -103
- package/install.sh +0 -114
- package/lib/doctor.js +0 -534
- package/lib/runtime.js +0 -139
- package/lib/versions.js +0 -149
- package/lib/yaml.js +0 -129
- package/profile/PIPELINE.template.md +0 -259
- package/profile/SCHEMA.md +0 -735
- package/profile/cohorte.config.template.yaml +0 -43
- package/scripts/assert-gate-hook.mjs +0 -75
- package/scripts/kanban-move.sh +0 -313
- package/scripts/metrics/collect.mjs +0 -531
- package/scripts/metrics/prices.json +0 -42
- package/scripts/new-feature.sh.template +0 -98
- package/scripts/preflight.sh +0 -124
- package/scripts/remove-feature.sh.template +0 -56
- package/scripts/test-adapter.mjs +0 -437
- package/scripts/test-gate.mjs +0 -443
- package/scripts/test-kanban.mjs +0 -304
- package/scripts/test-lib.mjs +0 -356
- package/scripts/test-metrics.mjs +0 -156
- package/scripts/test-workflows.mjs +0 -758
- package/scripts/validate-core.mjs +0 -325
package/core/hooks/gate.py
DELETED
|
@@ -1,588 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse gate — generic, profile-driven.
|
|
3
|
-
|
|
4
|
-
Forces a confirmation prompt (or hard-denies) on dangerous Bash commands,
|
|
5
|
-
inspecting the FULL command string so chained forms like
|
|
6
|
-
`cd apps/api && node ace migration:run` are caught — something prefix-based
|
|
7
|
-
permission rules in settings.json miss. Branch-conditional patterns follow that
|
|
8
|
-
same chain: a leading `cd <dir>` moves where the branch is resolved, so
|
|
9
|
-
`cd <other repo> && git commit` is judged against the checkout it lands in.
|
|
10
|
-
|
|
11
|
-
Patterns come from `.claude/gate-config.json` (generated by /cohorte-init-pipeline from
|
|
12
|
-
the PIPELINE.md `gate` block):
|
|
13
|
-
|
|
14
|
-
{ "deny": ["node ace migration:fresh", ...],
|
|
15
|
-
"ask": ["git commit", "git push", "node ace migration:run", ...] }
|
|
16
|
-
|
|
17
|
-
A pattern matches a command segment when the segment *contains* the pattern
|
|
18
|
-
(after normalizing whitespace). `deny` wins over `ask`. If the config is missing
|
|
19
|
-
or unreadable, the hook stays silent (exit 0) and lets settings.json decide.
|
|
20
|
-
|
|
21
|
-
The hook fires for EVERY agent in the session — the lead, /cohorte-build's implementers,
|
|
22
|
-
and subagents spawned by the Workflow runtime alike. Workflow subagents run in
|
|
23
|
-
acceptEdits regardless of the session's permission mode (their Write/Edit calls
|
|
24
|
-
are auto-approved), but acceptEdits does NOT auto-approve Bash or Task, so this
|
|
25
|
-
gate still sees and can block them. In bypassPermissions (headless `claude -p`,
|
|
26
|
-
unattended workflow runs) there is no human to answer a prompt, so every `ask` match is
|
|
27
|
-
escalated to a hard deny with the reason attached — a clear refusal beats a
|
|
28
|
-
prompt that can never be answered.
|
|
29
|
-
|
|
30
|
-
Two extra duties beyond Bash patterns:
|
|
31
|
-
|
|
32
|
-
- Phase gate (`preflight` block in gate-config.json): a Task dispatch of a
|
|
33
|
-
listed subagent_type (default review) requires a fresh
|
|
34
|
-
`.claude/preflight.ok` stamp, written by pipeline/scripts/preflight.sh when
|
|
35
|
-
typecheck+lint+tests are green. Stale/missing stamp => "ask" — dispatching
|
|
36
|
-
reviewers onto code that doesn't compile burns their whole run. Freshness is
|
|
37
|
-
a content digest (see tree_digest), not the HEAD sha: the reviewed tree is
|
|
38
|
-
normally dirty, so a commit must not invalidate a stamp and an uncommitted
|
|
39
|
-
edit must. The stamp is local and gitignored — never versioned.
|
|
40
|
-
|
|
41
|
-
Protocol: reads the PreToolUse payload on stdin; emits a JSON permissionDecision
|
|
42
|
-
of "deny" or "ask" on a match; otherwise exits 0 silently.
|
|
43
|
-
|
|
44
|
-
Four runtimes can run this as a real blocking hook, and they disagree on the envelope —
|
|
45
|
-
`--runtime <id>` (passed by the installer when it registers the hook) selects the dialect:
|
|
46
|
-
|
|
47
|
-
claude PreToolUse {"hookSpecificOutput": {"permissionDecision": "deny"|"ask", …}}
|
|
48
|
-
codex PreToolUse same envelope, but `ask` is parsed and NOT honoured
|
|
49
|
-
cursor beforeShellExecution {"permission": "deny"|"ask", "user_message", "agent_message"}
|
|
50
|
-
gemini BeforeTool {"decision": "deny", "reason": …} (no ask tier)
|
|
51
|
-
|
|
52
|
-
Where a runtime has no `ask`, an `ask` verdict is escalated to `deny` with the reason
|
|
53
|
-
attached rather than downgraded to allow: the whole point of the tier is that a human sees
|
|
54
|
-
the command before it runs, and a runtime that cannot ask cannot deliver that — so the safe
|
|
55
|
-
translation is to refuse and let the human re-run it deliberately. Same rule the
|
|
56
|
-
bypassPermissions escalation has always used.
|
|
57
|
-
|
|
58
|
-
Explicit mode (`--check`) — for OpenCode, whose extension point is plugins rather than a
|
|
59
|
-
blocking hook. Same config, same patterns, same verdicts; the difference is who calls it.
|
|
60
|
-
There the rendered commands instruct the agent to run the check itself before a gated
|
|
61
|
-
command or a phase dispatch:
|
|
62
|
-
|
|
63
|
-
gate.py --check "git push --force" # a Bash command
|
|
64
|
-
gate.py --check-dispatch review # the preflight phase gate
|
|
65
|
-
|
|
66
|
-
It prints ONE line (`gate: allow` / `gate: ask — <reason>` / `gate: deny — <reason>`)
|
|
67
|
-
and exits 0 / 1 / 2 so a shell can branch on it. This is advisory by construction — an
|
|
68
|
-
agent can decline to call it, which a hook makes impossible — so /cohorte-doctor reports
|
|
69
|
-
the gate as "advisory" on those runtimes rather than pretending parity.
|
|
70
|
-
"""
|
|
71
|
-
|
|
72
|
-
import json
|
|
73
|
-
import os
|
|
74
|
-
import re
|
|
75
|
-
import shutil
|
|
76
|
-
import subprocess
|
|
77
|
-
import sys
|
|
78
|
-
import tempfile
|
|
79
|
-
import time
|
|
80
|
-
|
|
81
|
-
SPLIT = re.compile(r"&&|\|\||[;|\n]")
|
|
82
|
-
WS = re.compile(r"\s+")
|
|
83
|
-
|
|
84
|
-
# How far to backdate the throwaway index in tree_digest(), in seconds. Anything modified
|
|
85
|
-
# within this window of the digest is re-hashed by content instead of trusted from its cached
|
|
86
|
-
# stat data. Must stay ≥ the filesystem's mtime granularity (1 s on ext4/HFS+); 5 s covers a
|
|
87
|
-
# clock that ticks backwards a little without costing anything on a quiet tree.
|
|
88
|
-
RACY_WINDOW_S = 5
|
|
89
|
-
HOOK_CWD = None
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def project_root() -> str:
|
|
93
|
-
return (os.environ.get("COHORTE_PROJECT_DIR") or os.environ.get("CLAUDE_PROJECT_DIR")
|
|
94
|
-
or HOOK_CWD or ".")
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
# The generated per-project files (gate config + preflight stamp) live under `.claude/`
|
|
98
|
-
# on a Claude install and `.cohorte/` on every other runtime. Probe both, newest layout
|
|
99
|
-
# first, so one gate serves a repo whichever agent is driving it — and so a repo that
|
|
100
|
-
# adds a second runtime doesn't end up with two configs, one of them silently unread.
|
|
101
|
-
STATE_DIRS = (".cohorte", ".claude")
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def state_path(name: str) -> str:
|
|
105
|
-
"""Path to a per-project pipeline file: the first layout that has it, else `.claude`
|
|
106
|
-
(the historical default, so a fresh write lands where existing tooling looks)."""
|
|
107
|
-
root = project_root()
|
|
108
|
-
for d in STATE_DIRS:
|
|
109
|
-
p = os.path.join(root, d, name)
|
|
110
|
-
if os.path.exists(p):
|
|
111
|
-
return p
|
|
112
|
-
return os.path.join(root, ".claude", name)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def load_config() -> dict:
|
|
116
|
-
path = state_path("gate-config.json")
|
|
117
|
-
empty = {"deny": [], "ask": [], "ask_on_default_branch": [], "default_branch": "main",
|
|
118
|
-
"preflight": {}}
|
|
119
|
-
try:
|
|
120
|
-
with open(path, "r", encoding="utf-8") as fh:
|
|
121
|
-
cfg = json.load(fh)
|
|
122
|
-
except Exception:
|
|
123
|
-
return empty
|
|
124
|
-
preflight = cfg.get("preflight")
|
|
125
|
-
if not isinstance(preflight, dict):
|
|
126
|
-
preflight = {}
|
|
127
|
-
return {
|
|
128
|
-
"deny": list(cfg.get("deny", [])),
|
|
129
|
-
"ask": list(cfg.get("ask", [])),
|
|
130
|
-
# Patterns gated ONLY on the default branch — allowed freely on feature branches.
|
|
131
|
-
"ask_on_default_branch": list(cfg.get("ask_on_default_branch", [])),
|
|
132
|
-
"default_branch": cfg.get("default_branch", "main") or "main",
|
|
133
|
-
# Phase gate: {"enabled": true, "agents": ["review"], "max_age_minutes": 30}
|
|
134
|
-
"preflight": preflight,
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def norm(s: str) -> str:
|
|
139
|
-
return WS.sub(" ", s.strip())
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def session_cwd(payload: dict) -> str:
|
|
143
|
-
"""Where the gated command actually runs. A feature worktree is its own
|
|
144
|
-
checkout — resolving git state in CLAUDE_PROJECT_DIR (the main checkout)
|
|
145
|
-
would gate every worktree command as if it ran on the default branch."""
|
|
146
|
-
return payload.get("cwd") or project_root()
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
CD = re.compile(r"^cd\s+(?!-\s*$)(?P<path>.+)$")
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def cd_target(seg: str, cwd: str):
|
|
153
|
-
"""The directory a `cd <path>` segment lands in, or None if this segment is
|
|
154
|
-
not a plain `cd` we can resolve. Chained commands are the norm here — the
|
|
155
|
-
tool's shell has no memory between calls, so agents write
|
|
156
|
-
`cd <other repo> && git commit`. Resolving git state at the payload cwd then
|
|
157
|
-
judges that commit against the WRONG checkout: a commit on a feature branch
|
|
158
|
-
in another repo was refused as if it were on the default branch, and no
|
|
159
|
-
approval could lift it because the branch it named was never the branch the
|
|
160
|
-
command would run on.
|
|
161
|
-
|
|
162
|
-
Deliberately narrow: one unquoted-or-quoted path, no `cd -`, no `$(…)`, no
|
|
163
|
-
variables. Anything else returns None and the caller keeps the cwd it had —
|
|
164
|
-
the conservative direction, since an unresolvable branch still gates."""
|
|
165
|
-
m = CD.match(seg)
|
|
166
|
-
if not m:
|
|
167
|
-
return None
|
|
168
|
-
path = m.group("path").strip()
|
|
169
|
-
if len(path) >= 2 and path[0] == path[-1] and path[0] in "\"'":
|
|
170
|
-
path = path[1:-1]
|
|
171
|
-
elif any(c in path for c in "$`\"'*?"):
|
|
172
|
-
return None # expansion or globbing — we cannot know where it lands
|
|
173
|
-
path = os.path.expanduser(path)
|
|
174
|
-
target = path if os.path.isabs(path) else os.path.join(cwd, path)
|
|
175
|
-
target = os.path.normpath(target)
|
|
176
|
-
return target if os.path.isdir(target) else None
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def current_branch(cwd: str):
|
|
180
|
-
"""The checked-out branch at `cwd`, or None (not a repo / detached / git absent)."""
|
|
181
|
-
try:
|
|
182
|
-
out = subprocess.run(
|
|
183
|
-
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
184
|
-
cwd=cwd, capture_output=True, text=True, timeout=3,
|
|
185
|
-
)
|
|
186
|
-
if out.returncode == 0:
|
|
187
|
-
return out.stdout.strip() or None
|
|
188
|
-
except Exception:
|
|
189
|
-
pass
|
|
190
|
-
return None
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def known_heads(cwd: str):
|
|
194
|
-
"""Every HEAD sha this repo currently has checked out: `cwd`'s plus each
|
|
195
|
-
linked worktree's. The preflight may legitimately have run in a feature
|
|
196
|
-
worktree while the Task dispatch fires from the main checkout (or vice
|
|
197
|
-
versa) — comparing against a single HEAD flags those runs as stale."""
|
|
198
|
-
heads = set()
|
|
199
|
-
try:
|
|
200
|
-
out = subprocess.run(
|
|
201
|
-
["git", "rev-parse", "HEAD"],
|
|
202
|
-
cwd=cwd, capture_output=True, text=True, timeout=3,
|
|
203
|
-
)
|
|
204
|
-
if out.returncode == 0 and out.stdout.strip():
|
|
205
|
-
heads.add(out.stdout.strip())
|
|
206
|
-
except Exception:
|
|
207
|
-
pass
|
|
208
|
-
try:
|
|
209
|
-
out = subprocess.run(
|
|
210
|
-
["git", "worktree", "list", "--porcelain"],
|
|
211
|
-
cwd=cwd, capture_output=True, text=True, timeout=3,
|
|
212
|
-
)
|
|
213
|
-
if out.returncode == 0:
|
|
214
|
-
for line in out.stdout.splitlines():
|
|
215
|
-
if line.startswith("HEAD "):
|
|
216
|
-
heads.add(line.split(" ", 1)[1].strip())
|
|
217
|
-
except Exception:
|
|
218
|
-
pass
|
|
219
|
-
return heads
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def worktree_dirs(cwd: str):
|
|
223
|
-
"""`cwd` plus every linked worktree path — the checkouts a stamp may describe."""
|
|
224
|
-
dirs = [cwd]
|
|
225
|
-
try:
|
|
226
|
-
out = subprocess.run(
|
|
227
|
-
["git", "worktree", "list", "--porcelain"],
|
|
228
|
-
cwd=cwd, capture_output=True, text=True, timeout=3,
|
|
229
|
-
)
|
|
230
|
-
if out.returncode == 0:
|
|
231
|
-
for line in out.stdout.splitlines():
|
|
232
|
-
if line.startswith("worktree "):
|
|
233
|
-
p = line.split(" ", 1)[1].strip()
|
|
234
|
-
if p and p not in dirs:
|
|
235
|
-
dirs.append(p)
|
|
236
|
-
except Exception:
|
|
237
|
-
pass
|
|
238
|
-
return dirs
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def tree_digest(cwd: str):
|
|
242
|
-
"""The digest pipeline/scripts/preflight.sh stamps for the checkout at `cwd`: the git
|
|
243
|
-
tree id of the working tree, computed in a throwaway index (`.claude`, `.cohorte` and `specs`
|
|
244
|
-
excluded — the pipeline writes those itself). Content-addressed, so a commit of the
|
|
245
|
-
same code keeps the stamp valid and any real edit invalidates it. Must stay identical
|
|
246
|
-
to the shell side. Returns None when git can't answer, and the caller falls back."""
|
|
247
|
-
tmp = None
|
|
248
|
-
try:
|
|
249
|
-
idx = subprocess.run(
|
|
250
|
-
["git", "rev-parse", "--absolute-git-dir"],
|
|
251
|
-
cwd=cwd, capture_output=True, text=True, timeout=3,
|
|
252
|
-
)
|
|
253
|
-
if idx.returncode != 0:
|
|
254
|
-
return None
|
|
255
|
-
real_index = os.path.join(idx.stdout.strip(), "index")
|
|
256
|
-
fd, tmp = tempfile.mkstemp(prefix="cohorte-idx-")
|
|
257
|
-
os.close(fd)
|
|
258
|
-
# Seed from the real index to keep its stat cache — `add` then re-hashes only
|
|
259
|
-
# what changed instead of the whole tree on every dispatch.
|
|
260
|
-
if os.path.exists(real_index):
|
|
261
|
-
shutil.copyfile(real_index, tmp)
|
|
262
|
-
# Age the copy by a few seconds. Git trusts an entry's cached stat data only when
|
|
263
|
-
# the entry's mtime is OLDER than the index file's; entries at or after it are
|
|
264
|
-
# "racily clean" and get re-hashed by content. copyfile stamps the temp index with
|
|
265
|
-
# `now`, so a file edited in the same second as the preflight — with an unchanged
|
|
266
|
-
# size — looked clean here and the gate greened code that had changed. Backdating
|
|
267
|
-
# forces a content check for anything touched inside that window, and leaves the
|
|
268
|
-
# stat-cache fast path (the whole point of seeding from the real index) intact for
|
|
269
|
-
# every older file.
|
|
270
|
-
old = time.time() - RACY_WINDOW_S
|
|
271
|
-
os.utime(tmp, (old, old))
|
|
272
|
-
else:
|
|
273
|
-
os.unlink(tmp) # a 0-byte index is a corrupt index — let git create it
|
|
274
|
-
env = dict(os.environ, GIT_INDEX_FILE=tmp)
|
|
275
|
-
# Drop the excluded paths outright — an `add` exclude only stops them being
|
|
276
|
-
# *updated*, so already-tracked ones would still shift the tree id.
|
|
277
|
-
subprocess.run(
|
|
278
|
-
["git", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--",
|
|
279
|
-
".claude", ".cohorte", "specs"],
|
|
280
|
-
cwd=cwd, capture_output=True, timeout=10, env=env,
|
|
281
|
-
)
|
|
282
|
-
add = subprocess.run(
|
|
283
|
-
["git", "add", "-A", "--", ".",
|
|
284
|
-
":(exclude).claude", ":(exclude).cohorte", ":(exclude)specs"],
|
|
285
|
-
cwd=cwd, capture_output=True, timeout=30, env=env,
|
|
286
|
-
)
|
|
287
|
-
if add.returncode != 0:
|
|
288
|
-
return None
|
|
289
|
-
out = subprocess.run(
|
|
290
|
-
["git", "write-tree"], cwd=cwd, capture_output=True, text=True, timeout=10, env=env,
|
|
291
|
-
)
|
|
292
|
-
if out.returncode != 0:
|
|
293
|
-
return None
|
|
294
|
-
return out.stdout.strip() or None
|
|
295
|
-
except Exception:
|
|
296
|
-
return None
|
|
297
|
-
finally:
|
|
298
|
-
for p in ((tmp, tmp + ".lock") if tmp else ()):
|
|
299
|
-
try:
|
|
300
|
-
os.unlink(p)
|
|
301
|
-
except Exception:
|
|
302
|
-
pass
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def known_digests(cwd: str):
|
|
306
|
-
"""Every checkout's current digest. The preflight may have run in a feature worktree
|
|
307
|
-
while the Task dispatch fires from the main checkout (or vice versa) — same reason
|
|
308
|
-
known_heads() spans worktrees."""
|
|
309
|
-
return {d for d in (tree_digest(p) for p in worktree_dirs(cwd)) if d}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
def check_preflight(payload: dict, cfg: dict) -> int:
|
|
313
|
-
"""Phase gate on Task dispatches: review agents need a green preflight stamp."""
|
|
314
|
-
pf = cfg.get("preflight") or {}
|
|
315
|
-
if not pf.get("enabled"):
|
|
316
|
-
return 0
|
|
317
|
-
agents = pf.get("agents") or ["review"]
|
|
318
|
-
# Runtimes name a subagent dispatch differently. Claude Code and Cursor send a `Task` tool
|
|
319
|
-
# carrying `subagent_type`; Gemini exposes each subagent as a tool of the SAME NAME, so the
|
|
320
|
-
# dispatch arrives as `tool_name: review`. Accept both rather than gating only the shape
|
|
321
|
-
# one vendor happens to use — a phase gate that silently never fires is the 1.3.0 bug.
|
|
322
|
-
tool_input = payload.get("tool_input") or {}
|
|
323
|
-
subagent = tool_input.get("subagent_type") or tool_input.get("agent_type") or ""
|
|
324
|
-
if not subagent and payload.get("tool_name") in agents:
|
|
325
|
-
subagent = payload.get("tool_name")
|
|
326
|
-
if subagent not in agents:
|
|
327
|
-
return 0
|
|
328
|
-
|
|
329
|
-
stamp = state_path("preflight.ok")
|
|
330
|
-
why = None
|
|
331
|
-
try:
|
|
332
|
-
with open(stamp, "r", encoding="utf-8") as fh:
|
|
333
|
-
raw = fh.read().strip()
|
|
334
|
-
except Exception:
|
|
335
|
-
raw = None
|
|
336
|
-
why = "no preflight stamp found"
|
|
337
|
-
if raw is not None:
|
|
338
|
-
try:
|
|
339
|
-
fields = raw.split()
|
|
340
|
-
epoch_s = fields[0]
|
|
341
|
-
sha = fields[1] if len(fields) > 1 else ""
|
|
342
|
-
digest = fields[2] if len(fields) > 2 else ""
|
|
343
|
-
age_min = (time.time() - float(epoch_s)) / 60
|
|
344
|
-
max_age = float(pf.get("max_age_minutes", 30) or 30)
|
|
345
|
-
cwd = session_cwd(payload)
|
|
346
|
-
if age_min > max_age:
|
|
347
|
-
why = f"the preflight stamp is {age_min:.0f} min old (max {max_age:.0f})"
|
|
348
|
-
elif digest not in ("", "none"):
|
|
349
|
-
# 2.0.0+ stamp: key off the verified CONTENT, not HEAD. A commit that
|
|
350
|
-
# doesn't touch code keeps the stamp valid; an edit that never reaches a
|
|
351
|
-
# commit invalidates it — both of which the sha comparison got backwards.
|
|
352
|
-
digests = known_digests(cwd)
|
|
353
|
-
if digests and digest not in digests:
|
|
354
|
-
why = "the code changed since the preflight ran"
|
|
355
|
-
else:
|
|
356
|
-
# Pre-2.0.0 stamp (no digest) — fall back to the HEAD comparison.
|
|
357
|
-
heads = known_heads(cwd)
|
|
358
|
-
if heads and sha not in ("", "none") and sha not in heads:
|
|
359
|
-
why = "HEAD moved since the preflight ran"
|
|
360
|
-
except Exception:
|
|
361
|
-
why = "the preflight stamp is unreadable (expected `<epoch> <sha> <digest>`)"
|
|
362
|
-
if why is None:
|
|
363
|
-
return 0
|
|
364
|
-
# Same rule as the Bash gate: unattended runs have nobody to answer an "ask".
|
|
365
|
-
unattended = payload.get("permission_mode") == "bypassPermissions"
|
|
366
|
-
return decide(
|
|
367
|
-
"deny" if unattended else "ask",
|
|
368
|
-
f"Phase gate: dispatching `{subagent}` but {why}. Run the deterministic pre-flight first "
|
|
369
|
-
f"(pipeline/scripts/preflight.sh — typecheck + lint + tests) so agents never review red code; "
|
|
370
|
-
f"or confirm to dispatch anyway (PIPELINE.md gate.preflight)."
|
|
371
|
-
+ (" (denied outright: unattended run, nobody to confirm)" if unattended else ""),
|
|
372
|
-
)
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def check_cli(argv) -> int:
|
|
376
|
-
"""Explicit gate for runtimes with no PreToolUse hook.
|
|
377
|
-
|
|
378
|
-
Builds the same payload the hook would have received and runs the same evaluation,
|
|
379
|
-
then translates the verdict into a line + exit code a shell (or an agent reading the
|
|
380
|
-
output) can act on. Exit 0 = allow, 1 = ask, 2 = deny — chosen so `&&` naturally
|
|
381
|
-
stops on anything that isn't a clean allow."""
|
|
382
|
-
mode = argv[0]
|
|
383
|
-
unattended = False
|
|
384
|
-
cwd = os.getcwd()
|
|
385
|
-
words = []
|
|
386
|
-
i = 1
|
|
387
|
-
while i < len(argv):
|
|
388
|
-
a = argv[i]
|
|
389
|
-
if a == "--unattended":
|
|
390
|
-
unattended = True
|
|
391
|
-
elif a == "--cwd" and i + 1 < len(argv):
|
|
392
|
-
cwd = argv[i + 1]
|
|
393
|
-
i += 1
|
|
394
|
-
elif a.startswith("--cwd="):
|
|
395
|
-
cwd = a[6:]
|
|
396
|
-
else:
|
|
397
|
-
words.append(a)
|
|
398
|
-
i += 1
|
|
399
|
-
subject = " ".join(words).strip()
|
|
400
|
-
if not subject:
|
|
401
|
-
print(f"gate: usage — gate.py {mode} <{'command' if mode == '--check' else 'agent'}>",
|
|
402
|
-
file=sys.stderr)
|
|
403
|
-
return 2
|
|
404
|
-
|
|
405
|
-
payload = {"cwd": cwd, "permission_mode": "bypassPermissions" if unattended else "default"}
|
|
406
|
-
if mode == "--check":
|
|
407
|
-
payload["tool_name"] = "Bash"
|
|
408
|
-
payload["tool_input"] = {"command": subject}
|
|
409
|
-
else:
|
|
410
|
-
payload["tool_name"] = "Task"
|
|
411
|
-
payload["tool_input"] = {"subagent_type": subject}
|
|
412
|
-
|
|
413
|
-
# Reuse the hook path verbatim — one evaluator, so the two modes can never drift into
|
|
414
|
-
# different verdicts on the same config. `decide()` records instead of printing while
|
|
415
|
-
# DECISIONS is armed, so the hook's JSON never leaks into the human-readable output.
|
|
416
|
-
global DECISIONS
|
|
417
|
-
DECISIONS = []
|
|
418
|
-
cfg = load_config()
|
|
419
|
-
if payload["tool_name"] == "Task":
|
|
420
|
-
check_preflight(payload, cfg)
|
|
421
|
-
else:
|
|
422
|
-
check_bash(payload, cfg)
|
|
423
|
-
|
|
424
|
-
if not DECISIONS:
|
|
425
|
-
print("gate: allow")
|
|
426
|
-
return 0
|
|
427
|
-
decision, reason = DECISIONS[0]
|
|
428
|
-
print(f"gate: {decision} — {reason}")
|
|
429
|
-
return 2 if decision == "deny" else 1
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
def check_bash(payload: dict, cfg: dict) -> int:
|
|
433
|
-
"""Pattern gate on a Bash command. Split out of main() so --check reuses it."""
|
|
434
|
-
command = (payload.get("tool_input") or {}).get("command", "") or ""
|
|
435
|
-
deny, ask, branch_gated = cfg["deny"], cfg["ask"], cfg["ask_on_default_branch"]
|
|
436
|
-
default = cfg["default_branch"]
|
|
437
|
-
if not deny and not ask and not branch_gated:
|
|
438
|
-
return 0
|
|
439
|
-
|
|
440
|
-
# No human can answer a prompt in bypassPermissions (headless runs) — an "ask"
|
|
441
|
-
# there either hangs or silently auto-resolves, so escalate it to a clear deny.
|
|
442
|
-
unattended = payload.get("permission_mode") == "bypassPermissions"
|
|
443
|
-
ask_decision = "deny" if unattended else "ask"
|
|
444
|
-
ask_suffix = " (denied outright: unattended run, nobody to confirm)" if unattended else ""
|
|
445
|
-
|
|
446
|
-
# Branch-conditional patterns (e.g. git/docker) are gated only on the default branch;
|
|
447
|
-
# on a feature branch they run freely. Unknown branch (no repo / detached / no git) ⇒
|
|
448
|
-
# be conservative and gate. The branch is resolved per segment at the cwd that
|
|
449
|
-
# segment runs in — a leading `cd <dir>` in the same chain moves it — and cached,
|
|
450
|
-
# so the common case is still one git call.
|
|
451
|
-
cwd = session_cwd(payload)
|
|
452
|
-
branch_cache = {}
|
|
453
|
-
|
|
454
|
-
def on_default_at(directory: str) -> bool:
|
|
455
|
-
if directory not in branch_cache:
|
|
456
|
-
branch = current_branch(directory)
|
|
457
|
-
branch_cache[directory] = branch is None or branch == default
|
|
458
|
-
return branch_cache[directory]
|
|
459
|
-
|
|
460
|
-
segments = [s for s in (norm(raw) for raw in SPLIT.split(command)) if s]
|
|
461
|
-
|
|
462
|
-
# Deny wins over ask across the WHOLE chain, not just in segment order. Scanning
|
|
463
|
-
# segment-by-segment returned on the first match, so `git commit -m x && node ace
|
|
464
|
-
# migration:fresh` surfaced only the benign `git commit` ask — and the human's one
|
|
465
|
-
# confirmation ran the hard-denied command behind it. Scan every segment for deny
|
|
466
|
-
# patterns first; only a fully deny-free chain gets an ask.
|
|
467
|
-
for seg in segments:
|
|
468
|
-
for pat in deny:
|
|
469
|
-
if norm(pat) in seg:
|
|
470
|
-
return decide("deny", f"`{pat}` is forbidden by the project's PIPELINE.md gate.")
|
|
471
|
-
|
|
472
|
-
for seg in segments:
|
|
473
|
-
for pat in ask:
|
|
474
|
-
if norm(pat) in seg:
|
|
475
|
-
return decide(ask_decision,
|
|
476
|
-
f"`{pat}` is a gated command — confirm first (PIPELINE.md gate)."
|
|
477
|
-
f"{ask_suffix}")
|
|
478
|
-
if branch_gated and on_default_at(cwd):
|
|
479
|
-
for pat in branch_gated:
|
|
480
|
-
if norm(pat) in seg:
|
|
481
|
-
return decide(ask_decision,
|
|
482
|
-
f"`{pat}` is gated on the default branch `{default}` — confirm "
|
|
483
|
-
f"(PIPELINE.md gate). It runs freely on feature branches."
|
|
484
|
-
f"{ask_suffix}")
|
|
485
|
-
# Apply the `cd` AFTER matching this segment: a `cd` is not itself gated,
|
|
486
|
-
# and every later segment in the chain runs in the new directory.
|
|
487
|
-
moved = cd_target(seg, cwd)
|
|
488
|
-
if moved:
|
|
489
|
-
cwd = moved
|
|
490
|
-
|
|
491
|
-
return 0
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
def main() -> int:
|
|
495
|
-
argv = sys.argv[1:]
|
|
496
|
-
if argv and argv[0] in ("--check", "--check-dispatch"):
|
|
497
|
-
return check_cli(argv)
|
|
498
|
-
|
|
499
|
-
global RUNTIME, HOOK_CWD
|
|
500
|
-
for i, a in enumerate(argv):
|
|
501
|
-
if a == "--runtime" and i + 1 < len(argv):
|
|
502
|
-
RUNTIME = argv[i + 1]
|
|
503
|
-
elif a.startswith("--runtime="):
|
|
504
|
-
RUNTIME = a[10:]
|
|
505
|
-
|
|
506
|
-
try:
|
|
507
|
-
payload = json.load(sys.stdin)
|
|
508
|
-
except Exception:
|
|
509
|
-
return 0 # malformed input → don't block
|
|
510
|
-
|
|
511
|
-
# Codex provides the project cwd in the envelope, not CLAUDE_PROJECT_DIR.
|
|
512
|
-
# A globally installed hook must inspect the calling project, not the process cwd.
|
|
513
|
-
if isinstance(payload.get("cwd"), str) and payload["cwd"]:
|
|
514
|
-
HOOK_CWD = payload["cwd"]
|
|
515
|
-
|
|
516
|
-
tool = payload.get("tool_name")
|
|
517
|
-
# Cursor's shell hook carries the command at the top level rather than in tool_input, and
|
|
518
|
-
# names no tool. Normalise once here so every check below stays runtime-agnostic.
|
|
519
|
-
if RUNTIME == "cursor" and not tool:
|
|
520
|
-
tool = "Bash"
|
|
521
|
-
payload = dict(payload, tool_name="Bash",
|
|
522
|
-
tool_input=payload.get("tool_input") or {"command": payload.get("command", "")})
|
|
523
|
-
cfg = load_config()
|
|
524
|
-
|
|
525
|
-
# A dispatch, in whichever shape this runtime sends it (see check_preflight).
|
|
526
|
-
if tool in ("Task", "spawn_agent", "Agent") or tool in ((cfg.get("preflight") or {}).get("agents") or ["review"]):
|
|
527
|
-
return check_preflight(payload, cfg)
|
|
528
|
-
# Bash is Claude's/Codex's name for the shell tool; Gemini calls it run_shell_command, and
|
|
529
|
-
# Cursor's beforeShellExecution was normalised to "Bash" above. Anything else is not a
|
|
530
|
-
# command we gate.
|
|
531
|
-
if tool not in ("Bash", "shell", "run_shell_command"):
|
|
532
|
-
return 0
|
|
533
|
-
return check_bash(payload, cfg)
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
# Armed by --check: while it is a list, decide() records the verdict instead of emitting
|
|
537
|
-
# a hook envelope, which only a hook host understands.
|
|
538
|
-
DECISIONS = None
|
|
539
|
-
|
|
540
|
-
# Which runtime is hosting this hook — set from `--runtime <id>`. Defaults to Claude Code:
|
|
541
|
-
# it is the only host that ran this file before the flag existed, so an old registration
|
|
542
|
-
# that predates the adapter keeps behaving exactly as it did.
|
|
543
|
-
RUNTIME = "claude"
|
|
544
|
-
|
|
545
|
-
# Runtimes whose hook contract has no "ask" tier. Codex parses `permissionDecision: ask` and
|
|
546
|
-
# ignores it — which would let a gated command through — and Gemini's BeforeTool only
|
|
547
|
-
# allows or denies. Escalate rather than downgrade: see the module docstring.
|
|
548
|
-
NO_ASK = {"codex", "gemini"}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
def decide(decision: str, reason: str) -> int:
|
|
552
|
-
if DECISIONS is not None:
|
|
553
|
-
DECISIONS.append((decision, reason))
|
|
554
|
-
return 0
|
|
555
|
-
|
|
556
|
-
if decision == "ask" and RUNTIME in NO_ASK:
|
|
557
|
-
decision = "deny"
|
|
558
|
-
reason += (f" (denied outright: {RUNTIME} has no confirmation tier, so an \"ask\" here "
|
|
559
|
-
f"would silently run. Re-run it yourself if you meant to.)")
|
|
560
|
-
|
|
561
|
-
if RUNTIME == "cursor":
|
|
562
|
-
# Cursor's own envelope. `agent_message` is what the model is told, `user_message`
|
|
563
|
-
# what the human sees — the reason serves both.
|
|
564
|
-
print(json.dumps({"permission": decision, "user_message": reason, "agent_message": reason}))
|
|
565
|
-
return 2 if decision == "deny" else 0
|
|
566
|
-
|
|
567
|
-
if RUNTIME == "gemini":
|
|
568
|
-
# BeforeTool speaks {"decision", "reason"}; anything else is read as allow.
|
|
569
|
-
print(json.dumps({"decision": decision, "reason": reason}))
|
|
570
|
-
return 0
|
|
571
|
-
|
|
572
|
-
# Claude Code and Codex share the PreToolUse envelope.
|
|
573
|
-
print(
|
|
574
|
-
json.dumps(
|
|
575
|
-
{
|
|
576
|
-
"hookSpecificOutput": {
|
|
577
|
-
"hookEventName": "PreToolUse",
|
|
578
|
-
"permissionDecision": decision,
|
|
579
|
-
"permissionDecisionReason": reason,
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
)
|
|
583
|
-
)
|
|
584
|
-
return 0
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
if __name__ == "__main__":
|
|
588
|
-
sys.exit(main())
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "claude",
|
|
3
|
-
"label": "Claude Code",
|
|
4
|
-
"docs": "https://docs.claude.com/en/docs/claude-code/slash-commands",
|
|
5
|
-
"detect": [
|
|
6
|
-
"~/.claude",
|
|
7
|
-
"~/.config/claude"
|
|
8
|
-
],
|
|
9
|
-
"scopes": {
|
|
10
|
-
"global": {
|
|
11
|
-
"root": "~/.claude",
|
|
12
|
-
"core": "~/.claude",
|
|
13
|
-
"commands": "~/.claude/commands",
|
|
14
|
-
"agents": "~/.claude/agents",
|
|
15
|
-
"hooks": "~/.claude/hooks",
|
|
16
|
-
"workflows": "~/.claude/workflows",
|
|
17
|
-
"settings": "~/.claude/settings.json",
|
|
18
|
-
"hooks_config": "~/.claude/settings.json"
|
|
19
|
-
},
|
|
20
|
-
"project": {
|
|
21
|
-
"root": ".claude",
|
|
22
|
-
"core": ".claude",
|
|
23
|
-
"commands": ".claude/commands",
|
|
24
|
-
"agents": ".claude/agents",
|
|
25
|
-
"hooks": ".claude/hooks",
|
|
26
|
-
"workflows": ".claude/workflows",
|
|
27
|
-
"settings": ".claude/settings.json",
|
|
28
|
-
"hooks_config": ".claude/settings.json"
|
|
29
|
-
}
|
|
30
|
-
},
|
|
31
|
-
"exclude_commands": [],
|
|
32
|
-
"command": {
|
|
33
|
-
"format": "md",
|
|
34
|
-
"ext": ".md",
|
|
35
|
-
"frontmatter": [
|
|
36
|
-
"model",
|
|
37
|
-
"description",
|
|
38
|
-
"argument-hint",
|
|
39
|
-
"allowed-tools",
|
|
40
|
-
"disable-model-invocation"
|
|
41
|
-
],
|
|
42
|
-
"args": "$ARGUMENTS",
|
|
43
|
-
"invoke": "/<name>"
|
|
44
|
-
},
|
|
45
|
-
"agent": {
|
|
46
|
-
"format": "md",
|
|
47
|
-
"frontmatter": [
|
|
48
|
-
"name",
|
|
49
|
-
"description",
|
|
50
|
-
"tools",
|
|
51
|
-
"model"
|
|
52
|
-
],
|
|
53
|
-
"dispatch": "Task tool, subagent_type: <name>",
|
|
54
|
-
"address": "<name>",
|
|
55
|
-
"ext": ".md"
|
|
56
|
-
},
|
|
57
|
-
"capabilities": {
|
|
58
|
-
"subagents": true,
|
|
59
|
-
"hooks": true,
|
|
60
|
-
"workflows": true,
|
|
61
|
-
"inline_design": true,
|
|
62
|
-
"mcp": true,
|
|
63
|
-
"tool_restriction": true
|
|
64
|
-
},
|
|
65
|
-
"memory": "CLAUDE.md",
|
|
66
|
-
"headless": "claude -p",
|
|
67
|
-
"hook": {
|
|
68
|
-
"format": "claude",
|
|
69
|
-
"event": "PreToolUse",
|
|
70
|
-
"matcher": "Bash|Task",
|
|
71
|
-
"supports_ask": true,
|
|
72
|
-
"config_shape": "settings"
|
|
73
|
-
}
|
|
74
|
-
}
|