cohorte 2.1.0 → 2.3.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/CHANGELOG.md +173 -0
- package/README.md +49 -41
- package/bin/cli.js +324 -28
- package/core/adapter/render.js +389 -0
- package/core/agents/implementer.template.md +3 -3
- package/core/agents/release.md +7 -6
- package/core/agents/review.md +17 -2
- package/core/commands/cohorte-audit.md +2 -0
- package/core/commands/cohorte-brainstorm.md +3 -11
- package/core/commands/cohorte-build.md +37 -23
- package/core/commands/cohorte-doctor.md +61 -36
- package/core/commands/cohorte-fix.md +3 -5
- package/core/commands/cohorte-init-pipeline.md +7 -8
- package/core/commands/cohorte-patch.md +113 -0
- package/core/commands/cohorte-refactor.md +5 -2
- package/core/commands/cohorte-review.md +20 -18
- package/core/commands/cohorte-ship.md +13 -14
- package/core/commands/cohorte-spec.md +4 -13
- package/core/commands/cohorte-update-pipeline.md +13 -12
- package/core/hooks/gate.py +203 -16
- package/core/runtimes/claude.json +73 -0
- package/core/runtimes/codex.json +82 -0
- package/core/runtimes/cursor.json +75 -0
- package/core/runtimes/gemini.json +75 -0
- package/core/runtimes/opencode.json +72 -0
- package/core/templates/patch.template.md +86 -0
- package/core/templates/spec.template.md +1 -3
- package/core/templates/steps/init-pipeline/01-detect-stack.md +1 -1
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +1 -11
- package/core/templates/steps/init-pipeline/04-write-render.md +23 -17
- package/core/templates/steps/init-pipeline/05-report.md +1 -1
- package/core/workflows/review.js +1 -3
- package/dashboard/dist/assets/{index-P1I1JGtj.js → index-D1rsbLat.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/server/doctor.js +156 -69
- package/dashboard/server/index.js +12 -2
- package/dashboard/server/metrics.js +13 -6
- package/dashboard/server/runtime.js +115 -0
- package/dashboard/server/versions.js +12 -1
- package/install.ps1 +26 -3
- package/install.sh +28 -6
- package/package.json +6 -2
- package/profile/PIPELINE.template.md +8 -6
- package/profile/SCHEMA.md +70 -108
- package/profile/cohorte.config.template.yaml +0 -16
- package/scripts/kanban-move.sh +11 -1
- package/scripts/metrics/collect.mjs +5 -3
- package/scripts/preflight.sh +27 -8
- package/scripts/test-adapter.mjs +368 -0
- package/scripts/test-dashboard.mjs +70 -0
- package/scripts/test-gate.mjs +62 -0
- package/scripts/validate-core.mjs +26 -24
- package/core/commands/cohorte-loop.md +0 -110
- package/scripts/loop-detach.sh +0 -153
- package/scripts/loop.sh +0 -399
- package/scripts/telemetry-send.sh +0 -77
- package/scripts/test-loop.mjs +0 -330
|
@@ -12,8 +12,7 @@ You are the **lead**. Ship feature **$ARGUMENTS**. This is the outward-facing ga
|
|
|
12
12
|
>
|
|
13
13
|
> **Kanban** (SCHEMA.md §Kanban) is mirrored in **explicit steps** below, not as an afterthought:
|
|
14
14
|
> §1 moves the card → **Ship**; §4 moves it → **Shipped** and writes the PR number. Both are one
|
|
15
|
-
> call to `<core>/pipeline/scripts/kanban-move.sh auto
|
|
16
|
-
> global — probe with `test -x`), which resolves the board from the config itself and exits 0 with a
|
|
15
|
+
> call to `<core>/pipeline/scripts/kanban-move.sh auto …`, which resolves the board from the config itself and exits 0 with a
|
|
17
16
|
> `kanban: <reason>` line when there is none. **Never decide "no board is configured" without running
|
|
18
17
|
> it** — a ship session that inferred that, having opened neither the config nor `PIPELINE.md`, is
|
|
19
18
|
> exactly how a merged feature's card stayed in "Ready to build". Do not skip §4's move either.
|
|
@@ -32,7 +31,10 @@ You are the **lead**. Ship feature **$ARGUMENTS**. This is the outward-facing ga
|
|
|
32
31
|
§`Acceptance criteria / DoD`; if any item is still `- [ ]`, list the open ones and ask the human to
|
|
33
32
|
confirm shipping anyway (they may be deferred on purpose — e.g. a UI item on a backend-only feature).
|
|
34
33
|
All `- [x]` ⇒ proceed silently.
|
|
35
|
-
- Show `git status` + `git diff --stat`; confirm the branch is `<
|
|
34
|
+
- Show `git status` + `git diff --stat`; confirm the branch is `<prefix>$ARGUMENTS`, where `<prefix>`
|
|
35
|
+
is `vcs.patch_branch_prefix` (falling back to `fix/` on a profile that predates the key) when the
|
|
36
|
+
spec front-matter carries `kind: patch`, and `vcs.feature_branch_prefix` otherwise. Resolve it once
|
|
37
|
+
here and pass the **literal** branch to §3's dispatch — the release agent must not re-derive it.
|
|
36
38
|
- **Ask the human to confirm** they want to commit, push, and open the PR. Wait for yes.
|
|
37
39
|
- After the yes: `<core>/pipeline/scripts/kanban-move.sh auto $ARGUMENTS ship`. Report what it
|
|
38
40
|
printed — `moved #…` or `kanban: <reason>` — never a guess about which happened.
|
|
@@ -58,6 +60,9 @@ See SCHEMA.md §Release notes.
|
|
|
58
60
|
`release_notes.forbid_levels` (a `0.x` repo forbidding `major` declares the rupture `minor`).
|
|
59
61
|
- **Ambiguous between two defensible levels?** State your reading in one line and **ask the human to
|
|
60
62
|
pick** before writing. A wrong bump becomes a published version number.
|
|
63
|
+
- A `kind: patch` spec is a `patch` bump by default — that is what the level means. It is a default,
|
|
64
|
+
not a rule: a bug fix that changes documented behaviour is still a `minor`, and one that removes it
|
|
65
|
+
is still breaking. Say which you chose when it isn't `patch`.
|
|
61
66
|
- The body describes what changed **for the user**, from the spec §1/§2 — no client names, no internal
|
|
62
67
|
paths, no exploitable attack vector, no file lists.
|
|
63
68
|
- If the feature genuinely must move no version, use `release_notes.empty_cmd` instead. Prefer that to
|
|
@@ -65,17 +70,18 @@ See SCHEMA.md §Release notes.
|
|
|
65
70
|
- Then say in one line which level you chose and why — this is the human's last chance to correct it
|
|
66
71
|
before it is committed.
|
|
67
72
|
|
|
68
|
-
> **Why this is its own gate.** The requirement usually lives in the project's
|
|
73
|
+
> **Why this is its own gate.** The requirement usually lives in the project's `<memory>`, which this
|
|
69
74
|
> flow never reads. Skip it and everything below still "succeeds": commit, push, PR opened, kanban card
|
|
70
75
|
> moved to **Shipped** — and CI red on a job nobody watched. The feature reads as shipped while being
|
|
71
76
|
> unmergeable.
|
|
72
77
|
|
|
73
78
|
## 3. Dispatch the `release` agent
|
|
74
79
|
|
|
75
|
-
Spawn one agent (`subagent_type: release
|
|
76
|
-
|
|
80
|
+
Spawn one agent (`subagent_type: release`, or the equivalent dispatch for this runtime):
|
|
81
|
+
"Release feature `$ARGUMENTS` on branch
|
|
82
|
+
`<the branch §1 resolved>`. Read `PIPELINE.md` §vcs first. Spec: `specs/$ARGUMENTS.md` (already
|
|
77
83
|
`status: shipped` — stage it). Write conventional commit(s), push (no force), open the PR (use `gh` if
|
|
78
|
-
`host: github` + available; else emit the compare URL + drafted PR body from
|
|
84
|
+
`host: github` + available; else emit the compare URL + drafted PR body from `<core>/templates/pr-body.md`).
|
|
79
85
|
Stage **all** the feature's changes including `specs/$ARGUMENTS.md` and, if `release_notes.enabled`, the
|
|
80
86
|
release note at `<release_notes.dir>/<release_notes.filename>` — it is already written, stage it as-is and
|
|
81
87
|
never author or edit one yourself. Never edit source, never force-push, never run migrations."
|
|
@@ -101,13 +107,6 @@ with a **grep for `#$ARGUMENTS`** on the board it named (with surrounding headin
|
|
|
101
107
|
`grep -B20 '#$ARGUMENTS' | grep '^##'`, or an offset-limited Read around the match): exactly one card,
|
|
102
108
|
under the `shipped` heading — never re-read the whole board into context.
|
|
103
109
|
|
|
104
|
-
**Telemetry — the usage ping that closes the funnel.** Chain it onto the verify call above
|
|
105
|
-
(`/cohorte-build` §4's shared form, `<phase>` = `ship`, `<seconds>` = `0` — the release agent's duration is
|
|
106
|
-
not the pipeline's, `<results>` = `pr` when a PR was created / `compare` when only a compare URL was
|
|
107
|
-
emitted). Fire it **after** the release agent reports success, never on an aborted ship — a `ship`
|
|
108
|
-
event must mean the feature actually left the pipeline. No board ⇒ still ping, in its own `|| true`
|
|
109
|
-
call. Silent no-op without consent; never ask about consent here.
|
|
110
|
-
|
|
111
110
|
## 5. After the PR — CI gate + teardown
|
|
112
111
|
|
|
113
112
|
- If `host: github` and `gh` is available, watch the PR's checks (`gh pr checks <url> --watch`) and
|
|
@@ -10,17 +10,13 @@ You run the **spec** step in the main thread — interactive, with the human. Pa
|
|
|
10
10
|
> Read `PIPELINE.md` first: `contract` (mechanism/path — so §5 names the right schema types),
|
|
11
11
|
> `design.enabled` (whether §8 matters), and §Conventions. Use `specs/_template.md` as the section list.
|
|
12
12
|
>
|
|
13
|
-
> Template paths below (`.claude/templates/…`) resolve to `~/.claude/templates/…` when the core is
|
|
14
|
-
> installed globally — read whichever exists.
|
|
15
|
-
>
|
|
16
13
|
> **Decision journal** (SCHEMA.md §Decisions): read `specs/_decisions.md` §Live if it exists — one
|
|
17
14
|
> line per standing decision, so it is cheap. It is the ONLY place the project's transverse rules
|
|
18
15
|
> live; a spec that contradicts one silently un-decides it. Absent ⇒ nothing to honour yet.
|
|
19
16
|
>
|
|
20
17
|
> **Kanban** (SCHEMA.md §Kanban): when the spec opens, run
|
|
21
18
|
> `<core>/pipeline/scripts/kanban-move.sh auto <feature_id> spec --title "<human title>"`; on freeze
|
|
22
|
-
> (`status: frozen`, Mode A), the same call with `ready`.
|
|
23
|
-
> global — probe with `test -x`. `auto` resolves the board from the config itself and exits 0 with a
|
|
19
|
+
> (`status: frozen`, Mode A), the same call with `ready`. `auto` resolves the board from the config itself and exits 0 with a
|
|
24
20
|
> `kanban: <reason>` line when there is none — so **never decide "no board is configured" without
|
|
25
21
|
> running it**.
|
|
26
22
|
|
|
@@ -55,19 +51,14 @@ Detect the mode from the pasted content:
|
|
|
55
51
|
it. This is just so the human isn't surprised when `/cohorte-build` proposes a new agent.
|
|
56
52
|
5. When the human validates, **freeze**: write `specs/<id>.md` (`status: frozen`, front-matter filled).
|
|
57
53
|
Create the file — do not ask the human to. **Postcondition:** `grep -q '^status: frozen' specs/<id>.md`
|
|
58
|
-
— if it fails the freeze didn't land; fix it before pointing the human at `/cohorte-build`.
|
|
59
|
-
opt-in usage ping onto the postcondition's Bash call (`/cohorte-build` §4's shared form, `<phase>` =
|
|
60
|
-
`spec`, `<seconds>` = `0` — interactive time, not pipeline wall-clock, `<results>` = `frozen`).
|
|
61
|
-
Ping only on a **landed** freeze, so the funnel counts specs that exist, not attempts. Mode B does
|
|
62
|
-
not ping — it re-enters an already-counted spec, and `/cohorte-fix` covers that loop. Silent no-op without
|
|
63
|
-
consent; never ask about consent here.
|
|
54
|
+
— if it fails the freeze didn't land; fix it before pointing the human at `/cohorte-build`.
|
|
64
55
|
5b. **Record the transverse decisions — the journal, not a summary.** Walk what the interview settled
|
|
65
56
|
and keep ONLY the decisions that **outlive this feature**: a rule the next spec would otherwise
|
|
66
57
|
re-litigate or contradict (auth model, id/naming scheme, where a kind of state lives, an error
|
|
67
58
|
convention, a deliberate non-goal that binds future features). Typical yield: **0–3 lines**; zero
|
|
68
59
|
is a normal, healthy outcome for a feature that decided nothing new — never invent lines to fill
|
|
69
60
|
the section. Append them to `specs/_decisions.md` §Live (create the file from
|
|
70
|
-
|
|
61
|
+
`<core>/templates/decisions.template.md` on first use), each exactly:
|
|
71
62
|
`- <YYYY-MM-DD> · <area> · <decision> — because <reason> · <feature_id>`
|
|
72
63
|
- **Never** duplicate what §5, `PIPELINE.md` §Conventions or the code already states — the journal
|
|
73
64
|
carries the *non-obvious rule*, not the feature's content. A line that restates a spec section is
|
|
@@ -78,7 +69,7 @@ Detect the mode from the pasted content:
|
|
|
78
69
|
- Append with one `>>` Bash call, not a full-file rewrite (the file is append-only, and reading it
|
|
79
70
|
back to re-write it is the one way to make a bounded file expensive).
|
|
80
71
|
6. Author the **design brief** — `specs/design/<id>.md`, rendered via
|
|
81
|
-
|
|
72
|
+
`<core>/templates/design-brief.md`.
|
|
82
73
|
_Only if `design.enabled` / the feature has UI; skip entirely for a backend-only feature._
|
|
83
74
|
- **Write it to `specs/design/<id>.md`** (the authored artifact, versioned with the spec; spec §8
|
|
84
75
|
holds the summary + pointer). Create the file — do not ask the human to. Keep it in the
|
|
@@ -6,14 +6,14 @@ argument-hint: [path-to-local-checkout]
|
|
|
6
6
|
|
|
7
7
|
You are the **pipeline updater**. Refresh the installed pipeline core to the latest version of the pipeline
|
|
8
8
|
repo. The installer's `--update` mode never touches generated files: `PIPELINE.md`, rendered surface agents,
|
|
9
|
-
`gate-config.json`, `settings.json`, and the filled
|
|
9
|
+
`gate-config.json`, `settings.json`, and the filled `<config>` are all preserved.
|
|
10
10
|
YOU then bring those generated files up to the new core yourself (§3.5) — additively, never clobbering
|
|
11
11
|
the human's choices — so `/cohorte-init-pipeline` never needs re-running for an upgrade.
|
|
12
12
|
|
|
13
13
|
## 1. Detect the install scope + current version
|
|
14
14
|
|
|
15
|
-
- **Global** install ⇒
|
|
16
|
-
|
|
15
|
+
- **Global** install ⇒ `<core>/pipeline/VERSION` exists. **Bundled** ⇒ this repo's
|
|
16
|
+
`<core>/pipeline/VERSION` exists. (Both can exist; prefer the bundled one when running inside such a
|
|
17
17
|
repo, and update both if the human wants.)
|
|
18
18
|
- **Never migrate a repo between bundled and global mode on your own.** Updating means refreshing the
|
|
19
19
|
core *in its current mode*. Only migrate (e.g. delete a bundled core in favor of the global one) if
|
|
@@ -52,7 +52,7 @@ the human's choices — so `/cohorte-init-pipeline` never needs re-running for a
|
|
|
52
52
|
|
|
53
53
|
Re-read the VERSION file(s) and print `old → new`. If unchanged, say the core was already up to date.
|
|
54
54
|
|
|
55
|
-
**Sync the pointer — in BOTH modes.** If this repo has a
|
|
55
|
+
**Sync the pointer — in BOTH modes.** If this repo has a `<state>/pipeline.json` whose `core_version`
|
|
56
56
|
differs from the core you just installed, rewrite that one field (leave every other field untouched)
|
|
57
57
|
and tell the human to commit it. In **bundled** mode the installer already did it; in **global** mode
|
|
58
58
|
**nothing does** — the installer refreshes one shared core and cannot know which repos point at it,
|
|
@@ -87,21 +87,22 @@ missing, say so. This is why `/cohorte-init-pipeline` never needs re-running for
|
|
|
87
87
|
Four of the §Reconcile steps matter specifically here:
|
|
88
88
|
|
|
89
89
|
- **Local-artifact hygiene** (§Reconcile step 8): gitignore + untrack the pipeline's runtime files
|
|
90
|
-
(
|
|
90
|
+
(`<state>/preflight.ok`, `<state>/pipeline-metrics.jsonl`, `specs/reports/`). A tracked
|
|
91
91
|
`preflight.ok` — what every pre-2.0.0 install ends up with once a release agent stages `.claude/` —
|
|
92
92
|
makes the phase gate ask on every single review dispatch, so fix it here and say so.
|
|
93
93
|
|
|
94
94
|
- **Spec-template top-up** (§Reconcile step 7): `specs/_template.md` was seeded at install and never
|
|
95
95
|
refreshed since, so add the front-matter fields the current `templates/spec.template.md` has and the
|
|
96
|
-
repo's copy lacks
|
|
96
|
+
repo's copy lacks — and drop `loop_pass`/`loop_phase`, retired with `/cohorte-loop` in 2.2.0 —
|
|
97
97
|
front-matter only, never the body.
|
|
98
98
|
|
|
99
|
-
- **Global config seed** (§Reconcile step 5): if
|
|
99
|
+
- **Global config seed** (§Reconcile step 5): if `<config>` is absent, seed it
|
|
100
100
|
from the template so the kanban + shared-vault config has a home. Never clobber an existing filled
|
|
101
|
-
file. Report what was seeded.
|
|
102
|
-
(
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
file. Report what was seeded. Then **scrub the retired `telemetry:` block** if the existing file
|
|
102
|
+
still has one (every install seeded before 2.3.0 does) — one targeted Edit deleting the block and
|
|
103
|
+
its comment header, nothing else touched. That capability was removed in 2.3.0, sender included,
|
|
104
|
+
so the block is dead config: nothing reads it, and an `enabled: true` left sitting in a file the
|
|
105
|
+
human may open reads as though data were still leaving the machine. Say you removed it.
|
|
105
106
|
- **Kanban sync** (§Reconcile step 6): resolve this project's board with
|
|
106
107
|
`<core>/pipeline/scripts/kanban-move.sh --check` — it prints either the board path or the exact
|
|
107
108
|
missing link. **Not linked** → offer to link/create a board (confirm the vault + `<folder>/Tasks.md`,
|
|
@@ -125,5 +126,5 @@ Four of the §Reconcile steps matter specifically here:
|
|
|
125
126
|
`/cohorte-update-pipeline` inside each (it will skip the already-done core update and just reconcile).
|
|
126
127
|
- **Commit** the reconciled files (`PIPELINE.md`, `.claude/`, `.mcp.json` if added) so teammates get them.
|
|
127
128
|
- The kanban config is global and user-scoped
|
|
128
|
-
(
|
|
129
|
+
(`<config>`) — never committed. The core update never touches it; only the
|
|
129
130
|
reconcile above seeds the file and writes kanban board links (into that global file, not the repo).
|
package/core/hooks/gate.py
CHANGED
|
@@ -40,6 +40,33 @@ Two extra duties beyond Bash patterns:
|
|
|
40
40
|
|
|
41
41
|
Protocol: reads the PreToolUse payload on stdin; emits a JSON permissionDecision
|
|
42
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.
|
|
43
70
|
"""
|
|
44
71
|
|
|
45
72
|
import json
|
|
@@ -54,13 +81,37 @@ import time
|
|
|
54
81
|
SPLIT = re.compile(r"&&|\|\||[;|\n]")
|
|
55
82
|
WS = re.compile(r"\s+")
|
|
56
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
|
+
|
|
57
90
|
|
|
58
91
|
def project_root() -> str:
|
|
59
|
-
return os.environ.get("CLAUDE_PROJECT_DIR", ".")
|
|
92
|
+
return os.environ.get("COHORTE_PROJECT_DIR") or os.environ.get("CLAUDE_PROJECT_DIR", ".")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# The generated per-project files (gate config + preflight stamp) live under `.claude/`
|
|
96
|
+
# on a Claude install and `.cohorte/` on every other runtime. Probe both, newest layout
|
|
97
|
+
# first, so one gate serves a repo whichever agent is driving it — and so a repo that
|
|
98
|
+
# adds a second runtime doesn't end up with two configs, one of them silently unread.
|
|
99
|
+
STATE_DIRS = (".cohorte", ".claude")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def state_path(name: str) -> str:
|
|
103
|
+
"""Path to a per-project pipeline file: the first layout that has it, else `.claude`
|
|
104
|
+
(the historical default, so a fresh write lands where existing tooling looks)."""
|
|
105
|
+
root = project_root()
|
|
106
|
+
for d in STATE_DIRS:
|
|
107
|
+
p = os.path.join(root, d, name)
|
|
108
|
+
if os.path.exists(p):
|
|
109
|
+
return p
|
|
110
|
+
return os.path.join(root, ".claude", name)
|
|
60
111
|
|
|
61
112
|
|
|
62
113
|
def load_config() -> dict:
|
|
63
|
-
path =
|
|
114
|
+
path = state_path("gate-config.json")
|
|
64
115
|
empty = {"deny": [], "ask": [], "ask_on_default_branch": [], "default_branch": "main",
|
|
65
116
|
"preflight": {}}
|
|
66
117
|
try:
|
|
@@ -187,7 +238,7 @@ def worktree_dirs(cwd: str):
|
|
|
187
238
|
|
|
188
239
|
def tree_digest(cwd: str):
|
|
189
240
|
"""The digest pipeline/scripts/preflight.sh stamps for the checkout at `cwd`: the git
|
|
190
|
-
tree id of the working tree, computed in a throwaway index (`.claude` and `specs`
|
|
241
|
+
tree id of the working tree, computed in a throwaway index (`.claude`, `.cohorte` and `specs`
|
|
191
242
|
excluded — the pipeline writes those itself). Content-addressed, so a commit of the
|
|
192
243
|
same code keeps the stamp valid and any real edit invalidates it. Must stay identical
|
|
193
244
|
to the shell side. Returns None when git can't answer, and the caller falls back."""
|
|
@@ -206,17 +257,29 @@ def tree_digest(cwd: str):
|
|
|
206
257
|
# what changed instead of the whole tree on every dispatch.
|
|
207
258
|
if os.path.exists(real_index):
|
|
208
259
|
shutil.copyfile(real_index, tmp)
|
|
260
|
+
# Age the copy by a few seconds. Git trusts an entry's cached stat data only when
|
|
261
|
+
# the entry's mtime is OLDER than the index file's; entries at or after it are
|
|
262
|
+
# "racily clean" and get re-hashed by content. copyfile stamps the temp index with
|
|
263
|
+
# `now`, so a file edited in the same second as the preflight — with an unchanged
|
|
264
|
+
# size — looked clean here and the gate greened code that had changed. Backdating
|
|
265
|
+
# forces a content check for anything touched inside that window, and leaves the
|
|
266
|
+
# stat-cache fast path (the whole point of seeding from the real index) intact for
|
|
267
|
+
# every older file.
|
|
268
|
+
old = time.time() - RACY_WINDOW_S
|
|
269
|
+
os.utime(tmp, (old, old))
|
|
209
270
|
else:
|
|
210
271
|
os.unlink(tmp) # a 0-byte index is a corrupt index — let git create it
|
|
211
272
|
env = dict(os.environ, GIT_INDEX_FILE=tmp)
|
|
212
273
|
# Drop the excluded paths outright — an `add` exclude only stops them being
|
|
213
274
|
# *updated*, so already-tracked ones would still shift the tree id.
|
|
214
275
|
subprocess.run(
|
|
215
|
-
["git", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--",
|
|
276
|
+
["git", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--",
|
|
277
|
+
".claude", ".cohorte", "specs"],
|
|
216
278
|
cwd=cwd, capture_output=True, timeout=10, env=env,
|
|
217
279
|
)
|
|
218
280
|
add = subprocess.run(
|
|
219
|
-
["git", "add", "-A", "--", ".",
|
|
281
|
+
["git", "add", "-A", "--", ".",
|
|
282
|
+
":(exclude).claude", ":(exclude).cohorte", ":(exclude)specs"],
|
|
220
283
|
cwd=cwd, capture_output=True, timeout=30, env=env,
|
|
221
284
|
)
|
|
222
285
|
if add.returncode != 0:
|
|
@@ -250,11 +313,17 @@ def check_preflight(payload: dict, cfg: dict) -> int:
|
|
|
250
313
|
if not pf.get("enabled"):
|
|
251
314
|
return 0
|
|
252
315
|
agents = pf.get("agents") or ["review"]
|
|
316
|
+
# Runtimes name a subagent dispatch differently. Claude Code and Cursor send a `Task` tool
|
|
317
|
+
# carrying `subagent_type`; Gemini exposes each subagent as a tool of the SAME NAME, so the
|
|
318
|
+
# dispatch arrives as `tool_name: review`. Accept both rather than gating only the shape
|
|
319
|
+
# one vendor happens to use — a phase gate that silently never fires is the 1.3.0 bug.
|
|
253
320
|
subagent = (payload.get("tool_input") or {}).get("subagent_type", "") or ""
|
|
321
|
+
if not subagent and payload.get("tool_name") in agents:
|
|
322
|
+
subagent = payload.get("tool_name")
|
|
254
323
|
if subagent not in agents:
|
|
255
324
|
return 0
|
|
256
325
|
|
|
257
|
-
stamp =
|
|
326
|
+
stamp = state_path("preflight.ok")
|
|
258
327
|
why = None
|
|
259
328
|
try:
|
|
260
329
|
with open(stamp, "r", encoding="utf-8") as fh:
|
|
@@ -300,20 +369,65 @@ def check_preflight(payload: dict, cfg: dict) -> int:
|
|
|
300
369
|
)
|
|
301
370
|
|
|
302
371
|
|
|
303
|
-
def
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
372
|
+
def check_cli(argv) -> int:
|
|
373
|
+
"""Explicit gate for runtimes with no PreToolUse hook.
|
|
374
|
+
|
|
375
|
+
Builds the same payload the hook would have received and runs the same evaluation,
|
|
376
|
+
then translates the verdict into a line + exit code a shell (or an agent reading the
|
|
377
|
+
output) can act on. Exit 0 = allow, 1 = ask, 2 = deny — chosen so `&&` naturally
|
|
378
|
+
stops on anything that isn't a clean allow."""
|
|
379
|
+
mode = argv[0]
|
|
380
|
+
unattended = False
|
|
381
|
+
cwd = os.getcwd()
|
|
382
|
+
words = []
|
|
383
|
+
i = 1
|
|
384
|
+
while i < len(argv):
|
|
385
|
+
a = argv[i]
|
|
386
|
+
if a == "--unattended":
|
|
387
|
+
unattended = True
|
|
388
|
+
elif a == "--cwd" and i + 1 < len(argv):
|
|
389
|
+
cwd = argv[i + 1]
|
|
390
|
+
i += 1
|
|
391
|
+
elif a.startswith("--cwd="):
|
|
392
|
+
cwd = a[6:]
|
|
393
|
+
else:
|
|
394
|
+
words.append(a)
|
|
395
|
+
i += 1
|
|
396
|
+
subject = " ".join(words).strip()
|
|
397
|
+
if not subject:
|
|
398
|
+
print(f"gate: usage — gate.py {mode} <{'command' if mode == '--check' else 'agent'}>",
|
|
399
|
+
file=sys.stderr)
|
|
400
|
+
return 2
|
|
401
|
+
|
|
402
|
+
payload = {"cwd": cwd, "permission_mode": "bypassPermissions" if unattended else "default"}
|
|
403
|
+
if mode == "--check":
|
|
404
|
+
payload["tool_name"] = "Bash"
|
|
405
|
+
payload["tool_input"] = {"command": subject}
|
|
406
|
+
else:
|
|
407
|
+
payload["tool_name"] = "Task"
|
|
408
|
+
payload["tool_input"] = {"subagent_type": subject}
|
|
409
|
+
|
|
410
|
+
# Reuse the hook path verbatim — one evaluator, so the two modes can never drift into
|
|
411
|
+
# different verdicts on the same config. `decide()` records instead of printing while
|
|
412
|
+
# DECISIONS is armed, so the hook's JSON never leaks into the human-readable output.
|
|
413
|
+
global DECISIONS
|
|
414
|
+
DECISIONS = []
|
|
310
415
|
cfg = load_config()
|
|
416
|
+
if payload["tool_name"] == "Task":
|
|
417
|
+
check_preflight(payload, cfg)
|
|
418
|
+
else:
|
|
419
|
+
check_bash(payload, cfg)
|
|
311
420
|
|
|
312
|
-
if
|
|
313
|
-
|
|
314
|
-
if tool != "Bash":
|
|
421
|
+
if not DECISIONS:
|
|
422
|
+
print("gate: allow")
|
|
315
423
|
return 0
|
|
424
|
+
decision, reason = DECISIONS[0]
|
|
425
|
+
print(f"gate: {decision} — {reason}")
|
|
426
|
+
return 2 if decision == "deny" else 1
|
|
427
|
+
|
|
316
428
|
|
|
429
|
+
def check_bash(payload: dict, cfg: dict) -> int:
|
|
430
|
+
"""Pattern gate on a Bash command. Split out of main() so --check reuses it."""
|
|
317
431
|
command = (payload.get("tool_input") or {}).get("command", "") or ""
|
|
318
432
|
deny, ask, branch_gated = cfg["deny"], cfg["ask"], cfg["ask_on_default_branch"]
|
|
319
433
|
default = cfg["default_branch"]
|
|
@@ -368,7 +482,80 @@ def main() -> int:
|
|
|
368
482
|
return 0
|
|
369
483
|
|
|
370
484
|
|
|
485
|
+
def main() -> int:
|
|
486
|
+
argv = sys.argv[1:]
|
|
487
|
+
if argv and argv[0] in ("--check", "--check-dispatch"):
|
|
488
|
+
return check_cli(argv)
|
|
489
|
+
|
|
490
|
+
global RUNTIME
|
|
491
|
+
for i, a in enumerate(argv):
|
|
492
|
+
if a == "--runtime" and i + 1 < len(argv):
|
|
493
|
+
RUNTIME = argv[i + 1]
|
|
494
|
+
elif a.startswith("--runtime="):
|
|
495
|
+
RUNTIME = a[10:]
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
payload = json.load(sys.stdin)
|
|
499
|
+
except Exception:
|
|
500
|
+
return 0 # malformed input → don't block
|
|
501
|
+
|
|
502
|
+
tool = payload.get("tool_name")
|
|
503
|
+
# Cursor's shell hook carries the command at the top level rather than in tool_input, and
|
|
504
|
+
# names no tool. Normalise once here so every check below stays runtime-agnostic.
|
|
505
|
+
if RUNTIME == "cursor" and not tool:
|
|
506
|
+
tool = "Bash"
|
|
507
|
+
payload = dict(payload, tool_name="Bash",
|
|
508
|
+
tool_input=payload.get("tool_input") or {"command": payload.get("command", "")})
|
|
509
|
+
cfg = load_config()
|
|
510
|
+
|
|
511
|
+
# A dispatch, in whichever shape this runtime sends it (see check_preflight).
|
|
512
|
+
if tool == "Task" or tool in ((cfg.get("preflight") or {}).get("agents") or ["review"]):
|
|
513
|
+
return check_preflight(payload, cfg)
|
|
514
|
+
# Bash is Claude's/Codex's name for the shell tool; Gemini calls it run_shell_command, and
|
|
515
|
+
# Cursor's beforeShellExecution was normalised to "Bash" above. Anything else is not a
|
|
516
|
+
# command we gate.
|
|
517
|
+
if tool not in ("Bash", "shell", "run_shell_command"):
|
|
518
|
+
return 0
|
|
519
|
+
return check_bash(payload, cfg)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# Armed by --check: while it is a list, decide() records the verdict instead of emitting
|
|
523
|
+
# a hook envelope, which only a hook host understands.
|
|
524
|
+
DECISIONS = None
|
|
525
|
+
|
|
526
|
+
# Which runtime is hosting this hook — set from `--runtime <id>`. Defaults to Claude Code:
|
|
527
|
+
# it is the only host that ran this file before the flag existed, so an old registration
|
|
528
|
+
# that predates the adapter keeps behaving exactly as it did.
|
|
529
|
+
RUNTIME = "claude"
|
|
530
|
+
|
|
531
|
+
# Runtimes whose hook contract has no "ask" tier. Codex parses `permissionDecision: ask` and
|
|
532
|
+
# ignores it — which would let a gated command through — and Gemini's BeforeTool only
|
|
533
|
+
# allows or denies. Escalate rather than downgrade: see the module docstring.
|
|
534
|
+
NO_ASK = {"codex", "gemini"}
|
|
535
|
+
|
|
536
|
+
|
|
371
537
|
def decide(decision: str, reason: str) -> int:
|
|
538
|
+
if DECISIONS is not None:
|
|
539
|
+
DECISIONS.append((decision, reason))
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
if decision == "ask" and RUNTIME in NO_ASK:
|
|
543
|
+
decision = "deny"
|
|
544
|
+
reason += (f" (denied outright: {RUNTIME} has no confirmation tier, so an \"ask\" here "
|
|
545
|
+
f"would silently run. Re-run it yourself if you meant to.)")
|
|
546
|
+
|
|
547
|
+
if RUNTIME == "cursor":
|
|
548
|
+
# Cursor's own envelope. `agent_message` is what the model is told, `user_message`
|
|
549
|
+
# what the human sees — the reason serves both.
|
|
550
|
+
print(json.dumps({"permission": decision, "user_message": reason, "agent_message": reason}))
|
|
551
|
+
return 2 if decision == "deny" else 0
|
|
552
|
+
|
|
553
|
+
if RUNTIME == "gemini":
|
|
554
|
+
# BeforeTool speaks {"decision", "reason"}; anything else is read as allow.
|
|
555
|
+
print(json.dumps({"decision": decision, "reason": reason}))
|
|
556
|
+
return 0
|
|
557
|
+
|
|
558
|
+
# Claude Code and Codex share the PreToolUse envelope.
|
|
372
559
|
print(
|
|
373
560
|
json.dumps(
|
|
374
561
|
{
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
"mcp": true,
|
|
62
|
+
"tool_restriction": true
|
|
63
|
+
},
|
|
64
|
+
"memory": "CLAUDE.md",
|
|
65
|
+
"headless": "claude -p",
|
|
66
|
+
"hook": {
|
|
67
|
+
"format": "claude",
|
|
68
|
+
"event": "PreToolUse",
|
|
69
|
+
"matcher": "Bash|Task",
|
|
70
|
+
"supports_ask": true,
|
|
71
|
+
"config_shape": "settings"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "codex",
|
|
3
|
+
"label": "Codex CLI (OpenAI)",
|
|
4
|
+
"docs": "https://learn.chatgpt.com/docs/build-skills",
|
|
5
|
+
"detect": [
|
|
6
|
+
"~/.codex"
|
|
7
|
+
],
|
|
8
|
+
"notes": [
|
|
9
|
+
"Commands ship as SKILLS (.agents/skills/<name>/SKILL.md), not as custom prompts. Prompts are",
|
|
10
|
+
"deprecated, and they are user-scoped only (~/.codex/prompts) \u2014 so a teammate cloning the repo",
|
|
11
|
+
"would get the profile but none of the commands. Skills are discovered from `.agents/skills` in",
|
|
12
|
+
"the repo, which means they travel with it. Invoked as $<name>, or implicitly from `description`.",
|
|
13
|
+
"Skills do NOT substitute arguments, so the preamble explains $ARGUMENTS rather than rewriting it.",
|
|
14
|
+
"Subagents are TOML, one file per agent, and the body lives in `developer_instructions`.",
|
|
15
|
+
"Hooks use the SAME PreToolUse envelope as Claude Code, with one difference that matters:",
|
|
16
|
+
"`permissionDecision: ask` is parsed but not honoured, so gate.py escalates ask to deny here.",
|
|
17
|
+
"Model pins are NOT carried across: the profile names Anthropic aliases (sonnet/haiku), which are meaningless here and would either error or be silently ignored. Agents and commands inherit this runtime's own model selection instead."
|
|
18
|
+
],
|
|
19
|
+
"scopes": {
|
|
20
|
+
"global": {
|
|
21
|
+
"root": "~/.codex",
|
|
22
|
+
"core": "~/.cohorte/codex",
|
|
23
|
+
"commands": "~/.agents/skills",
|
|
24
|
+
"agents": "~/.codex/agents",
|
|
25
|
+
"hooks": "~/.cohorte/codex/hooks",
|
|
26
|
+
"hooks_config": "~/.codex/hooks.json",
|
|
27
|
+
"workflows": null,
|
|
28
|
+
"settings": null
|
|
29
|
+
},
|
|
30
|
+
"project": {
|
|
31
|
+
"root": ".codex",
|
|
32
|
+
"core": ".cohorte/codex",
|
|
33
|
+
"commands": ".agents/skills",
|
|
34
|
+
"agents": ".codex/agents",
|
|
35
|
+
"hooks": ".cohorte/codex/hooks",
|
|
36
|
+
"hooks_config": ".codex/hooks.json",
|
|
37
|
+
"workflows": null,
|
|
38
|
+
"settings": null
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"exclude_commands": [],
|
|
42
|
+
"command": {
|
|
43
|
+
"format": "skill",
|
|
44
|
+
"ext": "/SKILL.md",
|
|
45
|
+
"frontmatter": [
|
|
46
|
+
"name",
|
|
47
|
+
"description"
|
|
48
|
+
],
|
|
49
|
+
"args": null,
|
|
50
|
+
"args_note": "the text you typed after the skill name",
|
|
51
|
+
"invoke": "$<name>"
|
|
52
|
+
},
|
|
53
|
+
"agent": {
|
|
54
|
+
"format": "toml",
|
|
55
|
+
"ext": ".toml",
|
|
56
|
+
"body_key": "developer_instructions",
|
|
57
|
+
"frontmatter": [
|
|
58
|
+
"name",
|
|
59
|
+
"description"
|
|
60
|
+
],
|
|
61
|
+
"readonly_key": "sandbox_mode",
|
|
62
|
+
"readonly_value": "read-only",
|
|
63
|
+
"dispatch": "spawn the named agent; several run in parallel and Codex returns a consolidated result",
|
|
64
|
+
"address": "<name>"
|
|
65
|
+
},
|
|
66
|
+
"hook": {
|
|
67
|
+
"format": "claude",
|
|
68
|
+
"event": "PreToolUse",
|
|
69
|
+
"matcher": "Bash|shell",
|
|
70
|
+
"supports_ask": false,
|
|
71
|
+
"config_shape": "json"
|
|
72
|
+
},
|
|
73
|
+
"capabilities": {
|
|
74
|
+
"subagents": true,
|
|
75
|
+
"hooks": true,
|
|
76
|
+
"workflows": false,
|
|
77
|
+
"mcp": true,
|
|
78
|
+
"tool_restriction": true
|
|
79
|
+
},
|
|
80
|
+
"memory": "AGENTS.md",
|
|
81
|
+
"headless": "codex exec"
|
|
82
|
+
}
|