@reddb-io/red-skills-dev 2.75.2
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/LICENSE +202 -0
- package/README.md +30 -0
- package/package.json +32 -0
- package/skills/engineering/README.md +36 -0
- package/skills/engineering/_report-runtime/WRAPPER.md +47 -0
- package/skills/engineering/afk/AGENT-PROMPT.md +295 -0
- package/skills/engineering/afk/SAFETY.md +103 -0
- package/skills/engineering/afk/SKILL.md +160 -0
- package/skills/engineering/afk/TROUBLESHOOTING.md +260 -0
- package/skills/engineering/afk/actions-lane.md +213 -0
- package/skills/engineering/afk/bin/README.md +29 -0
- package/skills/engineering/afk/bin/afk.mjs +51 -0
- package/skills/engineering/afk/defaults/cargo-pre-worktree.sh +37 -0
- package/skills/engineering/afk/defaults/envelope-post-attempt.sh +47 -0
- package/skills/engineering/afk/defaults/gradle-pre-worktree.sh +42 -0
- package/skills/engineering/afk/defaults/heartbeat-post-attempt.sh +43 -0
- package/skills/engineering/afk/defaults/validation-post-merge.sh +77 -0
- package/skills/engineering/afk/detectors/README.md +72 -0
- package/skills/engineering/afk/detectors/cargo.sh +27 -0
- package/skills/engineering/afk/detectors/gradle.sh +35 -0
- package/skills/engineering/afk/docs/BOOT-SWEEPS.md +81 -0
- package/skills/engineering/afk/docs/CONFIG.md +238 -0
- package/skills/engineering/afk/docs/ENVELOPE.md +177 -0
- package/skills/engineering/afk/docs/HANDOFF.md +70 -0
- package/skills/engineering/afk/docs/LIVENESS.md +56 -0
- package/skills/engineering/afk/docs/OPERATIONS.md +409 -0
- package/skills/engineering/afk/examples/only-mine.sh +32 -0
- package/skills/engineering/afk/examples/red-afk-attempt-action.yml +53 -0
- package/skills/engineering/afk/examples/rs-afk-attempt.yml +76 -0
- package/skills/engineering/afk/fleet.md +127 -0
- package/skills/engineering/afk/hooks/red-cargo +44 -0
- package/skills/engineering/afk/hooks/red-envelope +50 -0
- package/skills/engineering/afk/hooks/red-gradle +50 -0
- package/skills/engineering/afk/hooks/red-heartbeat +46 -0
- package/skills/engineering/afk/hooks/red-validation +79 -0
- package/skills/engineering/afk/monitor.md +170 -0
- package/skills/engineering/afk/runner-claude-minimax.md +71 -0
- package/skills/engineering/afk/runner-claude.md +67 -0
- package/skills/engineering/afk/runner-codex.md +138 -0
- package/skills/engineering/afk/runner-hermes.md +40 -0
- package/skills/engineering/afk/runner-opencode.md +142 -0
- package/skills/engineering/ask-red/SKILL.md +137 -0
- package/skills/engineering/audit-skills/SKILL.md +46 -0
- package/skills/engineering/code-review/SKILL.md +106 -0
- package/skills/engineering/context/SKILL.md +142 -0
- package/skills/engineering/curate/SKILL.md +133 -0
- package/skills/engineering/daily-review/SKILL.md +68 -0
- package/skills/engineering/dashboard/SKILL.md +44 -0
- package/skills/engineering/diagnose/SKILL.md +155 -0
- package/skills/engineering/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/skills/engineering/go/SKILL.md +144 -0
- package/skills/engineering/go/TROUBLESHOOTING.md +81 -0
- package/skills/engineering/ground-truth/SKILL.md +84 -0
- package/skills/engineering/hitl/SKILL.md +171 -0
- package/skills/engineering/hitl/TROUBLESHOOTING.md +76 -0
- package/skills/engineering/implement/SKILL.md +69 -0
- package/skills/engineering/improve-codebase-architecture/DEEPENING.md +37 -0
- package/skills/engineering/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
- package/skills/engineering/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/skills/engineering/improve-codebase-architecture/SKILL.md +81 -0
- package/skills/engineering/model-tier-policy/SKILL.md +83 -0
- package/skills/engineering/prototype/LOGIC.md +79 -0
- package/skills/engineering/prototype/SKILL.md +30 -0
- package/skills/engineering/prototype/UI.md +112 -0
- package/skills/engineering/red-doctor/APPLY.md +35 -0
- package/skills/engineering/red-doctor/SKILL.md +172 -0
- package/skills/engineering/red-gains/SKILL.md +49 -0
- package/skills/engineering/red-setup/INTERVIEW.md +266 -0
- package/skills/engineering/red-setup/ISSUE-SWEEP.md +18 -0
- package/skills/engineering/red-setup/REFERENCE.md +35 -0
- package/skills/engineering/red-setup/SKILL.md +58 -0
- package/skills/engineering/red-setup/WORKFLOWS.md +74 -0
- package/skills/engineering/red-setup/WRITE-CONTRACT.md +151 -0
- package/skills/engineering/red-setup/config-template.yaml +174 -0
- package/skills/engineering/red-setup/domain.md +52 -0
- package/skills/engineering/red-setup/issue-tracker-github.md +88 -0
- package/skills/engineering/red-setup/rsp-retirement-runbook.md +35 -0
- package/skills/engineering/red-setup/scripts/install-runtime-shim.sh +189 -0
- package/skills/engineering/red-setup/triage-labels.md +176 -0
- package/skills/engineering/red-setup/workflows/red-issues-needs-triage.yml +36 -0
- package/skills/engineering/red-statusline/HOST-NOTES.md +180 -0
- package/skills/engineering/red-statusline/SKILL.md +58 -0
- package/skills/engineering/report-bug/SKILL.md +155 -0
- package/skills/engineering/resolving-merge-conflicts/SKILL.md +143 -0
- package/skills/engineering/retake/SKILL.md +184 -0
- package/skills/engineering/review-adrs/SKILL.md +125 -0
- package/skills/engineering/start/ADR-FORMAT.md +47 -0
- package/skills/engineering/start/CONTEXT-FORMAT.md +77 -0
- package/skills/engineering/start/DOC-LANDING-FINALIZER.md +9 -0
- package/skills/engineering/start/SKILL.md +179 -0
- package/skills/engineering/tdd/SKILL.md +96 -0
- package/skills/engineering/tdd/deep-modules.md +33 -0
- package/skills/engineering/tdd/interface-design.md +31 -0
- package/skills/engineering/tdd/mocking.md +59 -0
- package/skills/engineering/tdd/tests.md +85 -0
- package/skills/engineering/to-spec/SKILL.md +104 -0
- package/skills/engineering/to-tickets/SKILL.md +158 -0
- package/skills/engineering/triage/AGENT-BRIEF.md +207 -0
- package/skills/engineering/triage/OUT-OF-SCOPE.md +101 -0
- package/skills/engineering/triage/SKILL.md +176 -0
- package/skills/engineering/verify/SKILL.md +136 -0
- package/skills/engineering/wayfinder/SKILL.md +128 -0
- package/skills/engineering/zoom-out/SKILL.md +51 -0
- package/skills/engineering/zoom-out/scripts/graph-mode-plumbing.sh +36 -0
- package/skills/engineering/zoom-out/scripts/tests/contract.test.sh +168 -0
- package/skills/knowledge/README.md +5 -0
- package/skills/knowledge/research/SKILL.md +76 -0
- package/skills/misc/README.md +8 -0
- package/skills/misc/branch-lock/SKILL.md +187 -0
- package/skills/misc/branch-lock/scripts/branch-lock-hook.sh +113 -0
- package/skills/misc/branch-lock/scripts/branch-lock-session-start.sh +81 -0
- package/skills/misc/branch-lock/scripts/branch-lock.sh +68 -0
- package/skills/misc/branch-lock/scripts/lib/dev-config.sh +126 -0
- package/skills/misc/branch-lock/scripts/lib/git-command-classifier.sh +198 -0
- package/skills/misc/branch-lock/scripts/lib/lock-store.sh +67 -0
- package/skills/misc/branch-lock/scripts/lib/scope-resolver.sh +43 -0
- package/skills/misc/branch-lock/scripts/tests/branch-lock-cli.test.sh +114 -0
- package/skills/misc/branch-lock/scripts/tests/claude-plugin-hook.test.sh +117 -0
- package/skills/misc/branch-lock/scripts/tests/codex-hook.test.sh +229 -0
- package/skills/misc/branch-lock/scripts/tests/dev-config.test.sh +99 -0
- package/skills/misc/branch-lock/scripts/tests/git-command-classifier.test.sh +187 -0
- package/skills/misc/branch-lock/scripts/tests/lock-store.test.sh +90 -0
- package/skills/misc/branch-lock/scripts/tests/scope-resolver.test.sh +63 -0
- package/skills/misc/branch-lock/scripts/tests/session-start.test.sh +123 -0
- package/skills/misc/git-guardrails-claude-code/SKILL.md +105 -0
- package/skills/misc/git-guardrails-claude-code/scripts/block-dangerous-git.sh +231 -0
- package/skills/misc/git-guardrails-claude-code/scripts/tests/block-dangerous-git.test.sh +143 -0
- package/skills/misc/migrate-to-shoehorn/SKILL.md +96 -0
- package/skills/misc/setup-pre-commit/SKILL.md +94 -0
- package/skills/productivity/README.md +8 -0
- package/skills/productivity/ff/SKILL.md +116 -0
- package/skills/productivity/handoff/SKILL.md +37 -0
- package/skills/productivity/reflect/SKILL.md +18 -0
- package/skills/productivity/write-a-skill/SKILL.md +190 -0
- package/skills/productivity/write-a-skill/WRITING-STYLE.md +62 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: branch-lock
|
|
3
|
+
description: Lock the agent to a branch and block it from switching away. Sets up self-contained pre-tool hooks that block the agent's git checkout/switch to any other branch while a lock is active, and gives you a /branch-lock command to set, change, and clear the lock. Use when you want the agent pinned to one branch for a session.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Branch Lock
|
|
8
|
+
|
|
9
|
+
Pin the agent to one branch in the primary checkout and block it from switching away.
|
|
10
|
+
|
|
11
|
+
<what-to-do>
|
|
12
|
+
|
|
13
|
+
**Never hand-edit `branch-lock.yaml` — route every lock change through `scripts/branch-lock.sh` so the lock-store stays single-writer.**
|
|
14
|
+
|
|
15
|
+
## Parse the request
|
|
16
|
+
|
|
17
|
+
The user invokes `/branch-lock <branch>` or `/branch-lock clear` (or just asks
|
|
18
|
+
to lock/unlock). Map it to one CLI action and run the bundled CLI:
|
|
19
|
+
|
|
20
|
+
- `/branch-lock <branch>` → `scripts/branch-lock.sh set <branch>`
|
|
21
|
+
- `/branch-lock` (no arg) → `scripts/branch-lock.sh set` (lock to the current branch)
|
|
22
|
+
- `/branch-lock clear` → `scripts/branch-lock.sh clear`
|
|
23
|
+
- "what's locked?" → `scripts/branch-lock.sh status`
|
|
24
|
+
|
|
25
|
+
The CLI does the **atomic relock-then-switch**: it rewrites the lock target
|
|
26
|
+
first, so the switch to that branch is itself "return to the lock target" and
|
|
27
|
+
the hook never blocks the very change the user asked for.
|
|
28
|
+
|
|
29
|
+
## Install the hooks (first time only)
|
|
30
|
+
|
|
31
|
+
If the hooks are not yet wired in this repo, install them before relying on the lock:
|
|
32
|
+
|
|
33
|
+
### Codex
|
|
34
|
+
|
|
35
|
+
The `dev` plugin ships Codex wiring in
|
|
36
|
+
`plugins/dev/.codex-plugin/plugin.json` (`"hooks": "./hooks/codex.hooks.json"`).
|
|
37
|
+
That manifest calls `plugins/dev/hooks/branch-lock-codex.sh`, which reads Codex
|
|
38
|
+
`PreToolUse` payloads and reuses the same `lock-store`, `scope-resolver`, and
|
|
39
|
+
`git-command-classifier` modules as the Claude hook.
|
|
40
|
+
|
|
41
|
+
Codex must load the updated `dev` plugin, and plugin hooks must be enabled.
|
|
42
|
+
Current Codex builds list `plugin_hooks` as stable/enabled; older builds may
|
|
43
|
+
need `[features].plugin_hooks = true` in `~/.codex/config.toml`. Once that is
|
|
44
|
+
true, no per-repo `.codex/` copy is needed: set the lock with
|
|
45
|
+
`scripts/branch-lock.sh set` or the `/branch-lock` skill command and the Codex
|
|
46
|
+
hook enforces it for shell-command tool calls. If Codex has plugin hooks
|
|
47
|
+
disabled, the lock file may exist but Codex will not enforce it.
|
|
48
|
+
|
|
49
|
+
### Claude Code
|
|
50
|
+
|
|
51
|
+
The `dev` plugin ships Claude wiring in
|
|
52
|
+
`plugins/dev/.claude-plugin/plugin.json` (`"hooks": "./hooks/claude.hooks.json"`).
|
|
53
|
+
That manifest registers `branch-lock-hook.sh` under `PreToolUse`/matcher `Bash`
|
|
54
|
+
at the plugin level, so no per-repo `.claude/settings.json` copy is needed for
|
|
55
|
+
the primary-branch guard. The hook reads `.red/config.yaml` at runtime; once
|
|
56
|
+
`plugins.dev.enabled: true` it enforces the untouchable-primary rule and, when a
|
|
57
|
+
branch-lock file is present, the work-loss family too.
|
|
58
|
+
|
|
59
|
+
Manual per-repo installation is only needed for older/pluginless Claude setups:
|
|
60
|
+
copy [scripts/branch-lock-hook.sh](scripts/branch-lock-hook.sh),
|
|
61
|
+
[scripts/branch-lock-session-start.sh](scripts/branch-lock-session-start.sh),
|
|
62
|
+
and `scripts/lib/` together into `.claude/hooks/branch-lock/`, make the scripts
|
|
63
|
+
executable, and register `branch-lock-hook.sh` under `PreToolUse`/matcher `Bash`.
|
|
64
|
+
The optional SessionStart hook only injects an instruction asking whether to
|
|
65
|
+
lock; it never writes the lock itself.
|
|
66
|
+
|
|
67
|
+
## DOs / DON'Ts
|
|
68
|
+
|
|
69
|
+
- ✅ Always route lock changes through `scripts/branch-lock.sh`.
|
|
70
|
+
- ✅ Confirm the resulting `status` to the user after `set`/`clear`.
|
|
71
|
+
- ❌ Never hand-edit `branch-lock.yaml` — route via CLI so the lock-store stays single-writer.
|
|
72
|
+
- ❌ Don't try to make the lock block the human terminal — out of scope by design (ADR 0006).
|
|
73
|
+
|
|
74
|
+
</what-to-do>
|
|
75
|
+
|
|
76
|
+
<supporting-info>
|
|
77
|
+
|
|
78
|
+
## Why the lock exists
|
|
79
|
+
|
|
80
|
+
The lock is a local fact — `.red/state/branch-lock.yaml` whose content is the
|
|
81
|
+
branch name — under the gitignored state tier, so every checkout and machine
|
|
82
|
+
locks independently and nothing is committed. Legacy readers may still recognize
|
|
83
|
+
the old `.red/tmp/branch-lock.yaml` path during migration, but the canonical home
|
|
84
|
+
is the durable state tier. Absence of the file means unlocked: the branch-lock
|
|
85
|
+
layer is strictly opt-in.
|
|
86
|
+
|
|
87
|
+
Layered under it, the dev plugin ships an **unconditional** primary-checkout
|
|
88
|
+
branch guard (ADR 0083 §2, untouchable primary). Once the dev plugin is enabled
|
|
89
|
+
(`plugins.dev.enabled: true`), the pre-tool hook blocks the agent from changing
|
|
90
|
+
the primary checkout's branch — always, with no lock file and no config toggle.
|
|
91
|
+
The answer to "may an agent switch the primary checkout's branch" is not
|
|
92
|
+
configurable: it is always **no**. The historical `dev.lock.primary-branch` key
|
|
93
|
+
stays **readable** for backward compatibility but can no longer enable or disable
|
|
94
|
+
switching; the guard fires regardless of its value, and `/red-doctor` may note it as
|
|
95
|
+
redundant.
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
dev: { lock: { primary-branch: true } } # retained for backward compatibility only — no longer toggles the guard
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The same unconditional guard also blocks the work-destroying commands the
|
|
102
|
+
standing maintainer rules forbid in the primary checkout, because parallel human
|
|
103
|
+
WIP lives there and these have destroyed in-progress work before (issue #1024).
|
|
104
|
+
The refusal points to the sanctioned alternative — do branch work in an isolated
|
|
105
|
+
worktree under `.red/tmp/worktrees/manual/<slug>`, and if the local trunk
|
|
106
|
+
diverged from origin, leave it alone and base on the fresh remote ref (ADR 0083).
|
|
107
|
+
Inside isolated worktrees these commands remain allowed — the guard is about the
|
|
108
|
+
primary checkout only, and `/afk` and `/go` worktrees under their registered
|
|
109
|
+
`.red/tmp/` lanes are always exempt.
|
|
110
|
+
|
|
111
|
+
Enforcement is **agent-only**, via runner pre-tool hooks — Claude Code
|
|
112
|
+
`PreToolUse(Bash)` and Codex plugin `PreToolUse` — that intercept the agent's own
|
|
113
|
+
tool calls, not the human's terminal (ADR 0006, unaffected). See
|
|
114
|
+
[ADR 0006](../../../../../.red/adr/0006-branch-lock-agent-only-enforcement.md).
|
|
115
|
+
The hook logic is self-contained: it depends on neither the
|
|
116
|
+
`git-guardrails-claude-code` skill nor anything else, and the two stack
|
|
117
|
+
harmlessly if both are installed.
|
|
118
|
+
|
|
119
|
+
## Layout
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
branch-lock/
|
|
123
|
+
├── SKILL.md
|
|
124
|
+
├── ../../../hooks/
|
|
125
|
+
│ ├── codex.hooks.json ← Codex plugin hook manifest
|
|
126
|
+
│ └── branch-lock-codex.sh ← Codex PreToolUse hook
|
|
127
|
+
└── scripts/
|
|
128
|
+
├── branch-lock.sh ← /branch-lock CLI (set | clear | status)
|
|
129
|
+
├── branch-lock-hook.sh ← Claude PreToolUse(Bash) hook
|
|
130
|
+
├── branch-lock-session-start.sh ← SessionStart hook (offers to lock at start)
|
|
131
|
+
├── lib/
|
|
132
|
+
│ ├── dev-config.sh ← read dev.lock.primary-branch from .red/config.yaml
|
|
133
|
+
│ ├── lock-store.sh ← read/write/clear branch-lock.yaml
|
|
134
|
+
│ ├── scope-resolver.sh ← primary enforces, registered .red/tmp worktrees exempt
|
|
135
|
+
│ └── git-command-classifier.sh ← branch-switch + work-loss family = block
|
|
136
|
+
└── tests/
|
|
137
|
+
├── lock-store.test.sh
|
|
138
|
+
├── scope-resolver.test.sh
|
|
139
|
+
├── git-command-classifier.test.sh
|
|
140
|
+
├── dev-config.test.sh
|
|
141
|
+
├── session-start.test.sh
|
|
142
|
+
├── claude-plugin-hook.test.sh
|
|
143
|
+
├── codex-hook.test.sh
|
|
144
|
+
└── branch-lock-cli.test.sh ← /branch-lock set: atomic relock-then-switch
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## What the hook blocks vs allows
|
|
148
|
+
|
|
149
|
+
In the primary checkout, once `plugins.dev.enabled: true`, the hook blocks (the
|
|
150
|
+
first three are the unconditional untouchable-primary rule; the rest are the
|
|
151
|
+
work-loss family, blocked only while a branch-lock file is present):
|
|
152
|
+
|
|
153
|
+
- `git checkout <any>` / `git switch <any>` — switching to any branch, including
|
|
154
|
+
the lock target (untouchable primary — the agent can never move the primary)
|
|
155
|
+
- `git checkout -b <new>` / `git switch -c <new>` — leaving via a new branch
|
|
156
|
+
- `git switch -` — switching to the previous branch
|
|
157
|
+
- `git stash` / `git stash push` / `git stash save` — shelving away the working tree
|
|
158
|
+
- `git clean -f` (any force flag: `-fd`, `-xfd`, `--force`) — deleting untracked files
|
|
159
|
+
- `git reset --hard` — discarding the working tree
|
|
160
|
+
- `git checkout .` / `git checkout -- .` / `git restore .` — whole-tree restore
|
|
161
|
+
|
|
162
|
+
It allows (exit 0, silent):
|
|
163
|
+
|
|
164
|
+
- `git checkout -- <path>` / `git restore <path>` — targeted file-level restore
|
|
165
|
+
- `git stash list` / `git stash show` — read-only stash
|
|
166
|
+
- `git clean -n` / `--dry-run` — non-destructive clean
|
|
167
|
+
- `git reset --soft` / mixed reset, `git restore --staged <path>` — no working-tree loss
|
|
168
|
+
- `git worktree add .red/tmp/worktrees/manual/<slug>` — manual worktrees stay in the registered lane
|
|
169
|
+
- `git commit`, `git status` / read-only git, and any other command
|
|
170
|
+
|
|
171
|
+
The dev command proxy has a stricter host-wide invariant when
|
|
172
|
+
`plugins.dev.enabled: true`: any agent-created `git worktree add` destination
|
|
173
|
+
must resolve under a registered lane in the repo's `.red/tmp/`. Branch-lock
|
|
174
|
+
allows the command class; the dev proxy decides whether the destination path is
|
|
175
|
+
inside the repo-owned runtime area.
|
|
176
|
+
|
|
177
|
+
## Tests
|
|
178
|
+
|
|
179
|
+
Pure-module unit tests, run directly with bash:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
for t in scripts/tests/*.test.sh; do bash "$t"; done
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Each prints `summary: N passed, 0 failed` and exits non-zero on any failure.
|
|
186
|
+
|
|
187
|
+
</supporting-info>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# branch-lock-hook.sh — PreToolUse(Bash) hook that blocks the agent from
|
|
3
|
+
# switching away from the locked branch. Self-contained: composes the three
|
|
4
|
+
# pure modules (lock-store, scope-resolver, git-command-classifier) into one
|
|
5
|
+
# allow/block verdict, with no dependency on the git-guardrails skill.
|
|
6
|
+
#
|
|
7
|
+
# Reads the Claude Code PreToolUse payload on stdin, extracts the Bash command,
|
|
8
|
+
# and:
|
|
9
|
+
# 1. resolves the project root (CLAUDE_PROJECT_DIR, else the git toplevel);
|
|
10
|
+
# 2. exits 0 (allow) when the checkout is an /afk worktree — scope exemption;
|
|
11
|
+
# 3. exits 0 (allow) when no lock file is present — unlocked is the default;
|
|
12
|
+
# 4. classifies the command; on "block" prints a clear message to stderr and
|
|
13
|
+
# exits 2 (the Claude Code convention for "deny this tool call").
|
|
14
|
+
#
|
|
15
|
+
# Any verdict other than block is a silent exit 0, so the hook never adds noise
|
|
16
|
+
# to allowed commands.
|
|
17
|
+
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
|
|
20
|
+
allow() {
|
|
21
|
+
printf '{}'
|
|
22
|
+
exit 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
deny() {
|
|
26
|
+
local reason="$1"
|
|
27
|
+
printf '%s\n' "$reason" >&2
|
|
28
|
+
jq -nc --arg reason "$reason" '{
|
|
29
|
+
decision: "block",
|
|
30
|
+
reason: $reason,
|
|
31
|
+
hookSpecificOutput: {
|
|
32
|
+
hookEventName: "PreToolUse",
|
|
33
|
+
permissionDecision: "deny",
|
|
34
|
+
permissionDecisionReason: $reason
|
|
35
|
+
}
|
|
36
|
+
}'
|
|
37
|
+
exit 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
41
|
+
# shellcheck source=lib/lock-store.sh
|
|
42
|
+
source "$HERE/lib/lock-store.sh"
|
|
43
|
+
# shellcheck source=lib/scope-resolver.sh
|
|
44
|
+
source "$HERE/lib/scope-resolver.sh"
|
|
45
|
+
# shellcheck source=lib/git-command-classifier.sh
|
|
46
|
+
source "$HERE/lib/git-command-classifier.sh"
|
|
47
|
+
# shellcheck source=lib/dev-config.sh
|
|
48
|
+
source "$HERE/lib/dev-config.sh"
|
|
49
|
+
|
|
50
|
+
INPUT="$(timeout "${RED_SKILLS_HOOK_STDIN_TIMEOUT_S:-5s}" cat 2>/dev/null || true)"
|
|
51
|
+
COMMAND="$(jq -r '.tool_input.command // empty' <<<"$INPUT" 2>/dev/null)"
|
|
52
|
+
[[ -z "$COMMAND" ]] && allow
|
|
53
|
+
|
|
54
|
+
# Project root: prefer the harness-provided dir, fall back to the git toplevel.
|
|
55
|
+
ROOT="${CLAUDE_PROJECT_DIR:-}"
|
|
56
|
+
if [[ -z "$ROOT" ]]; then
|
|
57
|
+
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
58
|
+
fi
|
|
59
|
+
[[ -z "$ROOT" ]] && allow
|
|
60
|
+
|
|
61
|
+
# Per-directory plugin gate (ADR 0067): the dev plugin's hooks are installed
|
|
62
|
+
# globally but must stay fully inert in any repo that did not opt in. Exit before
|
|
63
|
+
# any lock/scope work unless `plugins.dev.enabled: true` is set here.
|
|
64
|
+
dev_plugin_enabled "$ROOT/.red/config.yaml" || allow
|
|
65
|
+
|
|
66
|
+
# Scope: /afk worktrees are exempt even when a lock is active.
|
|
67
|
+
scope_should_enforce "$ROOT" || allow
|
|
68
|
+
|
|
69
|
+
# Untouchable primary (ADR 0083 §2): an agent may never move the primary
|
|
70
|
+
# checkout's branch. This block is unconditional — it no longer arms with the
|
|
71
|
+
# `dev.lock.primary-branch` toggle (which stays readable for backward
|
|
72
|
+
# compatibility but can no longer *enable* switching; /red-doctor may flag it as
|
|
73
|
+
# redundant). Human terminals are unaffected: this is an agent-only pre-tool hook
|
|
74
|
+
# (ADR 0006).
|
|
75
|
+
if [[ "$(classify_primary_branch_switch_guard "$COMMAND")" == "block" ]]; then
|
|
76
|
+
deny "$(cat <<EOF
|
|
77
|
+
BLOCKED by the untouchable-primary rule (ADR 0083): an agent can never switch
|
|
78
|
+
the primary checkout's branch or destroy work in it, regardless of
|
|
79
|
+
configuration or lock state. The command '$COMMAND' would move the agent's
|
|
80
|
+
primary checkout to another branch or destroy work in it (branch switch,
|
|
81
|
+
'git reset' in any form, 'git stash', or 'git rebase --autostash'). Parallel
|
|
82
|
+
human WIP lives in this primary checkout, and these commands have destroyed
|
|
83
|
+
in-progress work before.
|
|
84
|
+
|
|
85
|
+
Do branch work in an isolated worktree under .red/tmp/worktrees/manual/<slug> instead
|
|
86
|
+
('git worktree add .red/tmp/worktrees/manual/<slug> -b <branch> origin/main'). If the local
|
|
87
|
+
trunk diverged from origin, leave it alone and base on the fresh remote ref
|
|
88
|
+
(ADR 0083) — never reset or stash the primary to reconcile it.
|
|
89
|
+
|
|
90
|
+
Allowed in the primary checkout: git commit, git worktree add, read-only git,
|
|
91
|
+
and other non-destructive commands. To change the primary branch, ask the user.
|
|
92
|
+
EOF
|
|
93
|
+
)"
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
LOCKFILE="$ROOT/.red/tmp/branch-lock.yaml"
|
|
97
|
+
LOCK_BRANCH="$(lock_store_read "$LOCKFILE")" || allow # absent => unlocked
|
|
98
|
+
|
|
99
|
+
if [[ "$(classify_git_command "$LOCK_BRANCH" "$COMMAND")" == "block" ]]; then
|
|
100
|
+
deny "$(cat <<EOF
|
|
101
|
+
BLOCKED by branch lock: this session is locked to '$LOCK_BRANCH'.
|
|
102
|
+
The command '$COMMAND' would switch the agent away from the locked branch or
|
|
103
|
+
discard working-tree changes (stash, clean -f, reset --hard, whole-tree restore).
|
|
104
|
+
|
|
105
|
+
Allowed while locked: switching back to '$LOCK_BRANCH', targeted file restore
|
|
106
|
+
('git checkout -- <path>', 'git restore <path>'), read-only stash, dry-run clean,
|
|
107
|
+
soft/mixed reset, and 'git worktree add'. To change or release the lock, ask the
|
|
108
|
+
user — they drive it with '/branch-lock <branch>' or '/branch-lock clear'.
|
|
109
|
+
EOF
|
|
110
|
+
)"
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
allow
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# branch-lock-session-start.sh — SessionStart hook that offers, once at session
|
|
3
|
+
# start, to lock the agent to the current branch. Self-contained: reuses the
|
|
4
|
+
# same two pure modules as the PreToolUse hook (lock-store, scope-resolver) so
|
|
5
|
+
# the prompt obeys the exact same scope rule as enforcement.
|
|
6
|
+
#
|
|
7
|
+
# On SessionStart it:
|
|
8
|
+
# 1. resolves the project root (CLAUDE_PROJECT_DIR, else the git toplevel);
|
|
9
|
+
# 2. stays silent (exit 0, no output) inside an /afk worktree — the lock
|
|
10
|
+
# protects the interactive primary checkout, never the autonomous loop;
|
|
11
|
+
# 3. stays silent when a lock is already present — nothing to offer;
|
|
12
|
+
# 4. stays silent on a detached HEAD — there is no branch to lock;
|
|
13
|
+
# 5. otherwise emits a SessionStart additionalContext block instructing the
|
|
14
|
+
# agent to ask the user whether to lock to the current branch before doing
|
|
15
|
+
# anything else.
|
|
16
|
+
#
|
|
17
|
+
# The hook never writes the lock itself: it only injects the instruction. The
|
|
18
|
+
# agent runs `branch-lock.sh set <branch>` (or the user's /branch-lock command)
|
|
19
|
+
# on a "yes", and leaves the repo unlocked on a "no".
|
|
20
|
+
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
24
|
+
# shellcheck source=lib/lock-store.sh
|
|
25
|
+
source "$HERE/lib/lock-store.sh"
|
|
26
|
+
# shellcheck source=lib/scope-resolver.sh
|
|
27
|
+
source "$HERE/lib/scope-resolver.sh"
|
|
28
|
+
|
|
29
|
+
# session_start_should_prompt <root> <lockfile> <branch>
|
|
30
|
+
# Returns 0 (prompt) only in the primary checkout, while unlocked, with a real
|
|
31
|
+
# branch checked out. Returns 1 (stay silent) otherwise. No output.
|
|
32
|
+
session_start_should_prompt() {
|
|
33
|
+
local _root="$1" _lockfile="$2" _branch="$3"
|
|
34
|
+
scope_should_enforce "$_root" || return 1 # worktree => exempt
|
|
35
|
+
lock_store_is_locked "$_lockfile" && return 1 # already locked => nothing to offer
|
|
36
|
+
[[ -z "$_branch" || "$_branch" == "HEAD" ]] && return 1 # detached / no branch
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# session_start_prompt_text <branch>
|
|
41
|
+
# The instruction injected into the agent's context.
|
|
42
|
+
session_start_prompt_text() {
|
|
43
|
+
local _branch="$1"
|
|
44
|
+
cat <<EOF
|
|
45
|
+
branch-lock: this session is on branch '$_branch' in the primary checkout, and the branch is not locked. Before doing anything else, ask the user whether to lock the agent to '$_branch' for this session, showing the branch name. If they say yes, lock it with the /branch-lock command (or run scripts/branch-lock.sh set '$_branch'). If they say no, leave the repo unlocked and proceed. Do not lock without an explicit yes.
|
|
46
|
+
EOF
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
main() {
|
|
50
|
+
# Drain stdin (SessionStart sends a JSON payload we don't need to inspect).
|
|
51
|
+
cat >/dev/null 2>&1 || true
|
|
52
|
+
|
|
53
|
+
local root="${CLAUDE_PROJECT_DIR:-}"
|
|
54
|
+
if [[ -z "$root" ]]; then
|
|
55
|
+
root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
56
|
+
fi
|
|
57
|
+
[[ -z "$root" ]] && exit 0
|
|
58
|
+
|
|
59
|
+
local lockfile="$root/.red/tmp/branch-lock.yaml"
|
|
60
|
+
local branch
|
|
61
|
+
branch="$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
62
|
+
|
|
63
|
+
session_start_should_prompt "$root" "$lockfile" "$branch" || exit 0
|
|
64
|
+
|
|
65
|
+
local text
|
|
66
|
+
text="$(session_start_prompt_text "$branch")"
|
|
67
|
+
|
|
68
|
+
if command -v jq >/dev/null 2>&1; then
|
|
69
|
+
jq -nc --arg ctx "$text" \
|
|
70
|
+
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $ctx}}'
|
|
71
|
+
else
|
|
72
|
+
# Fallback: bare stdout is added to the session context too.
|
|
73
|
+
printf '%s\n' "$text"
|
|
74
|
+
fi
|
|
75
|
+
exit 0
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Run main only when executed directly, not when sourced by tests.
|
|
79
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
80
|
+
main "$@"
|
|
81
|
+
fi
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# branch-lock.sh — the CLI behind the /branch-lock command. Sets, clears, and
|
|
3
|
+
# reports the branch lock, keeping lib/lock-store.sh the single writer of the
|
|
4
|
+
# lock file. The lock always lives at <root>/.red/tmp/branch-lock.yaml, where
|
|
5
|
+
# <root> is CLAUDE_PROJECT_DIR (set by the harness) or the git toplevel.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# branch-lock.sh set [branch] lock to <branch> (default: current branch),
|
|
9
|
+
# atomic relock-then-switch — rewrite the lock
|
|
10
|
+
# target first so the move "back to the lock
|
|
11
|
+
# target" is itself allowed by the hook, then
|
|
12
|
+
# switch the working tree if not already there.
|
|
13
|
+
# branch-lock.sh clear release the lock.
|
|
14
|
+
# branch-lock.sh status print the locked branch, or "unlocked".
|
|
15
|
+
|
|
16
|
+
set -uo pipefail
|
|
17
|
+
|
|
18
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
# shellcheck source=lib/lock-store.sh
|
|
20
|
+
source "$HERE/lib/lock-store.sh"
|
|
21
|
+
|
|
22
|
+
resolve_root() {
|
|
23
|
+
local root="${CLAUDE_PROJECT_DIR:-}"
|
|
24
|
+
[[ -z "$root" ]] && root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
25
|
+
[[ -z "$root" ]] && { echo "branch-lock: not inside a git repo and CLAUDE_PROJECT_DIR unset" >&2; return 1; }
|
|
26
|
+
printf '%s' "$root"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
main() {
|
|
30
|
+
local action="${1:-status}"
|
|
31
|
+
local root lockfile
|
|
32
|
+
root="$(resolve_root)" || return 1
|
|
33
|
+
lockfile="$root/.red/tmp/branch-lock.yaml"
|
|
34
|
+
|
|
35
|
+
case "$action" in
|
|
36
|
+
set)
|
|
37
|
+
local branch="${2:-}"
|
|
38
|
+
[[ -z "$branch" ]] && branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
|
|
39
|
+
[[ -z "$branch" ]] && { echo "branch-lock: no branch given and HEAD is detached" >&2; return 1; }
|
|
40
|
+
# Atomic relock-then-switch: rewrite the target before moving, so the
|
|
41
|
+
# subsequent switch is "return to the lock target" and the hook allows it.
|
|
42
|
+
lock_store_write "$lockfile" "$branch" || return 1
|
|
43
|
+
local current; current="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
44
|
+
if [[ -n "$current" && "$current" != "$branch" ]]; then
|
|
45
|
+
git switch "$branch" || { echo "branch-lock: locked to '$branch' but switch failed (resolve manually)" >&2; return 1; }
|
|
46
|
+
fi
|
|
47
|
+
echo "branch-lock: locked to '$branch'"
|
|
48
|
+
;;
|
|
49
|
+
clear)
|
|
50
|
+
lock_store_clear "$lockfile"
|
|
51
|
+
echo "branch-lock: cleared (unlocked)"
|
|
52
|
+
;;
|
|
53
|
+
status)
|
|
54
|
+
local b
|
|
55
|
+
if b="$(lock_store_read "$lockfile")"; then
|
|
56
|
+
echo "branch-lock: locked to '$b'"
|
|
57
|
+
else
|
|
58
|
+
echo "branch-lock: unlocked"
|
|
59
|
+
fi
|
|
60
|
+
;;
|
|
61
|
+
*)
|
|
62
|
+
echo "usage: branch-lock.sh <set [branch]|clear|status>" >&2
|
|
63
|
+
return 2
|
|
64
|
+
;;
|
|
65
|
+
esac
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
main "$@"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# lib/dev-config.sh — tiny runtime reader for `.red/config.yaml` dev flags.
|
|
3
|
+
#
|
|
4
|
+
# The hook must be dormant by default: missing config file or missing key means
|
|
5
|
+
# disabled. This parser intentionally mirrors the constrained YAML shape used by
|
|
6
|
+
# the dev runtime: nested 2-space mappings with scalar leaves. It exposes the
|
|
7
|
+
# few boolean flags the dev hooks need (ADR 0043 lock guard, ADR 0067 gate).
|
|
8
|
+
|
|
9
|
+
# _dev_config_scalar_true <config.yaml> <dotted-key...>
|
|
10
|
+
# Returns 0 when ANY of the given dotted keys is the exact scalar `true`, 1
|
|
11
|
+
# otherwise (including a missing file). Shared parser for the public readers.
|
|
12
|
+
_dev_config_scalar_true() {
|
|
13
|
+
local _file="$1"
|
|
14
|
+
shift
|
|
15
|
+
local -a _keys=("$@")
|
|
16
|
+
[[ -f "$_file" ]] || return 1
|
|
17
|
+
|
|
18
|
+
local -a _stack=()
|
|
19
|
+
local -a _indents=()
|
|
20
|
+
local _raw _line _indent_str _indent _rest _key _value _full _i
|
|
21
|
+
local _inner _before_close _close_len _tail
|
|
22
|
+
|
|
23
|
+
while IFS= read -r _raw || [[ -n "$_raw" ]]; do
|
|
24
|
+
_raw="${_raw%$'\r'}"
|
|
25
|
+
_line="$_raw"
|
|
26
|
+
|
|
27
|
+
if [[ "$_line" != *\"* && "$_line" != *\'* ]]; then
|
|
28
|
+
_line="${_line%%#*}"
|
|
29
|
+
fi
|
|
30
|
+
_line="${_line%"${_line##*[![:space:]]}"}"
|
|
31
|
+
[[ -z "${_line//[[:space:]]/}" ]] && continue
|
|
32
|
+
|
|
33
|
+
_indent_str="${_line%%[![:space:]]*}"
|
|
34
|
+
_indent=${#_indent_str}
|
|
35
|
+
(( _indent % 2 == 0 )) || return 1
|
|
36
|
+
_rest="${_line:$_indent}"
|
|
37
|
+
|
|
38
|
+
# Skip block-sequence items (`- value`) — they never contain dev flags.
|
|
39
|
+
# A top-level sequence (empty stack) mirrors the TS parser: malformed → bail.
|
|
40
|
+
if [[ "$_rest" =~ ^-([[:space:]]|$) ]]; then
|
|
41
|
+
(( ${#_stack[@]} == 0 )) && return 1
|
|
42
|
+
continue
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
[[ "$_rest" =~ ^[A-Za-z_][A-Za-z0-9_-]*: ]] || return 1
|
|
46
|
+
_key="${_rest%%:*}"
|
|
47
|
+
_value="${_rest#*:}"
|
|
48
|
+
_value="${_value#"${_value%%[![:space:]]*}"}"
|
|
49
|
+
|
|
50
|
+
while ((${#_indents[@]} > 0 && _indents[${#_indents[@]} - 1] >= _indent)); do
|
|
51
|
+
local _last_stack=$(( ${#_stack[@]} - 1 ))
|
|
52
|
+
local _last_indent=$(( ${#_indents[@]} - 1 ))
|
|
53
|
+
unset "_stack[$_last_stack]"
|
|
54
|
+
unset "_indents[$_last_indent]"
|
|
55
|
+
done
|
|
56
|
+
|
|
57
|
+
_full=""
|
|
58
|
+
for ((_i = 0; _i < ${#_stack[@]}; _i++)); do
|
|
59
|
+
[[ -n "$_full" ]] && _full+="."
|
|
60
|
+
_full+="${_stack[$_i]}"
|
|
61
|
+
done
|
|
62
|
+
[[ -n "$_full" ]] && _full+="."
|
|
63
|
+
_full+="$_key"
|
|
64
|
+
|
|
65
|
+
if [[ -z "$_value" ]]; then
|
|
66
|
+
_stack+=("$_key")
|
|
67
|
+
_indents+=("$_indent")
|
|
68
|
+
continue
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
# Strip an inline comment that follows a closing quote (e.g. `key: "v" # note`).
|
|
72
|
+
if [[ "${_value:0:1}" == '"' ]]; then
|
|
73
|
+
_inner="${_value:1}"
|
|
74
|
+
_before_close="${_inner%%\"*}"
|
|
75
|
+
if [[ "$_inner" == *'"'* ]]; then
|
|
76
|
+
_close_len=$(( 1 + ${#_before_close} + 1 ))
|
|
77
|
+
_tail="${_value:$_close_len}"
|
|
78
|
+
if [[ "$_tail" =~ ^[[:space:]]*(#.*)?$ ]]; then
|
|
79
|
+
_value="${_value:0:$_close_len}"
|
|
80
|
+
fi
|
|
81
|
+
fi
|
|
82
|
+
elif [[ "${_value:0:1}" == "'" ]]; then
|
|
83
|
+
_inner="${_value:1}"
|
|
84
|
+
_before_close="${_inner%%\'*}"
|
|
85
|
+
if [[ "$_inner" == *"'"* ]]; then
|
|
86
|
+
_close_len=$(( 1 + ${#_before_close} + 1 ))
|
|
87
|
+
_tail="${_value:$_close_len}"
|
|
88
|
+
if [[ "$_tail" =~ ^[[:space:]]*(#.*)?$ ]]; then
|
|
89
|
+
_value="${_value:0:$_close_len}"
|
|
90
|
+
fi
|
|
91
|
+
fi
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
if [[ "$_value" == \"*\" && "$_value" == *\" ]]; then
|
|
95
|
+
_value="${_value:1:${#_value}-2}"
|
|
96
|
+
elif [[ "$_value" == \'*\' && "$_value" == *\' ]]; then
|
|
97
|
+
_value="${_value:1:${#_value}-2}"
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
local _k
|
|
101
|
+
for _k in "${_keys[@]}"; do
|
|
102
|
+
if [[ "$_full" == "$_k" ]]; then
|
|
103
|
+
[[ "$_value" == "true" ]] && return 0
|
|
104
|
+
fi
|
|
105
|
+
done
|
|
106
|
+
done < "$_file"
|
|
107
|
+
|
|
108
|
+
return 1
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# dev_config_lock_primary_branch_enabled <config.yaml>
|
|
112
|
+
# Returns 0 when the primary-branch lock is on, 1 otherwise. The flag lives at
|
|
113
|
+
# `dev.lock.primary-branch` (top-level) or `plugins.dev.lock.primary-branch`
|
|
114
|
+
# (namespaced); either set to exactly `true` enables the guard.
|
|
115
|
+
dev_config_lock_primary_branch_enabled() {
|
|
116
|
+
_dev_config_scalar_true "$1" "dev.lock.primary-branch" "plugins.dev.lock.primary-branch"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# dev_plugin_enabled <config.yaml>
|
|
120
|
+
# The per-directory plugin activation gate (ADR 0067): returns 0 only when
|
|
121
|
+
# `plugins.dev.enabled` is the exact scalar `true`. A missing file or missing
|
|
122
|
+
# flag → 1 (dormant). Every dev shell hook must early-exit on a non-zero return
|
|
123
|
+
# so it stays fully inert in repos that did not opt into the dev plugin.
|
|
124
|
+
dev_plugin_enabled() {
|
|
125
|
+
_dev_config_scalar_true "$1" "plugins.dev.enabled"
|
|
126
|
+
}
|