cohorte 1.2.5 → 1.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.
@@ -16,6 +16,23 @@ A pattern matches a command segment when the segment *contains* the pattern
16
16
  (after normalizing whitespace). `deny` wins over `ask`. If the config is missing
17
17
  or unreadable, the hook stays silent (exit 0) and lets settings.json decide.
18
18
 
19
+ The hook fires for EVERY agent in the session — the lead, /build's implementers,
20
+ and subagents spawned by the Workflow runtime alike. Workflow subagents run in
21
+ acceptEdits regardless of the session's permission mode (their Write/Edit calls
22
+ are auto-approved), but acceptEdits does NOT auto-approve Bash or Task, so this
23
+ gate still sees and can block them. In bypassPermissions (headless `claude -p`,
24
+ dashboard actions) there is no human to answer a prompt, so every `ask` match is
25
+ escalated to a hard deny with the reason attached — a clear refusal beats a
26
+ prompt that can never be answered.
27
+
28
+ Two extra duties beyond Bash patterns:
29
+
30
+ - Phase gate (`preflight` block in gate-config.json): a Task dispatch of a
31
+ listed subagent_type (default review/smoke) requires a fresh
32
+ `.claude/preflight.ok` stamp, written by pipeline/scripts/preflight.sh when
33
+ typecheck+lint+tests are green. Stale/missing stamp => "ask" — dispatching
34
+ reviewers onto code that doesn't compile burns their whole run.
35
+
19
36
  Protocol: reads the PreToolUse payload on stdin; emits a JSON permissionDecision
20
37
  of "deny" or "ask" on a match; otherwise exits 0 silently.
21
38
  """
@@ -25,6 +42,7 @@ import os
25
42
  import re
26
43
  import subprocess
27
44
  import sys
45
+ import time
28
46
 
29
47
  SPLIT = re.compile(r"&&|\|\||[;|\n]")
30
48
  WS = re.compile(r"\s+")
@@ -33,18 +51,24 @@ WS = re.compile(r"\s+")
33
51
  def load_config() -> dict:
34
52
  root = os.environ.get("CLAUDE_PROJECT_DIR", ".")
35
53
  path = os.path.join(root, ".claude", "gate-config.json")
36
- empty = {"deny": [], "ask": [], "ask_on_default_branch": [], "default_branch": "main"}
54
+ empty = {"deny": [], "ask": [], "ask_on_default_branch": [], "default_branch": "main",
55
+ "preflight": {}}
37
56
  try:
38
57
  with open(path, "r", encoding="utf-8") as fh:
39
58
  cfg = json.load(fh)
40
59
  except Exception:
41
60
  return empty
61
+ preflight = cfg.get("preflight")
62
+ if not isinstance(preflight, dict):
63
+ preflight = {}
42
64
  return {
43
65
  "deny": list(cfg.get("deny", [])),
44
66
  "ask": list(cfg.get("ask", [])),
45
67
  # Patterns gated ONLY on the default branch — allowed freely on feature branches.
46
68
  "ask_on_default_branch": list(cfg.get("ask_on_default_branch", [])),
47
69
  "default_branch": cfg.get("default_branch", "main") or "main",
70
+ # Phase gate: {"enabled": true, "agents": ["review","smoke"], "max_age_minutes": 30}
71
+ "preflight": preflight,
48
72
  }
49
73
 
50
74
 
@@ -67,22 +91,83 @@ def current_branch():
67
91
  return None
68
92
 
69
93
 
94
+ def current_head():
95
+ """HEAD sha of CLAUDE_PROJECT_DIR, or None."""
96
+ root = os.environ.get("CLAUDE_PROJECT_DIR", ".")
97
+ try:
98
+ out = subprocess.run(
99
+ ["git", "rev-parse", "HEAD"],
100
+ cwd=root, capture_output=True, text=True, timeout=3,
101
+ )
102
+ if out.returncode == 0:
103
+ return out.stdout.strip() or None
104
+ except Exception:
105
+ pass
106
+ return None
107
+
108
+
109
+ def check_preflight(payload: dict, cfg: dict) -> int:
110
+ """Phase gate on Task dispatches: review/smoke agents need a green preflight stamp."""
111
+ pf = cfg.get("preflight") or {}
112
+ if not pf.get("enabled"):
113
+ return 0
114
+ agents = pf.get("agents") or ["review", "smoke"]
115
+ subagent = (payload.get("tool_input") or {}).get("subagent_type", "") or ""
116
+ if subagent not in agents:
117
+ return 0
118
+
119
+ root = os.environ.get("CLAUDE_PROJECT_DIR", ".")
120
+ stamp = os.path.join(root, ".claude", "preflight.ok")
121
+ why = None
122
+ try:
123
+ with open(stamp, "r", encoding="utf-8") as fh:
124
+ epoch_s, _, sha = fh.read().strip().partition(" ")
125
+ age_min = (time.time() - float(epoch_s)) / 60
126
+ max_age = float(pf.get("max_age_minutes", 30) or 30)
127
+ if age_min > max_age:
128
+ why = f"the preflight stamp is {age_min:.0f} min old (max {max_age:.0f})"
129
+ else:
130
+ head = current_head()
131
+ if head and sha not in ("", "none") and head != sha:
132
+ why = "HEAD moved since the preflight ran"
133
+ except Exception:
134
+ why = "no preflight stamp found"
135
+ if why is None:
136
+ return 0
137
+ return decide(
138
+ "ask",
139
+ f"Phase gate: dispatching `{subagent}` but {why}. Run the deterministic pre-flight first "
140
+ f"(pipeline/scripts/preflight.sh — typecheck + lint + tests) so agents never review red code; "
141
+ f"or confirm to dispatch anyway (PIPELINE.md gate.preflight).",
142
+ )
143
+
144
+
70
145
  def main() -> int:
71
146
  try:
72
147
  payload = json.load(sys.stdin)
73
148
  except Exception:
74
149
  return 0 # malformed input → don't block
75
150
 
76
- if payload.get("tool_name") != "Bash":
151
+ tool = payload.get("tool_name")
152
+ cfg = load_config()
153
+
154
+ if tool == "Task":
155
+ return check_preflight(payload, cfg)
156
+ if tool != "Bash":
77
157
  return 0
78
158
 
79
159
  command = (payload.get("tool_input") or {}).get("command", "") or ""
80
- cfg = load_config()
81
160
  deny, ask, branch_gated = cfg["deny"], cfg["ask"], cfg["ask_on_default_branch"]
82
161
  default = cfg["default_branch"]
83
162
  if not deny and not ask and not branch_gated:
84
163
  return 0
85
164
 
165
+ # No human can answer a prompt in bypassPermissions (headless runs) — an "ask"
166
+ # there either hangs or silently auto-resolves, so escalate it to a clear deny.
167
+ unattended = payload.get("permission_mode") == "bypassPermissions"
168
+ ask_decision = "deny" if unattended else "ask"
169
+ ask_suffix = " (denied outright: unattended run, nobody to confirm)" if unattended else ""
170
+
86
171
  # Branch-conditional patterns (e.g. git/docker) are gated only on the default branch;
87
172
  # on a feature branch they run freely. Unknown branch (no repo / detached / no git) ⇒
88
173
  # be conservative and gate. Resolve the branch once, lazily.
@@ -100,12 +185,16 @@ def main() -> int:
100
185
  return decide("deny", f"`{pat}` is forbidden by the project's PIPELINE.md gate.")
101
186
  for pat in ask:
102
187
  if norm(pat) in seg:
103
- return decide("ask", f"`{pat}` is a gated command — confirm first (PIPELINE.md gate).")
188
+ return decide(ask_decision,
189
+ f"`{pat}` is a gated command — confirm first (PIPELINE.md gate)."
190
+ f"{ask_suffix}")
104
191
  if on_default:
105
192
  for pat in branch_gated:
106
193
  if norm(pat) in seg:
107
- return decide("ask", f"`{pat}` is gated on the default branch `{default}` — confirm "
108
- f"(PIPELINE.md gate). It runs freely on feature branches.")
194
+ return decide(ask_decision,
195
+ f"`{pat}` is gated on the default branch `{default}` — confirm "
196
+ f"(PIPELINE.md gate). It runs freely on feature branches."
197
+ f"{ask_suffix}")
109
198
 
110
199
  return 0
111
200
 
@@ -1,7 +1,8 @@
1
1
  # HANDOFF — <surface> · `<feature_id>`
2
2
 
3
3
  <!-- Keep it tight: the lead only acts on mismatches, test failures, remediation ticks, and TODOs.
4
- Never list files one by one — the lead has `git diff --stat`. -->
4
+ Never list files one by one — the lead has `git diff --stat`. Never paste code excerpts —
5
+ a file:line reference is enough, the code is on disk. One line per item. -->
5
6
 
6
7
  ## Summary
7
8
 
@@ -2,7 +2,10 @@
2
2
 
3
3
  <!-- Emitted-report shape. Everything in an HTML comment is guidance — do NOT emit it.
4
4
  Verdict rules live in the review agent's instructions (SHIP = no CRITICAL/security;
5
- REVISE = ≥1 CRITICAL; BLOCK = security vulnerability). -->
5
+ REVISE = ≥1 CRITICAL; BLOCK = security vulnerability).
6
+ CAPPED: max 20 findings, ONE line each (severity · file:line · type · concrete fix),
7
+ ZERO code excerpts — file:line is enough, the source is on disk. Overflow ⇒ keep all
8
+ CRITICAL/HIGH/security, fill by severity, close with one `+<n> more …` line. -->
6
9
 
7
10
  feature_id: <feature_id>
8
11
  Feature branch: <feature_branch_prefix><feature_id>
@@ -15,6 +15,13 @@ Ask ONLY what you couldn't confidently detect. Batch related questions. Cover:
15
15
  per SCHEMA.md §Specialization. If the human accepts, apply the rules: **shared code (routing, global
16
16
  state, DS kit/tokens) becomes its own single-owner surface**, and cross-slice shapes go through the
17
17
  contract. Default to NOT splitting when boundaries are tangled or slices are tiny — coarse is fine.
18
+ - **Quiet commands** (never store a bare `pnpm test` as what agents execute) — for each noisy command
19
+ (tests, lint; per surface AND repo-wide), propose the detected **bridled variant** as the
20
+ Recommended option: dot/failures-only reporter (`--reporter=dot` vitest/playwright, `--silent`
21
+ jest, `-q` pytest, `--quiet` eslint/ruff — whatever the detected runner supports). These land in
22
+ `test_quiet_cmd`/`lint_quiet_cmd` + `commands.test_quiet`/`lint_quiet` and are what agents and the
23
+ `/review`·`/smoke` pre-flight actually run (SCHEMA.md §Output discipline). If the human declines or
24
+ the runner has no such flag, leave `""` — consumers then fall back to `<cmd> 2>&1 | tail -40`.
18
25
  - **Contract** — mechanism (`shared-types-zod` / `openapi` / `protobuf` / `json-schema` / `none`) and
19
26
  where feature contracts are authored. If `none`, surfaces sync by the spec prose alone.
20
27
  - **UI language** — language of all user-facing copy.
@@ -9,18 +9,26 @@
9
9
  3. **Render one agent per surface** — for each surface, follow SCHEMA.md §"Rendering / reconciling a
10
10
  surface agent" (steps 2–3): render `.claude/agents/<agent>.md` from the installer's
11
11
  `pipeline/implementer.template.md`, substituting `<SURFACE_AGENT>`, `<SURFACE_LABEL>`, `<SURFACE_PATH>`,
12
- `<SURFACE_TOOLS>`, `<SURFACE_MODEL>`, `<PROJECT_NAME>`, and the surface-specific blocks
12
+ `<SURFACE_TOOLS>`, `<SURFACE_MODEL>`, `<PROJECT_NAME>`, `<SURFACE_CONVENTIONS>` (the surface's
13
+ baked convention slice — §Shared + its `### Surface:` stanza + its §Testing lines from the
14
+ PIPELINE.md you just wrote), and the surface-specific blocks
13
15
  (`<SURFACE_EXTRA_NEVER>`, `<SURFACE_DESIGN_INPUT>`, `<SURFACE_TDD_STEP1>` — fill design-related ones
14
16
  only when `uses_design`).
15
- Leave `review.md` + `release.md` as-is (generic).
16
- 4. **Generate `.claude/gate-config.json`** from the `gate` block — copy all four keys verbatim:
17
- `{"deny": [...], "ask": [...], "ask_on_default_branch": [...], "default_branch": "<vcs.default_branch>"}`.
17
+ Leave `review.md` + `release.md` + `profile-reader.md` as-is (generic).
18
+ 4. **Generate `.claude/gate-config.json`** from the `gate` block — copy all five keys verbatim:
19
+ `{"deny": [...], "ask": [...], "ask_on_default_branch": [...], "default_branch": "<vcs.default_branch>",
20
+ "preflight": {"enabled": <gate.preflight.enabled>, "agents": [...], "max_age_minutes": <n>}}`
21
+ (profile has no `preflight` block ⇒ omit the key — the hook then skips the phase gate).
18
22
  5. **Write `.claude/settings.json`** permissions (`ask`/`deny` lists mirroring the gate, **plus an
19
23
  `allow` list of the project's read-only / verification commands** so agents don't stall on
20
- permission prompts: the detected per-surface `test_cmd`/`lint_cmd`/`typecheck_cmd`/`build_cmd`
21
- and repo-wide `commands.*` equivalents as `Bash(<cmd>:*)` rules, plus read-only git —
22
- `Bash(git status:*)`, `Bash(git diff:*)`, `Bash(git log:*)`. Never allowlist anything matching a
23
- `gate.ask`/`gate.deny` pattern. Mention the human can widen it later with
24
+ permission prompts — including mid-workflow, where nobody is watching a prompt: the detected
25
+ per-surface `test_cmd`/`lint_cmd`/`typecheck_cmd`/`build_cmd` **and their `*_quiet_cmd`
26
+ variants** and repo-wide `commands.*` equivalents as `Bash(<cmd>:*)` rules, plus read-only git
27
+ `Bash(git status:*)`, `Bash(git diff:*)`, `Bash(git log:*)`, `Bash(git rev-parse:*)` plus the
28
+ shipped pipeline scripts for BOTH cores (`Bash(.claude/pipeline/scripts/:*)` and
29
+ `Bash(~/.claude/pipeline/scripts/:*)` — preflight, kanban-move, telemetry-send), and the
30
+ retrieval provider's MCP tools when wired (e.g. `mcp__serena`). Never allowlist anything matching
31
+ a `gate.ask`/`gate.deny` pattern. Mention the human can widen it later with
24
32
  `/fewer-permission-prompts`) + the hooks, **conditioned on the install mode:**
25
33
  - **bundled:** register the PreToolUse `Bash` hook `.claude/hooks/gate.py` and the PostToolUse
26
34
  formatter (detected formatter).
@@ -0,0 +1,144 @@
1
+ // cohorte — /audit as a deterministic workflow (opt-in; the conversational
2
+ // /audit command remains the default path and the fallback).
3
+ //
4
+ // Invoke with args = {target: "<path or domain>"} (optional — default whole repo).
5
+ //
6
+ // Shape (SCHEMA.md §Workflows): profile via profile-reader (phase 0), the
7
+ // mechanical gates staged to disk by one haiku agent, then ONE auditor per
8
+ // domain (each surface + `shared`) running concurrently — the runtime caps
9
+ // concurrency at ~16, extra domains queue — and a merge phase that writes the
10
+ // prioritized specs/refactor-backlog.md. Only the summary comes back.
11
+
12
+ export const meta = {
13
+ name: 'cohorte-audit',
14
+ description: 'Audit the codebase against PIPELINE.md: mechanical gates, one auditor per domain in parallel, prioritized refactor backlog',
15
+ whenToUse: 'Only when the human explicitly asks for the audit workflow. args = {target: "<path or domain>"} — omit for the whole repo.',
16
+ phases: [
17
+ { title: 'Profile', detail: 'PIPELINE.md → JSON via profile-reader', model: 'haiku' },
18
+ { title: 'Gates', detail: 'format/lint/typecheck/tests → specs/reports/audit-gates.txt', model: 'haiku' },
19
+ { title: 'Audit', detail: 'one review-in-audit-mode agent per domain (concurrent, runtime-capped ~16)' },
20
+ { title: 'Backlog', detail: 'merge + write specs/refactor-backlog.md', model: 'haiku' },
21
+ ],
22
+ }
23
+
24
+ const target = (typeof args === 'string' ? args.trim() : args && args.target) || ''
25
+
26
+ const PROFILE = { type: 'object', additionalProperties: true }
27
+
28
+ const GATES = {
29
+ type: 'object', required: ['failures'], additionalProperties: false,
30
+ properties: {
31
+ failures: {
32
+ type: 'array', maxItems: 40,
33
+ items: {
34
+ type: 'object', required: ['file', 'line', 'kind', 'summary'], additionalProperties: false,
35
+ properties: {
36
+ file: { type: 'string' }, line: { type: 'integer' },
37
+ kind: { enum: ['lint', 'format', 'type', 'test'] },
38
+ summary: { type: 'string', description: 'one line, no output excerpts' },
39
+ },
40
+ },
41
+ },
42
+ overflow: { type: 'integer', description: 'failures beyond the 40-item cap' },
43
+ },
44
+ }
45
+
46
+ const BACKLOG = {
47
+ type: 'object', required: ['items'], additionalProperties: false,
48
+ properties: {
49
+ items: {
50
+ type: 'array', maxItems: 30,
51
+ items: {
52
+ type: 'object', required: ['severity', 'file', 'line', 'kind', 'fix'], additionalProperties: false,
53
+ properties: {
54
+ severity: { enum: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] },
55
+ file: { type: 'string' }, line: { type: 'integer' },
56
+ kind: { enum: ['rule', 'tdd', 'lint', 'format', 'type', 'security'] },
57
+ fix: { type: 'string', description: 'one concrete change, one line, no code excerpts' },
58
+ },
59
+ },
60
+ },
61
+ overflow: { type: 'integer' },
62
+ },
63
+ }
64
+
65
+ // ── Phase 0 — profile ────────────────────────────────────────────────────────
66
+ phase('Profile')
67
+ const profile = await agent(
68
+ 'Return this project\'s PIPELINE.md `yaml pipeline-profile` block as JSON, per your instructions.',
69
+ { agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
70
+ )
71
+ if (!profile || profile.error) {
72
+ return { error: `profile unreadable: ${(profile && profile.error) || 'profile-reader returned nothing'}` }
73
+ }
74
+ const cmds = profile.commands || {}
75
+ const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
76
+ const quiet = (q, full) => (q && !String(q).startsWith('<') ? q : full ? `${full} 2>&1 | tail -40` : '')
77
+ const scope = target || 'the whole repo'
78
+
79
+ // ── Phase 1 — mechanical gates, staged to disk ───────────────────────────────
80
+ phase('Gates')
81
+ const gateCmds = [
82
+ cmds.format ? `${cmds.format} --check` : '',
83
+ quiet(cmds.lint_quiet, cmds.lint),
84
+ cmds.typecheck,
85
+ quiet(cmds.test_quiet, cmds.test),
86
+ ].filter(c => c && !String(c).startsWith('<'))
87
+ const gates = await agent(
88
+ `Run the cohorte audit's mechanical gates, scoped to ${scope}. Commands (adapt the format one to the ` +
89
+ `project's check mode if --check is wrong): ${gateCmds.map(c => JSON.stringify(c)).join(' · ')}.\n` +
90
+ 'Redirect EVERY command\'s output into specs/reports/audit-gates.txt (append, `cmd >> file 2>&1`) — ' +
91
+ 'never print it — and keep going after failures (this is an inventory, not a gate to pass). Then grep ' +
92
+ 'the file and return each failure as file/line/kind/one-line summary, capped at 40 (set overflow for the rest).',
93
+ { model: 'haiku', label: 'gates', schema: GATES, effort: 'low' },
94
+ )
95
+ const mech = (gates && gates.failures) || []
96
+ log(`Mechanical failures: ${mech.length}${gates && gates.overflow ? ` (+${gates.overflow} overflow)` : ''}`)
97
+
98
+ // ── Phase 2 — one auditor per domain, concurrent ─────────────────────────────
99
+ // Domains = every surface + `shared` (contract package + anything outside the
100
+ // surface trees). The runtime caps concurrent agents (~16); more domains queue.
101
+ phase('Audit')
102
+ const domains = surfaces.map(s => ({ key: s.key, path: s.path }))
103
+ .concat([{ key: 'shared', path: (profile.contract && profile.contract.path) || '(everything outside the surface trees)' }])
104
+ .filter(d => !target || target === d.key || String(d.path).startsWith(target) || target.startsWith(String(d.path)))
105
+ if (!domains.length) return { error: `target "${target}" matches no surface/domain` }
106
+
107
+ const audited = await parallel(domains.map(d => () => agent(
108
+ 'Audit a target against PIPELINE.md (no spec — audit mode, per your agent instructions). Check ' +
109
+ 'conventions for the domain\'s surface, TDD coverage (untested entry points / modules), and — if the ' +
110
+ 'profile enables them — mobile-first + design-system usage. Mechanical findings are already staged: ' +
111
+ 'read specs/reports/audit-gates.txt and fold the ones in your domain in. Return the prioritized items, ' +
112
+ 'capped at 30, one line each, no code excerpts. — Variable slots: domain: ' +
113
+ `${d.key} · tree: ${d.path}${target ? ` · human-requested target: ${target}` : ''}`,
114
+ { agentType: 'review', label: `audit:${d.key}`, schema: BACKLOG },
115
+ ).then(r => r && { key: d.key, items: r.items, overflow: r.overflow || 0 })))
116
+ const perDomain = audited.filter(Boolean)
117
+
118
+ // ── Phase 3 — merge + write the backlog ──────────────────────────────────────
119
+ phase('Backlog')
120
+ const SEV = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
121
+ const body = ['# Refactor backlog', '', `> Generated by the cohorte-audit workflow (scope: ${scope}).`]
122
+ let total = 0
123
+ for (const d of perDomain) {
124
+ const items = [...d.items].sort((a, b) => SEV[a.severity] - SEV[b.severity])
125
+ total += items.length
126
+ body.push('', `## ${d.key}`, '')
127
+ for (const it of items) body.push(`- [ ] ${it.severity} · ${it.file}:${it.line} · ${it.kind} · ${it.fix}`)
128
+ if (d.overflow) body.push(`- [ ] (+${d.overflow} more beyond the cap — re-audit ${d.key} after this pass)`)
129
+ }
130
+ await agent(
131
+ `Write EXACTLY this content to specs/refactor-backlog.md (overwrite), then return the single word done:\n<<<BACKLOG\n${body.join('\n')}\nBACKLOG`,
132
+ { model: 'haiku', label: 'write-backlog', effort: 'low' },
133
+ )
134
+
135
+ return {
136
+ backlog: 'specs/refactor-backlog.md',
137
+ mechanicalFailures: mech.length,
138
+ domains: Object.fromEntries(perDomain.map(d => [d.key, d.items.length + (d.overflow || 0)])),
139
+ total,
140
+ top: perDomain.flatMap(d => d.items.map(it => ({ ...it, domain: d.key })))
141
+ .sort((a, b) => SEV[a.severity] - SEV[b.severity]).slice(0, 10)
142
+ .map(it => `[${it.severity}] ${it.domain} · ${it.file}:${it.line} — ${it.fix}`),
143
+ next: 'refactor a domain with /refactor <domain> (or the refactor workflow for big domains)',
144
+ }