From 4c3a624962a16024652adf02948e843c81438265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=82nderson=20Q?= Date: Fri, 14 Aug 2026 11:39:19 -0300 Subject: [PATCH] chore(crew): onboard to the CLU gates + kit 1.10.0 crew capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo ran with no permission floor, no gates and no worktree enforcement. All of it lands here. - `.claude/hooks/dangerous-command-gate.sh`: the permission floor. A PreToolUse Bash gate wired first in the chain, matching the operation rather than the spelling — it catches `git -C reset --hard`, `git clean -xdf`, reordered flags and env prefixes that a `permissions.deny` prefix pattern walks straight past, and it fires in every permission mode including bypassPermissions. - `.claude/settings.json`: the deny list as one readable declarative layer (not the floor), the ask rules on `gh pr merge` / `gh repo create` / DELETE API, `worktree.bgIsolation: none`, and `ponytail@ponytail` through the author's own marketplace. - `.claude/hooks/`: the rest of the kit set; gate ledger `2026-08-13.1`. - `.claude/tron-clu-{merge,worktree,pulse}-guard.sh`: byte-identical to the copies canon carries, wired the same way. Inert until `.tron-clu-active` exists. - `.mcp.json`: exa on its keyless hosted path, plus github, chrome-devtools and playwright. - `.pi/`: the seven-package pin set and the user-level routing example. Where the repo carried no `.repo-class` / `.integration-branch`, both are added here. The class is `app` deliberately: it is the fail-safe of the three, since only `canon` and `meta` may take the no-PR fast-forward landing. The integration branch is read from the remote's own default, never assumed. No PostToolUse hook: there is no lint, typecheck or test command that runs in a fresh worktree without an install step. Refs: plan-canon-crew-extensions.md P4, canon principles-base.md §19. --- .claude/hooks/README.md | 60 +++++++++ .claude/hooks/dangerous-command-gate.sh | 115 ++++++++++++++++ .claude/hooks/gate-ledger-gate.sh | 134 +++++++++++++++++++ .claude/hooks/linear-card-lint.sh | 80 ++++++++++++ .claude/hooks/persona-anchor-inject.sh | 64 +++++++++ .claude/hooks/telegram-send-check.sh | 82 ++++++++++++ .claude/settings.json | 104 +++++++++++++++ .claude/tron-clu-merge-guard.sh | 166 ++++++++++++++++++++++++ .claude/tron-clu-pulse-guard.sh | 64 +++++++++ .claude/tron-clu-worktree-guard.sh | 80 ++++++++++++ .gitignore | 6 + .integration-branch | 1 + .mcp.json | 32 +++++ .pi/settings.json | 11 ++ .pi/web-search.example.json | 7 + 15 files changed, 1006 insertions(+) create mode 100644 .claude/hooks/README.md create mode 100755 .claude/hooks/dangerous-command-gate.sh create mode 100755 .claude/hooks/gate-ledger-gate.sh create mode 100755 .claude/hooks/linear-card-lint.sh create mode 100755 .claude/hooks/persona-anchor-inject.sh create mode 100755 .claude/hooks/telegram-send-check.sh create mode 100644 .claude/settings.json create mode 100755 .claude/tron-clu-merge-guard.sh create mode 100755 .claude/tron-clu-pulse-guard.sh create mode 100755 .claude/tron-clu-worktree-guard.sh create mode 100644 .integration-branch create mode 100644 .mcp.json create mode 100644 .pi/settings.json create mode 100644 .pi/web-search.example.json diff --git a/.claude/hooks/README.md b/.claude/hooks/README.md new file mode 100644 index 0000000..72c800f --- /dev/null +++ b/.claude/hooks/README.md @@ -0,0 +1,60 @@ +# Kit hooks — deterministic gates + +Hook scripts shipped by the scaffold kit and wired into `../settings.json`. They are the +harness-enforcement layer of the deterministic-fleet plan: the model proposes, these +scripts validate. **A gate counts as landed only where its hook is installed** — the +canon text alone is explicitly inert. + +| Script | Event · matcher | Enforces | +|:--|:--|:--| +| `dangerous-command-gate.sh` | `PreToolUse` · `Bash` (**first** in the chain) | Permission floor (`principles-base.md` §19): destructive git/gh operations and `.env` reads, matched on the normalised operation rather than a literal prefix | +| `linear-card-lint.sh` | `PreToolUse` · `mcp__linear__save_issue` | Card contract (`skill-linear-cards.md`): agent state vocabulary, fleet assignee, owner line + signature + universal label on create | +| `telegram-send-check.sh` | `PreToolUse` · `Bash` | Channel registry (`telegram-channels.md`): a send's chat ID must equal the registered channel for this project, or it is refused | +| `gate-ledger-gate.sh` | `PreToolUse` · `Bash` | Gate ledger (`skill-gate-ledger.md`): PR/merge/deploy commands blocked while the active block is missing prior-stage records | +| `persona-anchor-inject.sh` | `SessionStart` · `compact` | Persona persistence (`principles-base.md` §17): re-injects `*-anchor.md` files after every compaction | + +## Per-project configuration + +Each script runs on built-in fleet defaults; optional config files beside the scripts +override them (all untracked-safe, none required to boot): + +- `linear-lint.config.json` — `{ "allowed_states": [...], "assignee": "..." }` +- `telegram-channels.local.json` — **required before any Telegram send**; written once at + project start after the operator confirms the channel. Prefer `chat_id_env` (secrets by + ENV NAME only); keep this file untracked. +- `gate-ledger.config.json` — `{ "ledger_dir", "active_block_file", "pr_pattern", "merge_pattern", "deploy_pattern" }`. + `pr_pattern` is **default off**: absent, the `gh pr create` surface is inert; set (e.g. + `"gh[[:space:]]+pr[[:space:]]+create"`), PR creation requires `build challenge validation` + recorded. A supervisor that turns it on removes the key again at teardown. +- `persona-anchors.config.json` — `{ "paths": ["glob", ...] }` + +## Semantics + +- Refusals exit `2` with the reason on stderr — the agent reads it and corrects course; + it never works around a refusal. +- `jq` is required to parse hook input; if missing, the PreToolUse gates skip **loudly** + (stderr warning) rather than break every tool call. Install `jq` to arm them. +- The gate-ledger gate activates only while `.tron-active-block` (project root) names an + **active** block. The supervising process writes one entry per line — `` (active) or + ` closed` (closed-marked: the block's evidence landed, its gates are down) — appending + at block start and marking at block close. **Zero active entries is inert**: the gate + passes everything, exactly as an absent file does. +- Block resolution is **fail-closed and never branch-derived**: an explicit `TRON_BLOCK=` + prefix on the command wins (active entry → that block governs; closed-marked entry → + ungated; an id in no entry → **refused**, so a mistype never silently disarms the gate); + else a single active entry governs; else two or more active entries without a token are + refused with the token form named. A guarded command is never passed unresolved. +- The script header carries a `gate-ledger-gate-version:` line — a supervisor tests an + installed copy's **marker value** against the current template rather than its mere + presence, so a stale retrofitted copy is detectable. +- The permission floor is the **hook**, not `settings.json → permissions.deny`. Deny entries are + literal prefix matches (`git -C reset --hard` and `git clean -xdf` walk past them) and are + skipped entirely in bypassPermissions mode; PreToolUse hooks run in **every** mode. The deny block + stays as one readable, tracked layer that stops the direct form — never as the guarantee. + `disableBypassPermissionsMode` is deliberately **not** shipped: it locks the operator out of their + own repo and defends nothing the gate does not already defend. +- `dangerous-command-gate.sh` matches command **text**. It normalises away the usual disguises + (`git -C`, env prefixes, `sudo`, absolute paths, reordered flags) but indirection through a + variable (`g="reset"; git $g --hard`) still defeats it. Documented in the script header; the gate + is a floor against accidents and defaults, not an adversary-proof sandbox. +- Retrofits install these same files unchanged; new projects are born with them. diff --git a/.claude/hooks/dangerous-command-gate.sh b/.claude/hooks/dangerous-command-gate.sh new file mode 100755 index 0000000..8402372 --- /dev/null +++ b/.claude/hooks/dangerous-command-gate.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# tron kit — Dangerous command gate (PreToolUse hook, matcher: Bash). +# Contract it enforces: canon `principles-base.md §19` permission floor. +# +# Why this exists as a hook and not only as a `permissions.deny` list: +# +# 1. `deny` entries are matched as LITERAL PREFIXES. `Bash(git reset --hard:*)` never sees +# `git -C reset --hard`, and `Bash(git clean -fdx:*)` never sees `git clean -xdf`. +# Both walk-arounds were reproduced against the 1.9.0 block (KONDO, 2026-08-14). +# 2. `deny` is skipped entirely in bypass-permissions mode. Hooks are not permissions — they run +# in every mode — so the floor holds without taking the operator's bypass away from them. +# +# The declarative `deny` block stays in settings.json beside this: it is readable in the repo and it +# stops the direct form even where this hook is missing. This gate is the layer that holds. +# +# What it refuses (after normalising the command, so `git -C P`, `--git-dir=`, `-c k=v`, env +# prefixes and reordered short flags are all seen for what they are): +# - force push in any spelling, including --force-with-lease and --mirror +# - git reset --hard, git clean -f, git filter-branch / filter-repo +# - --no-verify (and `git commit -n`), which walks past the canon commit gates +# - gh pr merge --auto (canon: auto-merge is never armed — the operator clicks) +# - gh repo delete, rm -rf / +# - reading a real .env through Bash (`cat`/`less`/`grep`/… ), which the Read deny cannot see +# +# Limits, stated honestly: it matches the command TEXT. Indirection defeats it +# (`g=reset; git $g --hard`, a wrapper script, `eval "$(printf …)"`). It is a floor, not a sandbox. + +input=$(cat) + +if ! command -v jq >/dev/null 2>&1; then + echo "dangerous-command-gate: jq not found — check skipped, destructive-command floor UNENFORCED this call. Install jq to arm this gate." >&2 + exit 0 +fi + +cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') +[ -n "$cmd" ] || exit 0 + +# --- normalise ------------------------------------------------------------------------------- +# Split compound commands so each segment is judged on its own, then strip the wrappers that make +# an identical operation look different: leading env assignments, absolute paths to the binary, +# `git -C `, `--git-dir=`/`--work-tree=`, `-c key=value`, `--no-pager`. +segments=$(printf '%s' "$cmd" | tr '\n;|&' '\n\n\n\n') + +norm() { + printf '%s' "$1" \ + | sed -E 's/^[[:space:]]*(\(|\{)?[[:space:]]*//' \ + | sed -E 's/^([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)+//' \ + | sed -E 's#^[[:space:]]*(sudo|command|nohup|time)[[:space:]]+##' \ + | sed -E 's#^[[:space:]]*(/[^[:space:]]*/)?(git|gh)([[:space:]])#\2\3#' \ + | sed -E 's#\B-C[[:space:]]+[^[:space:]]+##g; s#[[:space:]]-C[[:space:]]+[^[:space:]]+##g' \ + | sed -E 's#--git-dir(=| )[^[:space:]]+##g; s#--work-tree(=| )[^[:space:]]+##g' \ + | sed -E 's#[[:space:]]-c[[:space:]]+[^[:space:]]+=[^[:space:]]+##g' \ + | sed -E 's#--no-pager##g' \ + | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//' +} + +refuse() { + echo "dangerous-command-gate: REFUSED — $1" >&2 + echo "Canon principles-base.md §19 (permission floor). This gate reads the whole command, so \`git -C \`, reordered flags and env prefixes do not step around it. If the operation is genuinely needed, it is the operator's call — surface it as a typed question (§11) rather than rephrasing the command." >&2 + exit 2 +} + +while IFS= read -r seg; do + [ -n "$seg" ] || continue + n=$(norm "$seg") + [ -n "$n" ] || continue + + case "$n" in + git\ *) + # force push, any spelling + printf '%s' "$n" | grep -qE '^git .*\bpush\b' && \ + printf '%s' "$n" | grep -qE '(--force-with-lease|--force\b|--mirror\b|(^|[[:space:]])-[A-Za-z]*f([[:space:]]|$))' && \ + refuse "force push. The integration branch's history is shared; a force push rewrites what others already have." + + printf '%s' "$n" | grep -qE '^git .*\breset\b.*--hard' && \ + refuse "\`git reset --hard\` discards uncommitted work irreversibly." + + printf '%s' "$n" | grep -qE '^git .*\bclean\b' && \ + printf '%s' "$n" | grep -qE '(--force\b|(^|[[:space:]])-[A-Za-z]*f)' && \ + refuse "\`git clean -f\` deletes untracked files, which git cannot bring back." + + printf '%s' "$n" | grep -qE '^git .*\b(filter-branch|filter-repo)\b' && \ + refuse "history rewriting." + + printf '%s' "$n" | grep -qE '^git .*\b(commit|push|merge)\b.*--no-verify' && \ + refuse "\`--no-verify\` skips the canon commit and push gates." + + printf '%s' "$n" | grep -qE '^git .*\bcommit\b.*(^|[[:space:]])-[A-Za-z]*n[A-Za-z]*([[:space:]]|$)' && \ + refuse "\`git commit -n\` is \`--no-verify\`; it skips the canon commit gates." + ;; + gh\ *) + # Anchor on the subcommand pair. `.*` between `gh` and the verb would swallow + # `gh pr merge --repo X --delete-branch`, which is an ordinary landing. + printf '%s' "$n" | grep -qE '^gh( +--[A-Za-z-]+([= ][^ ]+)?)* +pr +merge\b.*(^|[[:space:]])--auto([[:space:]]|=|$)' && \ + refuse "auto-merge. Canon: the merge is the operator's click, never armed to fire later." + + printf '%s' "$n" | grep -qE '^gh( +--[A-Za-z-]+([= ][^ ]+)?)* +repo +delete\b' && \ + refuse "repo deletion." + ;; + esac + + printf '%s' "$n" | grep -qE '(^|[[:space:]])rm[[:space:]]+(-[A-Za-z]+[[:space:]]+)*-{0,2}[A-Za-z]*[rR][A-Za-z]*f?[A-Za-z]*[[:space:]]+/([[:space:]]|$)' && \ + refuse "\`rm -rf /\`." + + # A real .env read through Bash. `.env.example`/`.sample`/`.template` are documentation and pass. + printf '%s' "$n" | grep -qE '(^|[[:space:]])(cat|bat|less|more|head|tail|strings|xxd|od|nl|grep|rg|awk|sed)([[:space:]]|$)' && \ + printf '%s' "$n" | grep -qE '(^|[[:space:]/])\.env([[:space:]]|$|\.[A-Za-z0-9_-]+)' && \ + ! printf '%s' "$n" | grep -qE '\.env\.(example|sample|template|dist)' && \ + refuse "reading a .env through Bash. Secrets are referenced by ENV NAME, never read into a transcript." + +done < — active: the block's gates are up +# closed — closed-marked: the block's evidence landed, its gates are down +# No active-block file, or a file with zero active entries → no supervised block in +# flight → this hook passes everything (supervisors still self-enforce the ledger as +# canon text). +# +# Block resolution is fail-closed and never branch-derived: +# 1. an explicit `TRON_BLOCK=` token in the command wins — an active entry +# governs; a closed-marked entry is ungated; an id in no entry is REFUSED +# (a mistyped id must never silently disarm the gate); +# 2. else a single active entry governs every guarded command; +# 3. else (two or more active entries, no token) the command is REFUSED with the +# token form named — never passed unresolved. +# +# Guarded surfaces (defaults; override via config): +# pr — `gh pr create` → requires build..validation recorded +# CONFIG-GATED, DEFAULT OFF: inert unless `pr_pattern` is set. +# merge — `git merge`, `gh pr merge` → requires build..ci recorded +# deploy — `vercel ... --prod`, deploy runs → requires build..merge recorded +# +# Config (optional): .claude/hooks/gate-ledger.config.json +# { "ledger_dir": "myproj-meta/blocks/ledger", +# "active_block_file": ".tron-active-block", +# "pr_pattern": "...", "merge_pattern": "...", "deploy_pattern": "..." } +# Installed per project at retrofit — inert until wired into .claude/settings.json. + +input=$(cat) + +if ! command -v jq >/dev/null 2>&1; then + echo "gate-ledger-gate: jq not found — gate skipped, ledger UNENFORCED this call. Install jq to arm this gate." >&2 + exit 0 +fi + +cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') +[ -n "$cmd" ] || exit 0 + +proj="${CLAUDE_PROJECT_DIR:-.}" +cfg="$proj/.claude/hooks/gate-ledger.config.json" + +active_file="$proj/.tron-active-block" +ledger_dir="" +pr_pattern='' +merge_pattern='(^|[;&|[:space:]])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+merge|gh[[:space:]]+pr[[:space:]]+merge' +deploy_pattern='vercel[[:space:]][^;|&]*--prod|gh[[:space:]]+workflow[[:space:]]+run[[:space:]][^;|&]*deploy' +if [ -f "$cfg" ]; then + v=$(jq -r '.active_block_file // empty' "$cfg" 2>/dev/null); [ -n "$v" ] && active_file="$proj/$v" + v=$(jq -r '.ledger_dir // empty' "$cfg" 2>/dev/null); [ -n "$v" ] && ledger_dir="$proj/$v" + v=$(jq -r '.pr_pattern // empty' "$cfg" 2>/dev/null); [ -n "$v" ] && pr_pattern="$v" + v=$(jq -r '.merge_pattern // empty' "$cfg" 2>/dev/null); [ -n "$v" ] && merge_pattern="$v" + v=$(jq -r '.deploy_pattern // empty' "$cfg" 2>/dev/null); [ -n "$v" ] && deploy_pattern="$v" +fi + +[ -f "$active_file" ] || exit 0 + +stage="" +[ -n "$pr_pattern" ] && printf '%s' "$cmd" | grep -qE "$pr_pattern" && stage="pr" +printf '%s' "$cmd" | grep -qE "$merge_pattern" && stage="merge" +printf '%s' "$cmd" | grep -qE "$deploy_pattern" && stage="deploy-verify" +[ -n "$stage" ] || exit 0 + +# Parse the active-block file: one entry per line, `` or ` closed`. +# Any marker that is not exactly `closed` counts as ACTIVE — fail-closed. +active_ids=""; closed_ids=""; all_ids="" +while IFS= read -r line || [ -n "$line" ]; do + # shellcheck disable=SC2086 + set -- $line + [ -n "$1" ] || continue + all_ids="$all_ids $1" + if [ "$2" = "closed" ]; then closed_ids="$closed_ids $1"; else active_ids="$active_ids $1"; fi +done < "$active_file" + +in_list() { case " $2 " in *" $1 "*) return 0 ;; esac; return 1; } + +token=$(printf '%s' "$cmd" | grep -oE 'TRON_BLOCK=[^[:space:];&|"'"'"']+' | head -1 | sed 's/^TRON_BLOCK=//') + +block_id="" +if [ -n "$token" ]; then + if in_list "$token" "$active_ids"; then + block_id="$token" + elif in_list "$token" "$closed_ids"; then + exit 0 # closed-marked: the block's evidence landed, its gates are down + else + echo "gate-ledger-gate: REFUSED — TRON_BLOCK=$token names no entry in $active_file (entries:$all_ids). A mistyped block id never disarms the gate: use the exact id of an active entry, or drop the token where a single active entry governs." >&2 + exit 2 + fi +else + set -- $active_ids + case $# in + 0) exit 0 ;; # zero active entries → no active block → pass + 1) block_id="$1" ;; + *) echo "gate-ledger-gate: REFUSED — $active_file holds $# active blocks ($active_ids) and the command names none. Prefix the command with 'TRON_BLOCK= ' to say which block this $stage action advances." >&2 + exit 2 ;; + esac +fi + +# Locate the ledger: configured dir first, then the kit's standard homes. +ledger="" +for d in "$ledger_dir" "$proj"/*-meta/blocks/ledger "$proj/blocks/ledger" "$proj/meta/blocks/ledger"; do + [ -n "$d" ] && [ -f "$d/$block_id.ledger.md" ] && { ledger="$d/$block_id.ledger.md"; break; } +done +if [ -z "$ledger" ]; then + echo "gate-ledger-gate: REFUSED — active block '$block_id' has no ledger file ({meta}/blocks/ledger/$block_id.ledger.md). No record, no advance (skill-gate-ledger.md): create the ledger and record the completed stages before a $stage action." >&2 + exit 2 +fi + +case "$stage" in + pr) required="build challenge validation" ;; + merge) required="build challenge validation review ci" ;; + deploy-verify) required="build challenge validation review ci merge" ;; +esac + +missing="" +for s in $required; do + grep -qE "^## $s — (DONE|n/a)" "$ledger" || missing="$missing $s" +done + +if [ -n "$missing" ]; then + echo "gate-ledger-gate: REFUSED — block '$block_id' is missing ledger record(s) for:$missing (ledger: $ledger). No record, no advance (skill-gate-ledger.md): append '## {stage} — DONE' with when/by/evidence for each completed stage (or the block-start '## {stage} — n/a' declaration), then retry. Skipping a stage is a defect regardless of whether the work 'was actually done'." >&2 + exit 2 +fi + +exit 0 diff --git a/.claude/hooks/linear-card-lint.sh b/.claude/hooks/linear-card-lint.sh new file mode 100755 index 0000000..d941e52 --- /dev/null +++ b/.claude/hooks/linear-card-lint.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# tron kit — Linear card-write linter (PreToolUse hook, matcher: mcp__linear__save_issue). +# Contract it enforces: {meta}/skills/skill-linear-cards.md (canon home: +# tortuga/dead-mans-chest/skills/skill-linear-cards.md) + deterministic-fleet plan §1.4. +# +# Blocks a save_issue call when: +# - `state` is outside the agent vocabulary (all other states are operator-only), +# - `assignee` is present and is not the fleet default, +# - a CREATE call (no `id`) is missing the universal label, the owner line, or the +# signature history in its description. +# On close/update calls the current card body is not readable here, so description +# checks apply only when the call carries a `description` — the skill still binds. +# +# Config (optional): .claude/hooks/linear-lint.config.json +# { "allowed_states": ["..."], "assignee": "ops@42labs.io" } +# Defaults below are the fleet contract. Installed per project at retrofit — inert +# until wired into .claude/settings.json. + +input=$(cat) + +if ! command -v jq >/dev/null 2>&1; then + echo "linear-card-lint: jq not found — linter skipped, card contract UNENFORCED this call. Install jq to arm this gate." >&2 + exit 0 +fi + +proj="${CLAUDE_PROJECT_DIR:-.}" +cfg="$proj/.claude/hooks/linear-lint.config.json" + +allowed_states='["Backlog","On the Horizon","Minions Deployed","Done","Canceled","Duplicate"]' +assignee_expected="ops@42labs.io" +if [ -f "$cfg" ]; then + s=$(jq -c '.allowed_states // empty' "$cfg" 2>/dev/null); [ -n "$s" ] && allowed_states="$s" + a=$(jq -r '.assignee // empty' "$cfg" 2>/dev/null); [ -n "$a" ] && assignee_expected="$a" +fi + +ti=$(printf '%s' "$input" | jq '.tool_input // {}') +state=$(printf '%s' "$ti" | jq -r '.state // .status // empty') +assignee=$(printf '%s' "$ti" | jq -r '.assignee // empty') +card_id=$(printf '%s' "$ti" | jq -r '.id // empty') +desc=$(printf '%s' "$ti" | jq -r '.description // empty') + +fail=0 +say() { echo "linear-card-lint: $1" >&2; fail=1; } + +# 1. State vocabulary — case-exact against the agent allow-list. +if [ -n "$state" ]; then + ok=$(printf '%s' "$allowed_states" | jq --arg s "$state" 'index($s) != null') + [ "$ok" = "true" ] || say "state '$state' is not in the agent vocabulary $(printf '%s' "$allowed_states" | jq -c .). All other states are operator-only (skill-linear-cards §2c). A walled card keeps its state; surface the wall via ACT/FLAG." +fi + +# 2. Assignee — fleet default, exact. Rejection upstream escalates, never retries blind. +if [ -n "$assignee" ] && [ "$assignee" != "$assignee_expected" ]; then + say "assignee '$assignee' is not the fleet default '$assignee_expected' (skill-linear-cards §8). Custody is expressed by the owner line + signature, not the assignee." +fi + +# 3. Create-call format — description opens with the owner line, ends with a signature, +# labels carry the universal marker (skill-linear-cards §4/§6). +if [ -z "$card_id" ]; then + if [ -z "$desc" ]; then + say "create call has no description — the card must open with the owner line ('> **Owner:** ...') and end with the signature history (skill-linear-cards §6)." + else + printf '%s\n' "$desc" | head -1 | grep -q '^> \*\*Owner:\*\*' \ + || say "description does not OPEN with the owner line '> **Owner:** · Session ...' (skill-linear-cards §6a)." + printf '%s\n' "$desc" | grep -q '^🤖 _' \ + || say "description has no signature history line ('🤖 _ · ... — created_') (skill-linear-cards §6b)." + fi + labels_joined=$(printf '%s' "$ti" | jq -r '(.labels // []) | join("|")') + case "$labels_joined" in + *"🤖 beep-boop"*) : ;; + *) say "create call labels are missing the universal marker '🤖 beep-boop' (skill-linear-cards §4 tier 1; labels is a full replace — send universal + persona + project labels together)." ;; + esac +elif [ -n "$desc" ]; then + printf '%s\n' "$desc" | head -1 | grep -q '^> \*\*Owner:\*\*' \ + || say "description rewrite dropped the opening owner line (skill-linear-cards §6a — exactly one, first line)." + printf '%s\n' "$desc" | grep -q '^🤖 _' \ + || say "description rewrite has no signature history (skill-linear-cards §6b — append-only, never removed)." +fi + +[ "$fail" -eq 1 ] && exit 2 +exit 0 diff --git a/.claude/hooks/persona-anchor-inject.sh b/.claude/hooks/persona-anchor-inject.sh new file mode 100755 index 0000000..32d6cb1 --- /dev/null +++ b/.claude/hooks/persona-anchor-inject.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# tron kit — persona anchor injector (SessionStart hook, matcher: compact). +# Contract: principles-base.md §17 (persona persistence) + deterministic-fleet plan §1.7. +# +# After every compaction this hook re-injects the persona anchor(s) for the agents +# working this project, so identity, hard rules, and the active mandate survive +# context loss. stdout is added to the session context by the SessionStart hook. +# +# Anchor homes searched (defaults; override via config): +# - project meta repo: {project}/*-meta/agents/*-anchor.md +# - canon-repo layout: {project}/agents/*-anchor.md +# - harness memory (cross-project agents / TRON modes), one per (agent, project): +# ~/.claude/projects/{project-dir-slug}/memory/*-anchor.md +# +# Config (optional): .claude/hooks/persona-anchors.config.json +# { "paths": ["glob", ...] } — replaces the default glob set (project-relative +# or absolute; ~ expands). +# Installed per project at retrofit — inert until wired into .claude/settings.json. +# No anchors found → silent no-op. + +proj="${CLAUDE_PROJECT_DIR:-$PWD}" +cfg="$proj/.claude/hooks/persona-anchors.config.json" + +globs=() +if [ -f "$cfg" ] && command -v jq >/dev/null 2>&1; then + while IFS= read -r g; do + [ -n "$g" ] || continue + case "$g" in + "~/"*) globs+=("$HOME/${g#\~/}") ;; + /*) globs+=("$g") ;; + *) globs+=("$proj/$g") ;; + esac + done < <(jq -r '.paths // [] | .[]' "$cfg" 2>/dev/null) +fi + +if [ ${#globs[@]} -eq 0 ]; then + slug=$(printf '%s' "$proj" | sed 's|[/.]|-|g') + globs=("$proj"/*-meta/agents/*-anchor.md + "$proj"/agents/*-anchor.md + "$HOME/.claude/projects/$slug/memory/"*-anchor.md) +fi + +anchors=() +for g in "${globs[@]}"; do + for f in $g; do + [ -f "$f" ] && anchors+=("$f") + done +done + +[ ${#anchors[@]} -eq 0 ] && exit 0 + +echo "" +echo "# Persona anchors — re-read after compaction" +echo +echo "Context was just compacted. The anchor(s) below restore identity, hard rules, and the active mandate. Re-read each anchor's named source docs before resuming work." +echo +for f in "${anchors[@]}"; do + echo "---" + echo "" + cat "$f" + echo +done + +exit 0 diff --git a/.claude/hooks/telegram-send-check.sh b/.claude/hooks/telegram-send-check.sh new file mode 100755 index 0000000..0817a8e --- /dev/null +++ b/.claude/hooks/telegram-send-check.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# tron kit — Telegram send check (PreToolUse hook, matcher: Bash). +# Contract it enforces: the fleet Telegram channel registry (canon home: +# tortuga/dead-mans-chest/reference/telegram-channels.md) + deterministic-fleet plan §1.5. +# +# Scope: only Bash commands touching the Telegram send surface — api.telegram.org +# calls and the fleet's send wrappers (telegram-send / tg-send). Everything else +# passes untouched. +# +# Rule: the chat ID in the command must equal a channel registered for this project, +# or the send is refused. A missing project registry is a refusal too — the group is +# confirmed with the operator once at project start and recorded; never guessed +# (registry rule 2). +# +# Project registry (untracked, written at project start / retrofit): +# .claude/hooks/telegram-channels.local.json +# { "bots": { "TRON": { "chat_id_env": "TRON_TG_CHAT_ID" }, +# "ALFRED": { "chat_id": "-100123..." } } } +# Prefer `chat_id_env` (secrets by ENV NAME only — fleet convention); a literal +# `chat_id` is allowed ONLY because this file is untracked project-local config. +# Installed per project at retrofit — inert until wired into .claude/settings.json. + +input=$(cat) + +if ! command -v jq >/dev/null 2>&1; then + echo "telegram-send-check: jq not found — check skipped, send surface UNENFORCED this call. Install jq to arm this gate." >&2 + exit 0 +fi + +cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') +[ -n "$cmd" ] || exit 0 + +printf '%s' "$cmd" | grep -qE 'api\.telegram\.org|telegram[-_]send|tg[-_]send' || exit 0 + +proj="${CLAUDE_PROJECT_DIR:-.}" +reg="$proj/.claude/hooks/telegram-channels.local.json" + +if [ ! -f "$reg" ]; then + echo "telegram-send-check: REFUSED — no project channel registry at .claude/hooks/telegram-channels.local.json. The chat is confirmed with the operator once at project start and recorded there (telegram-channels.md rule 2); never guess a chat ID. Ask the operator (ACT), write the registry, then retry." >&2 + exit 2 +fi + +allowed_literals=$(jq -r '[.bots // {} | .[] | .chat_id // empty] | .[]' "$reg" 2>/dev/null) +allowed_envs=$(jq -r '[.bots // {} | .[] | .chat_id_env // empty] | .[]' "$reg" 2>/dev/null) + +# Candidate chat IDs in the command: chat_id=VALUE / -d chat_id=... / "chat_id": "..." +# / --chat_id "VALUE" (separator may be =, :, or whitespace). +candidates=$(printf '%s' "$cmd" \ + | grep -oE 'chat_id["'"'"' ]*[=:[:space:]]["'"'"' ]*[$]?\{?[-@A-Za-z0-9_]+\}?' \ + | sed -E 's/^chat_id["'"'"' ]*[=:[:space:]]["'"'"' ]*//; s/^\$\{?//; s/\}$//' \ + | sort -u) + +if [ -z "$candidates" ]; then + echo "telegram-send-check: REFUSED — Telegram send surface detected but no explicit chat_id found in the command. Sends must name their chat_id explicitly (literal or \$ENV_NAME) so it can be checked against the project registry." >&2 + exit 2 +fi + +bad="" +for c in $candidates; do + # An env-style token ($VAR was stripped to VAR): allowed if VAR is a registered + # chat_id_env name, or if its resolved value matches a registered literal. + if printf '%s\n' "$allowed_envs" | grep -qxF -- "$c"; then + continue + fi + if printf '%s\n' "$allowed_literals" | grep -qxF -- "$c"; then + continue + fi + if printf '%s' "$c" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*$'; then + resolved=$(eval "printf '%s' \"\${$c:-}\"" 2>/dev/null) + if [ -n "$resolved" ] && printf '%s\n' "$allowed_literals" | grep -qxF -- "$resolved"; then + continue + fi + fi + bad="$bad $c" +done + +if [ -n "$bad" ]; then + echo "telegram-send-check: REFUSED — chat_id token(s)$bad not registered for this project (.claude/hooks/telegram-channels.local.json). One bot, one purpose: a send goes to exactly the registered channel (telegram-channels.md rules 1–2). If the channel is legitimate, register it with the operator's confirmation first." >&2 + exit 2 +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ca1deb9 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,104 @@ +{ + "enabledPlugins": { + "ponytail@ponytail": true + }, + "extraKnownMarketplaces": { + "ponytail": { + "source": { + "source": "github", + "repo": "DietrichGebert/ponytail" + } + } + }, + "permissions": { + "deny": [ + "Bash(git push --force:*)", + "Bash(git push -f:*)", + "Bash(git push --mirror:*)", + "Bash(git reset --hard:*)", + "Bash(git clean -fdx:*)", + "Bash(git filter-branch:*)", + "Bash(git commit --no-verify:*)", + "Bash(git push --no-verify:*)", + "Bash(rm -rf /:*)", + "Bash(gh pr merge --auto:*)", + "Bash(gh repo delete:*)", + "Read(./.env)", + "Read(./.env.*)", + "Read(./**/.env)", + "Read(./**/.env.*)" + ], + "ask": [ + "Bash(gh pr merge:*)", + "Bash(gh repo create:*)", + "Bash(gh api --method DELETE:*)" + ] + }, + "worktree": { + "bgIsolation": "none" + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "mcp__linear__save_issue", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/linear-card-lint.sh" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/dangerous-command-gate.sh" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/tron-clu-merge-guard.sh" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/telegram-send-check.sh" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gate-ledger-gate.sh" + } + ] + }, + { + "matcher": "Edit|Write|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/tron-clu-worktree-guard.sh" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/persona-anchor-inject.sh" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/tron-clu-pulse-guard.sh" + } + ] + } + ] + } +} diff --git a/.claude/tron-clu-merge-guard.sh b/.claude/tron-clu-merge-guard.sh new file mode 100755 index 0000000..871221e --- /dev/null +++ b/.claude/tron-clu-merge-guard.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# tron-clu merge guard (PreToolUse hook, matcher: Bash). +# Enforces law §3 / clu.md "CLU merges nothing, ever" as a mechanism instead of a +# habit: a hook deny fires in EVERY permission mode, including bypassPermissions, +# and also inside subagents — so CLU and every worker are bound alike. +# +# No-op unless a CLU run is active (.tron-clu-active in the project root). +# +# `gh pr merge` — denied unless ALL of: +# · the PR is named explicitly: a number (`gh pr merge 42`) or a PR URL whose +# trailing /pull/ is read offline (this hook never calls `gh`); a +# branch-name selector is denied with the numeric form named, because +# resolving it would need a network call; +# · a line ` ` in .tron-clu-merge-grant covers it — the +# operator's authorization, written by CLU, one line per authorized PR; +# · no `--auto` / merge-queue flag. That one is denied unconditionally, grant or +# no grant: a grant authorizes a merge NOW, never a scheduled one, and +# tron.md §3 bans arming auto-merge absolutely. +# Target repo = `-R/--repo` if given, else the repo at `git -C `, else the +# session cwd's repo, normalized to owner/repo from the origin remote. A PR URL +# supplies its own owner/repo and overrides that ladder. +# +# `git merge` — passes in exactly two forms: +# · `--ff-only` where the target repo's `.repo-class` is `canon` or `meta` (the +# law-prescribed no-PR landing, skill-branching.md §Session end). On `app` +# repos, on a repo with no `.repo-class` marker, and wherever the target repo +# cannot be resolved, the FF form is denied too — fail-closed; the refusal +# names the PR path, or the `git -C merge --ff-only ` form +# that makes the class check decidable. +# · the recovery verbs --abort / --continue / --quit: a repo left mid-conflict +# must have a legal exit. +# Every other form is denied; refusals name the legal paths. +# +# Session scoping is deliberately ABSENT: this gate binds the whole project root +# while a run is live (plan Decision 1, unscoped branch). Verified 2026-08-13: a +# subagent's hook stdin carries the PARENT session id, so scoping by session would +# cover subagent commands and could not distinguish them. An operator session in +# the same project during a live run is bound too — every refusal names its +# unblock, and `install/README.md` documents the impact. +# +# Layered guardrail, not a security boundary: `gh api` merges, local merge + push, +# plain `git pull` merges, and `--no-verify` sit outside the matcher, at the same +# trust level as the ledger. No tamper defense is claimed. + +input=$(cat) + +proj="${CLAUDE_PROJECT_DIR:-.}" +flag="$proj/.tron-clu-active" +grant="$proj/.tron-clu-merge-grant" + +[ -f "$flag" ] || exit 0 + +if ! command -v jq >/dev/null 2>&1; then + echo "tron-clu merge-guard: jq not found — gate skipped, merges UNGATED this call. Install jq to arm this gate." >&2 + exit 0 +fi + +cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') +[ -n "$cmd" ] || exit 0 +cwd=$(printf '%s' "$input" | jq -r '.cwd // empty') +[ -n "$cwd" ] || cwd="$proj" + +deny() { echo "tron-clu merge-guard: REFUSED — $1" >&2; exit 2; } + +# owner/repo from a remote URL (https://host/owner/repo[.git] or host:owner/repo). +slug_from_url() { + printf '%s' "${1%.git}" | sed -E 's#/+$##; s#^.*[:/]([^/:]+)/([^/]+)$#\1/\2#' +} + +# Everything after the first bare `merge` token, up to the first command separator. +args_after_merge() { + local seg + seg=$(printf '%s\n' "$cmd" | awk '{for(i=1;i<=NF;i++) if($i=="merge"){s="";for(j=i+1;j<=NF;j++) s=s" "$j; print s; exit}}') + seg=${seg%%;*}; seg=${seg%%&&*}; seg=${seg%%|*} + printf '%s' "$seg" +} + +# The directory the command targets: `git -C ` if present, else the cwd. +target_dir() { + local d + d=$(printf '%s' "$cmd" | grep -oE 'git[[:space:]]+(-[^C[:space:]-][^[:space:]]*[[:space:]]+)*-C[[:space:]]+[^[:space:];&|]+' | head -1 | sed -E 's/.*-C[[:space:]]+//') + if [ -n "$d" ]; then + case "$d" in /*) printf '%s' "$d" ;; *) printf '%s/%s' "$cwd" "$d" ;; esac + else + printf '%s' "$cwd" + fi +} + +# ---------------------------------------------------------------- gh pr merge -- +if printf '%s' "$cmd" | grep -qE '(^|[;&|[:space:]])gh([[:space:]]+-[^[:space:]]+)*[[:space:]]+pr[[:space:]]+merge([[:space:]]|$)'; then + seg=$(args_after_merge) + + pr_num=""; selector=""; repo_arg=""; url_slug=""; skip_next=0; repo_next=0 + for t in $seg; do + if [ "$skip_next" = 1 ]; then skip_next=0; continue; fi + if [ "$repo_next" = 1 ]; then repo_arg="$t"; repo_next=0; continue; fi + case "$t" in + --auto|--auto=*|--merge-queue|--queue) + deny "\`--auto\` / merge-queue merges are banned outright (tron.md §3: never arm auto-merge, in any mode, authorized or not). A grant authorizes a merge NOW, never a scheduled one. Drop the flag and merge the PR directly once the operator has granted it." ;; + *://*/pull/*) + n=${t##*/pull/}; n=${n%%/*}; n=${n%%\?*} + url_slug=$(slug_from_url "${t%%/pull/*}") + case "$n" in ''|*[!0-9]*) : ;; *) pr_num="$n" ;; esac ;; + -R=*|--repo=*) repo_arg=${t#*=} ;; + -R|--repo) repo_next=1 ;; + -b|--body|-t|--subject|--match-head-commit|--author-email|--body-file) skip_next=1 ;; + -*) : ;; + *) + if [ -z "$pr_num$selector" ]; then + case "$t" in ''|*[!0-9]*) selector="$t" ;; *) pr_num="$t" ;; esac + fi ;; + esac + done + + [ -n "$pr_num" ] || { + if [ -n "$selector" ]; then + deny "\`gh pr merge $selector\` selects the PR by branch — resolving that needs a network call this hook must not make. Re-issue with the PR number (\`gh pr merge \`) or the PR URL." + fi + deny "\`gh pr merge\` with no PR named. Name the PR explicitly — \`gh pr merge \` or the PR URL — so the grant can be checked. And the merge itself needs the operator's word: CLU writes \` \` into .tron-clu-merge-grant only on their authorization." + } + + # Target repo: URL wins (it names its repo authoritatively), then -R, then the ladder. + if [ -n "$url_slug" ]; then repo_slug="$url_slug" + elif [ -n "$repo_arg" ]; then repo_slug=$(slug_from_url "$repo_arg") + else + dir=$(target_dir) + origin=$(git -C "$dir" remote get-url origin 2>/dev/null) + [ -n "$origin" ] || deny "cannot resolve the target repo for PR #$pr_num (no origin remote at '$dir'), so the grant cannot be checked. Re-issue with \`-R \` or the PR URL." + repo_slug=$(slug_from_url "$origin") + fi + + [ -f "$grant" ] || deny "no merge grant exists. The merge is the operator's: on their authorization CLU writes \`$pr_num $repo_slug\` into .tron-clu-merge-grant, then this command passes. Ask — an earlier blanket 'merge them all' is never standing authorization." + + grep -qE "^[[:space:]]*$pr_num[[:space:]]+$repo_slug[[:space:]]*$" "$grant" || \ + deny "PR #$pr_num ($repo_slug) is not in .tron-clu-merge-grant. Grant lines are \` \`, one per authorized PR, written by CLU only on the operator's authorization. Put the PR to the operator, then retry." + + exit 0 +fi + +# ------------------------------------------------------------------ git merge -- +if printf '%s' "$cmd" | grep -qE '(^|[;&|[:space:]])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+merge([[:space:]]|$)'; then + seg=$(args_after_merge) + + case " $seg " in + *" --abort "*|*" --continue "*|*" --quit "*) exit 0 ;; + esac + + case " $seg " in + *" --ff-only "*) : ;; + *) deny "\`git merge\` is off-blueprint while a CLU run is active. Parallel-block reconciliation is a rebase (skill-merge-close), trunk sync is \`git pull --ff-only\`, and a landing is \`git -C merge --ff-only \` on a canon/meta repo. App repos land through a PR — CLU merges nothing." ;; + esac + + dir=$(target_dir) + root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) + [ -n "$root" ] || deny "\`--ff-only\` landing, but the target repo cannot be resolved from '$dir', so its class cannot be checked. Re-issue naming the repo: \`git -C merge --ff-only \` — the invocation style clu.md §Invariants already mandates." + + class="" + [ -f "$root/.repo-class" ] && class=$(tr -d '[:space:]' < "$root/.repo-class") + case "$class" in + canon|meta) exit 0 ;; + '') deny "\`--ff-only\` landing in $root, which carries no \`.repo-class\` marker. Fail-closed: an unclassified repo has no law-prescribed fast-forward landing. Land through a PR, or classify the repo (canon/meta/app) first." ;; + *) deny "\`--ff-only\` landing in $root, class \`$class\`. The no-PR fast-forward landing is for \`canon\`/\`meta\` repos only (skill-branching.md §Session end) — an \`app\` repo lands through a PR the operator merges." ;; + esac +fi + +exit 0 diff --git a/.claude/tron-clu-pulse-guard.sh b/.claude/tron-clu-pulse-guard.sh new file mode 100755 index 0000000..fc598db --- /dev/null +++ b/.claude/tron-clu-pulse-guard.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# tron-clu PULSE guard (Stop hook). v3. +# No-op unless a CLU run is active (.tron-clu-active flag in project root). +# Flag format: "" (armed until expiry) or ":", state in +# {done, walled} — a recorded kill state lets the turn end (PULSE off by rule), +# except that a `walled` run with pending operator items must raise one of them +# as a blocking question before it may end (see below). +# Otherwise blocks turn end if the PULSE timer has lapsed, forcing a re-arm. + +input=$(cat) + +proj="${CLAUDE_PROJECT_DIR:-.}" +flag="$proj/.tron-clu-active" + +[ -f "$flag" ] || exit 0 + +# Already blocked once this stop — let it through to avoid an infinite loop. +echo "$input" | grep -q '"stop_hook_active":true' && exit 0 + +# Session scoping: hooks fire in EVERY session of the project. If the sidecar +# names CLU's session, only that session is guarded; others pass untouched. +sid_file="$proj/.tron-clu-session" +if [ -f "$sid_file" ]; then + sid=$(tr -d '[:space:]' < "$sid_file") + if [ -n "$sid" ] && ! echo "$input" | grep -q "$sid"; then + exit 0 + fi +fi + +raw=$(tr -d '[:space:]' < "$flag" 2>/dev/null) +armed_until="${raw%%:*}" +state="" +case "$raw" in *:*) state="${raw#*:}" ;; esac + +# Kill states: done / walled — PULSE is legitimately off; the turn may end. +# (Teardown/flag deletion still belongs to skill-session-end, not this hook.) +# Exception (gate 4): a `walled` run whose attention queue is non-empty is in the +# one state where waiting on the operator is all that is left — so the turn may +# not simply end in silence. AskUserQuestion is model-invoked only; this hook can +# only refuse the ending and say why, which it does exactly once per stop. +case "$state" in + walled) + att="$proj/.tron-clu-attention" + if [ -s "$att" ]; then + echo "Walled with pending operator items ($att). Nothing is parallelizable and the only remaining state is waiting on the operator — so do not end silently: convert the OLDEST pending item into a blocking AskUserQuestion now (skill-operator-comms §Blocking moments), then end the turn." >&2 + exit 2 + fi + exit 0 ;; + done) exit 0 ;; + '') : ;; + *) + echo "PULSE flag malformed ('$raw'): state token must be 'done' or 'walled'. Either arm a fresh beat (echo \$(( \$(date +%s) + 240 )) > .tron-clu-active) or record a valid kill state (echo \"\$(date +%s):done\" or ...:walled > .tron-clu-active)." >&2 + exit 2 ;; +esac + +case "$armed_until" in ('' | *[!0-9]*) armed_until=0 ;; esac + +now=$(date +%s) +if [ "$now" -gt "$armed_until" ]; then + echo "PULSE not armed or lapsed. Arm the background timer now (sleep per the adaptive cadence, run_in_background) and write the new expiry: echo \$(( \$(date +%s) + 240 )) > .tron-clu-active — or, if the run is finished or fully walled with nothing parallelizable, record the kill state: echo \"\$(date +%s):done\" (or :walled) > .tron-clu-active. Then continue." >&2 + exit 2 +fi + +exit 0 diff --git a/.claude/tron-clu-worktree-guard.sh b/.claude/tron-clu-worktree-guard.sh new file mode 100755 index 0000000..abd7a38 --- /dev/null +++ b/.claude/tron-clu-worktree-guard.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# tron-clu worktree guard (PreToolUse hook, matcher: Edit|Write|MultiEdit|NotebookEdit). +# Enforces "TRON never touches the project" (clu.md §Invariants) and the dispatch +# Term that workers write only inside their assigned worktree +# (skill-dispatch PMT §Terms; skill-pulse §Tick sweep). +# +# No-op unless a CLU run is active (.tron-clu-active in the project root). +# +# While a run is live, a write whose path is INSIDE the project root is denied +# unless it is: +# · inside a worktree home — `.worktrees/` or `worktrees/` +# (principles-base.md §14); or +# · on the allow-list — TRON's own run state and install surface: the +# `.tron-clu-*` sidecars (incl. `.tron-clu.env` and `.tron-clu-merge-grant`), +# `.tron-active-block`, the block ledger dir (`{meta}/blocks/ledger/`, or +# `ledger_dir` from .claude/hooks/gate-ledger.config.json), and `.claude/` +# (settings merge + project-local hook copies at install). +# The project `.gitignore` is NOT allow-listed: it is tracked project source, and +# the grant-file ignore line is operator/engineer-routed guidance, never a CLU write. +# Paths outside the project root pass untouched. +# +# Session scoping is deliberately ABSENT — same reason as merge-guard.sh: a subagent +# inherits the PARENT session id (verified 2026-08-13), so scoping buys nothing here; +# the gate covers the project root while a run is live (plan Decision 1, unscoped +# branch; impact in install/README.md). +# +# Decision 3 — this covers the FILE TOOLS ONLY. Bash-mediated writes (`echo >`, +# `sed -i`, `cp`) are NOT covered and stay model-walked. Layered guardrail, not a +# security boundary; same trust level as the ledger. No tamper defense is claimed. + +input=$(cat) + +proj="${CLAUDE_PROJECT_DIR:-.}" +flag="$proj/.tron-clu-active" + +[ -f "$flag" ] || exit 0 + +if ! command -v jq >/dev/null 2>&1; then + echo "tron-clu worktree-guard: jq not found — gate skipped, project writes UNGATED this call. Install jq to arm this gate." >&2 + exit 0 +fi + +path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty') +[ -n "$path" ] || exit 0 +cwd=$(printf '%s' "$input" | jq -r '.cwd // empty') +[ -n "$cwd" ] || cwd="$proj" + +case "$path" in /*) : ;; *) path="$cwd/$path" ;; esac + +# Normalize both sides without requiring the file to exist (no realpath dependency). +norm() { printf '%s' "$1" | sed -E 's#/\./#/#g; s#/+#/#g; s#/$##'; } +path=$(norm "$path") +root=$(norm "$(cd "$proj" 2>/dev/null && pwd)") +[ -n "$root" ] || exit 0 + +# Outside the project root → not ours to gate. +case "$path/" in "$root"/*) : ;; *) exit 0 ;; esac + +rel=${path#"$root"/} + +# (a) worktree homes +case "$rel" in + .worktrees/*|worktrees/*|*/.worktrees/*|*/worktrees/*) exit 0 ;; +esac + +# (b) allow-list: TRON's own run state and install surface +case "$rel" in + .tron-clu-*|.tron-clu.env|.tron-active-block|.claude/*) exit 0 ;; +esac + +ledger_dir="" +cfg="$root/.claude/hooks/gate-ledger.config.json" +[ -f "$cfg" ] && ledger_dir=$(jq -r '.ledger_dir // empty' "$cfg" 2>/dev/null) +case "$rel" in + blocks/ledger/*|meta/blocks/ledger/*|*-meta/blocks/ledger/*) exit 0 ;; +esac +[ -n "$ledger_dir" ] && case "$rel" in "${ledger_dir%/}"/*) exit 0 ;; esac + +echo "tron-clu worktree-guard: REFUSED — a CLU run is live and '$rel' is in the project checkout. TRON never touches the project: block work belongs in the assigned worktree (.worktrees//...). Write there instead, or — if this is genuinely CLU run state — it belongs in a .tron-clu-* sidecar or the block ledger. Nothing else in this checkout is writable while the run flag stands." >&2 +exit 2 diff --git a/.gitignore b/.gitignore index 3e389e0..1f87f0c 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,9 @@ ENV/ .ropeproject .vercel .env*.local + +# Claude Code -- local-only files stay untracked (kit hygiene) +.claude/settings.local.json +.claude/worktrees/ +.claude/hooks/*.local.json +.worktrees/ diff --git a/.integration-branch b/.integration-branch new file mode 100644 index 0000000..ba2906d --- /dev/null +++ b/.integration-branch @@ -0,0 +1 @@ +main diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..ab91e6e --- /dev/null +++ b/.mcp.json @@ -0,0 +1,32 @@ +{ + "mcpServers": { + "exa": { + "type": "http", + "url": "https://mcp.exa.ai/mcp" + }, + "github": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PAT}" + } + }, + "chrome-devtools": { + "command": "npx", + "args": [ + "-y", + "chrome-devtools-mcp@latest" + ] + }, + "playwright": { + "command": "npx", + "args": [ + "-y", + "@playwright/mcp@latest" + ] + } + } +} diff --git a/.pi/settings.json b/.pi/settings.json new file mode 100644 index 0000000..0c6690c --- /dev/null +++ b/.pi/settings.json @@ -0,0 +1,11 @@ +{ + "packages": [ + "npm:@dietrichgebert/ponytail@4.9.0", + "npm:pi-lens@3.8.74", + "npm:pi-simplify@0.2.3", + "npm:pi-web-access@0.22.0", + "npm:@gotgenes/pi-permission-system@25.1.0", + "npm:@juicesharp/rpiv-ask-user-question@2.4.0", + "npm:@howaboua/pi-codex-conversion@3.0.14" + ] +} diff --git a/.pi/web-search.example.json b/.pi/web-search.example.json new file mode 100644 index 0000000..ea65c8b --- /dev/null +++ b/.pi/web-search.example.json @@ -0,0 +1,7 @@ +{ + "_note": "pi-web-access reads this from the USER-level path only — copy to ~/.pi/web-search.json (or $PI_CODING_AGENT_DIR / $XDG_CONFIG_HOME/pi). It is NOT read from the project. Tracked here as the canon-shaped example; canon §19 requires Exa primary with fallback, never the zero-config provider order.", + "searchRouting": { + "providers": ["exa", "searxng", "openai"], + "fallbackOn": ["error", "empty", "timeout"] + } +}