@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,198 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# lib/git-command-classifier.sh — the Git Command Classifier: given the locked
|
|
3
|
+
# branch and a shell command string, decides whether the command would lose the
|
|
4
|
+
# agent's work while a lock is active and must be blocked. Pure / explicit-args,
|
|
5
|
+
# like the sibling lib/ modules. Self-contained: it carries its own small
|
|
6
|
+
# command matcher so the branch-lock hook needs no dependency on the
|
|
7
|
+
# git-guardrails skill.
|
|
8
|
+
#
|
|
9
|
+
# It blocks two families of command:
|
|
10
|
+
# 1. Branch-leaving `git checkout <branch>` / `git switch <branch>` (the
|
|
11
|
+
# original slice) — unless the target is the lock branch itself.
|
|
12
|
+
# 2. The work-loss family (issue #61): commands that throw away the working
|
|
13
|
+
# tree or the stash —
|
|
14
|
+
# - `git stash` / `git stash push` / `git stash save` (bare stash defaults to push)
|
|
15
|
+
# - `git clean` with any force flag (`-f`, `-fd`, `-xfd`, `--force`)
|
|
16
|
+
# - `git reset --hard`
|
|
17
|
+
# - whole-tree restore: `git checkout .`, `git checkout -- .`, `git restore .`
|
|
18
|
+
#
|
|
19
|
+
# Everything else is allowed — switching back to the lock branch, targeted
|
|
20
|
+
# single-file restore (`git checkout -- <path>`, `git restore <path>`), a bare
|
|
21
|
+
# `git checkout`, read-only stash (`list`/`show`), a dry-run clean (`-n`), a
|
|
22
|
+
# soft/mixed reset, an unstage (`git restore --staged`), `git worktree add`
|
|
23
|
+
# (worktrees are how /afk works and are exempt by scope), and any other command.
|
|
24
|
+
#
|
|
25
|
+
# Recognition is intentionally conservative: it scans the token stream for a
|
|
26
|
+
# `git` token, skips any global options between `git` and the subcommand
|
|
27
|
+
# (`git -C <path> checkout x`, `git --git-dir=… switch y`, `git -c k=v …`), and
|
|
28
|
+
# classifies the recognised subcommand, so a compound command
|
|
29
|
+
# (`cd x && git reset --hard`) and a global-option form are both classified.
|
|
30
|
+
#
|
|
31
|
+
# Public surface:
|
|
32
|
+
# classify_git_command <lock_branch> <command>
|
|
33
|
+
# Echoes exactly "block" or "allow" and returns 0.
|
|
34
|
+
#
|
|
35
|
+
# classify_primary_branch_switch_guard <command>
|
|
36
|
+
# Echoes exactly "block" for commands that would move or destroy work in the
|
|
37
|
+
# primary checkout, and "allow" otherwise. This is the config-flag guard
|
|
38
|
+
# from ADR 0043 (unlike classify_git_command it has no lock branch). It
|
|
39
|
+
# blocks: branch movement (switch/create/checkout to a branch); `git reset`
|
|
40
|
+
# in ANY form; every `git stash` subcommand; and `git rebase --autostash`
|
|
41
|
+
# (issue #1024). Parallel human WIP lives in the primary checkout and these
|
|
42
|
+
# commands have destroyed in-progress work before; do branch work in an
|
|
43
|
+
# isolated worktree under .red/tmp/ instead. Worktrees are exempt by scope,
|
|
44
|
+
# so this classifier only ever runs against the primary checkout.
|
|
45
|
+
|
|
46
|
+
# _git_subcommand_index <toks-name> <git-index>
|
|
47
|
+
# Given the name of the token array and the index of a `git` token, echoes the
|
|
48
|
+
# index of the git subcommand, skipping any leading global options. Global
|
|
49
|
+
# options that take a separate argument (`-C <path>`, `-c <name=value>`,
|
|
50
|
+
# `--git-dir <path>`, `--work-tree <path>`, `--namespace <name>`,
|
|
51
|
+
# `--exec-path <path>`, `--super-prefix <path>`) consume the following token;
|
|
52
|
+
# the `--opt=value` form is self-contained, and bare flags (`-p`, `--no-pager`,
|
|
53
|
+
# `--bare`, …) consume only themselves. Echoes the array length when no
|
|
54
|
+
# subcommand follows.
|
|
55
|
+
_git_subcommand_index() {
|
|
56
|
+
local -n _t="$1"
|
|
57
|
+
local k=$(( $2 + 1 ))
|
|
58
|
+
local len=${#_t[@]}
|
|
59
|
+
while (( k < len )); do
|
|
60
|
+
local tok="${_t[k]}"
|
|
61
|
+
case "$tok" in
|
|
62
|
+
-C|-c|--git-dir|--work-tree|--namespace|--exec-path|--super-prefix)
|
|
63
|
+
k=$(( k + 2 )); continue ;; # option + its separate argument
|
|
64
|
+
--*=*|-*)
|
|
65
|
+
k=$(( k + 1 )); continue ;; # `--opt=value` or a bare flag
|
|
66
|
+
*)
|
|
67
|
+
break ;; # the subcommand token
|
|
68
|
+
esac
|
|
69
|
+
done
|
|
70
|
+
echo "$k"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# classify_git_command <lock_branch> <command>
|
|
74
|
+
classify_git_command() {
|
|
75
|
+
local _lock="$1" _cmd="$2"
|
|
76
|
+
local -a toks
|
|
77
|
+
read -ra toks <<<"$_cmd"
|
|
78
|
+
local n=${#toks[@]} i j
|
|
79
|
+
|
|
80
|
+
for ((i = 0; i < n; i++)); do
|
|
81
|
+
[[ "${toks[i]}" == "git" ]] || continue
|
|
82
|
+
local s; s="$(_git_subcommand_index toks "$i")" # subcommand index, past global options
|
|
83
|
+
local sub="${toks[s]:-}"
|
|
84
|
+
case "$sub" in
|
|
85
|
+
worktree)
|
|
86
|
+
echo "allow"; return 0
|
|
87
|
+
;;
|
|
88
|
+
stash)
|
|
89
|
+
# bare `git stash` defaults to push; push/save discard the working tree.
|
|
90
|
+
local op="${toks[s + 1]:-}"
|
|
91
|
+
if [[ -z "$op" || "$op" == "push" || "$op" == "save" ]]; then
|
|
92
|
+
echo "block"; return 0
|
|
93
|
+
fi
|
|
94
|
+
echo "allow"; return 0 # list / show / pop / apply / drop …
|
|
95
|
+
;;
|
|
96
|
+
clean)
|
|
97
|
+
# any force flag makes clean destructive; -n/--dry-run is safe.
|
|
98
|
+
for ((j = s + 1; j < n; j++)); do
|
|
99
|
+
local t="${toks[j]}"
|
|
100
|
+
[[ "$t" == "--force" ]] && { echo "block"; return 0; }
|
|
101
|
+
# short flag bundle containing 'f' (-f, -fd, -xfd, …) but not -n.
|
|
102
|
+
if [[ "$t" == -[^-]* && "$t" == *f* ]]; then echo "block"; return 0; fi
|
|
103
|
+
done
|
|
104
|
+
echo "allow"; return 0
|
|
105
|
+
;;
|
|
106
|
+
reset)
|
|
107
|
+
for ((j = s + 1; j < n; j++)); do
|
|
108
|
+
[[ "${toks[j]}" == "--hard" ]] && { echo "block"; return 0; }
|
|
109
|
+
done
|
|
110
|
+
echo "allow"; return 0
|
|
111
|
+
;;
|
|
112
|
+
restore)
|
|
113
|
+
# whole-tree restore (`git restore .`) loses work; a targeted path or an
|
|
114
|
+
# unstage (`--staged <path>`) is allowed, mirroring `checkout -- <path>`.
|
|
115
|
+
for ((j = s + 1; j < n; j++)); do
|
|
116
|
+
local t="${toks[j]}"
|
|
117
|
+
[[ "$t" == -* ]] && continue # skip flags (--staged, --source=…, …)
|
|
118
|
+
if [[ "$t" == "." ]]; then echo "block"; return 0; fi
|
|
119
|
+
break # a named path: allowed
|
|
120
|
+
done
|
|
121
|
+
echo "allow"; return 0
|
|
122
|
+
;;
|
|
123
|
+
checkout|switch)
|
|
124
|
+
local target="" sawdoubledash=0
|
|
125
|
+
for ((j = s + 1; j < n; j++)); do
|
|
126
|
+
local t="${toks[j]}"
|
|
127
|
+
if [[ "$t" == "--" ]]; then sawdoubledash=1; continue; fi
|
|
128
|
+
if [[ "$t" == "-" ]]; then target="-"; break; fi # `switch -` = previous branch
|
|
129
|
+
[[ "$t" == -* ]] && continue # skip flags (-b, -c, -q, --track, …)
|
|
130
|
+
target="$t"; break
|
|
131
|
+
done
|
|
132
|
+
# whole-tree restore via checkout (`git checkout .`, `git checkout -- .`).
|
|
133
|
+
if [[ "$target" == "." ]]; then echo "block"; return 0; fi
|
|
134
|
+
if (( sawdoubledash )); then echo "allow"; return 0; fi # file restore
|
|
135
|
+
if [[ -z "$target" ]]; then echo "allow"; return 0; fi # bare checkout
|
|
136
|
+
if [[ "$target" == "$_lock" ]]; then echo "allow"; return 0; fi # back to lock
|
|
137
|
+
echo "block"; return 0
|
|
138
|
+
;;
|
|
139
|
+
esac
|
|
140
|
+
done
|
|
141
|
+
|
|
142
|
+
echo "allow"; return 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# classify_primary_branch_switch_guard <command>
|
|
146
|
+
classify_primary_branch_switch_guard() {
|
|
147
|
+
local _cmd="$1"
|
|
148
|
+
local -a toks
|
|
149
|
+
read -ra toks <<<"$_cmd"
|
|
150
|
+
local n=${#toks[@]} i j
|
|
151
|
+
|
|
152
|
+
for ((i = 0; i < n; i++)); do
|
|
153
|
+
[[ "${toks[i]}" == "git" ]] || continue
|
|
154
|
+
local s; s="$(_git_subcommand_index toks "$i")" # subcommand index, past global options
|
|
155
|
+
local sub="${toks[s]:-}"
|
|
156
|
+
case "$sub" in
|
|
157
|
+
worktree)
|
|
158
|
+
echo "allow"; return 0
|
|
159
|
+
;;
|
|
160
|
+
reset)
|
|
161
|
+
# `git reset` in ANY form (--hard/--soft/--mixed, with or without a
|
|
162
|
+
# pathspec) can throw away parallel human WIP in the primary checkout
|
|
163
|
+
# (issue #1024). All forms blocked; use a worktree for branch work.
|
|
164
|
+
echo "block"; return 0
|
|
165
|
+
;;
|
|
166
|
+
stash)
|
|
167
|
+
# `git stash` and every subcommand (push/save/pop/apply/list/show/…)
|
|
168
|
+
# blocked in the primary checkout (issue #1024) — the whole family
|
|
169
|
+
# touches or hides the primary's working tree.
|
|
170
|
+
echo "block"; return 0
|
|
171
|
+
;;
|
|
172
|
+
rebase)
|
|
173
|
+
# `git rebase --autostash` silently stashes/pops the primary's WIP
|
|
174
|
+
# around the rebase (issue #1024); a plain rebase is not this vector.
|
|
175
|
+
for ((j = s + 1; j < n; j++)); do
|
|
176
|
+
[[ "${toks[j]}" == "--autostash" ]] && { echo "block"; return 0; }
|
|
177
|
+
done
|
|
178
|
+
echo "allow"; return 0
|
|
179
|
+
;;
|
|
180
|
+
checkout|switch)
|
|
181
|
+
local target="" sawdoubledash=0
|
|
182
|
+
for ((j = s + 1; j < n; j++)); do
|
|
183
|
+
local t="${toks[j]}"
|
|
184
|
+
if [[ "$t" == "--" ]]; then sawdoubledash=1; continue; fi
|
|
185
|
+
if [[ "$t" == "-" ]]; then target="-"; break; fi
|
|
186
|
+
[[ "$t" == -* ]] && continue
|
|
187
|
+
target="$t"; break
|
|
188
|
+
done
|
|
189
|
+
if (( sawdoubledash )); then echo "allow"; return 0; fi
|
|
190
|
+
if [[ -z "$target" ]]; then echo "allow"; return 0; fi
|
|
191
|
+
if [[ "$target" == "." ]]; then echo "allow"; return 0; fi
|
|
192
|
+
echo "block"; return 0
|
|
193
|
+
;;
|
|
194
|
+
esac
|
|
195
|
+
done
|
|
196
|
+
|
|
197
|
+
echo "allow"; return 0
|
|
198
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# lib/lock-store.sh — the Lock Store: owns the branch-lock.yaml file end to end,
|
|
3
|
+
# following the pure / explicit-args contract of the afk lib/ modules.
|
|
4
|
+
#
|
|
5
|
+
# The lock file records the single branch the agent is pinned to in the primary
|
|
6
|
+
# checkout. Its presence means "locked"; its absence means "unlocked". Content
|
|
7
|
+
# is the branch name on one line (a bare YAML scalar — valid YAML, trivially
|
|
8
|
+
# inspectable). It lives under the gitignored .red/tmp/ so every checkout and
|
|
9
|
+
# machine locks independently and nothing is ever committed.
|
|
10
|
+
#
|
|
11
|
+
# Reads no globals. The lock-file path is a parameter on every call, so the
|
|
12
|
+
# Module stays unit-testable by sourcing it directly. Writes are atomic
|
|
13
|
+
# (temp-in-same-dir then mv) so a concurrent reader never sees a half-written
|
|
14
|
+
# file and a relock can never leave the target empty.
|
|
15
|
+
#
|
|
16
|
+
# Public surface:
|
|
17
|
+
# lock_store_write <lockfile> <branch>
|
|
18
|
+
# Atomically writes <branch> as the lock target. Creates the parent dir if
|
|
19
|
+
# missing. Returns 2 on missing arguments.
|
|
20
|
+
#
|
|
21
|
+
# lock_store_read <lockfile>
|
|
22
|
+
# Echoes the locked branch name (trailing whitespace stripped) and returns
|
|
23
|
+
# 0 when locked. Prints nothing and returns 1 when the file is absent
|
|
24
|
+
# (= unlocked), so absence is strictly opt-out.
|
|
25
|
+
#
|
|
26
|
+
# lock_store_clear <lockfile>
|
|
27
|
+
# Removes the lock file. Idempotent — returns 0 whether or not it existed.
|
|
28
|
+
#
|
|
29
|
+
# lock_store_is_locked <lockfile>
|
|
30
|
+
# Returns 0 when a non-empty lock file is present, 1 otherwise. No output.
|
|
31
|
+
|
|
32
|
+
# lock_store_write <lockfile> <branch>
|
|
33
|
+
lock_store_write() {
|
|
34
|
+
local _path="$1" _branch="$2"
|
|
35
|
+
[[ -z "$_path" || -z "$_branch" ]] && { printf '[lock-store] lock_store_write: need <lockfile> <branch>\n' >&2; return 2; }
|
|
36
|
+
mkdir -p "$(dirname "$_path")"
|
|
37
|
+
local tmp
|
|
38
|
+
tmp="$(mktemp "$(dirname "$_path")/.branch-lock.XXXXXX")"
|
|
39
|
+
printf '%s\n' "$_branch" > "$tmp"
|
|
40
|
+
mv -f "$tmp" "$_path"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# lock_store_read <lockfile>
|
|
44
|
+
lock_store_read() {
|
|
45
|
+
local _path="$1"
|
|
46
|
+
[[ -s "$_path" ]] || return 1
|
|
47
|
+
local branch
|
|
48
|
+
# strip trailing whitespace/newlines; keep the first line only
|
|
49
|
+
IFS= read -r branch < "$_path"
|
|
50
|
+
branch="${branch%"${branch##*[![:space:]]}"}"
|
|
51
|
+
[[ -n "$branch" ]] || return 1
|
|
52
|
+
printf '%s' "$branch"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# lock_store_clear <lockfile>
|
|
56
|
+
lock_store_clear() {
|
|
57
|
+
local _path="$1"
|
|
58
|
+
[[ -z "$_path" ]] && { printf '[lock-store] lock_store_clear: need <lockfile>\n' >&2; return 2; }
|
|
59
|
+
rm -f "$_path"
|
|
60
|
+
return 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# lock_store_is_locked <lockfile>
|
|
64
|
+
lock_store_is_locked() {
|
|
65
|
+
local _path="$1"
|
|
66
|
+
lock_store_read "$_path" >/dev/null 2>&1
|
|
67
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# lib/scope-resolver.sh — the Scope Resolver: decides whether the branch lock is
|
|
3
|
+
# enforced for a given git checkout, by toplevel location alone. Pure /
|
|
4
|
+
# explicit-args, like lib/lock-store.sh.
|
|
5
|
+
#
|
|
6
|
+
# The lock is defined as "the primary checkout's branch". /afk does its work in
|
|
7
|
+
# isolated worktrees under registered .red/tmp worktree lanes, which must never
|
|
8
|
+
# be strangled by an interactive session's lock. The robust discriminator is
|
|
9
|
+
# *where the checkout lives*, not its branch name or an env flag (a branch can be
|
|
10
|
+
# named anything; an env flag relies on every caller setting it). So: a toplevel
|
|
11
|
+
# under .red/tmp/worktrees/<lane>/<slug> or the legacy .red/tmp/work-*/ shape is
|
|
12
|
+
# a worktree (exempt); anything else is the primary checkout (enforced).
|
|
13
|
+
#
|
|
14
|
+
# Public surface:
|
|
15
|
+
# scope_is_worktree <toplevel>
|
|
16
|
+
# Returns 0 when <toplevel> is (or is nested under) a registered .red/tmp
|
|
17
|
+
# worktree lane or legacy .red/tmp/work-* directory. Returns 1 otherwise.
|
|
18
|
+
# No output.
|
|
19
|
+
#
|
|
20
|
+
# scope_should_enforce <toplevel>
|
|
21
|
+
# The verdict the hook wants: returns 0 (enforce the lock) for the primary
|
|
22
|
+
# checkout, 1 (exempt) for a worktree. The exact inverse of
|
|
23
|
+
# scope_is_worktree. No output.
|
|
24
|
+
|
|
25
|
+
# scope_is_worktree <toplevel>
|
|
26
|
+
scope_is_worktree() {
|
|
27
|
+
local _top="$1"
|
|
28
|
+
case "$_top" in
|
|
29
|
+
*/.red/tmp/worktrees/*/*) return 0 ;; # registered worktree lane root
|
|
30
|
+
*/.red/tmp/worktrees/*/*/*) return 0 ;; # nested below registered worktree lane root
|
|
31
|
+
*/.red/tmp/work-*) return 0 ;; # the worktree root itself
|
|
32
|
+
*/.red/tmp/work-*/*) return 0 ;; # nested below a worktree root
|
|
33
|
+
*) return 1 ;;
|
|
34
|
+
esac
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# scope_should_enforce <toplevel>
|
|
38
|
+
scope_should_enforce() {
|
|
39
|
+
if scope_is_worktree "$1"; then
|
|
40
|
+
return 1
|
|
41
|
+
fi
|
|
42
|
+
return 0
|
|
43
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Unit test for branch-lock.sh — the /branch-lock CLI (issue #63).
|
|
3
|
+
#
|
|
4
|
+
# Covers the atomic relock-then-switch contract of `set`:
|
|
5
|
+
# AC1: `set <new>` from a different branch updates the lock AND lands the
|
|
6
|
+
# working tree on <new> in one step.
|
|
7
|
+
# AC2: the CLI still lands the branch even though the untouchable-primary rule
|
|
8
|
+
# (ADR 0083) blocks every raw agent `git switch` — proven by showing
|
|
9
|
+
# (a) the PreToolUse hook BLOCKS a raw `git switch <new>`, yet (b)
|
|
10
|
+
# `set <new>` succeeds anyway, because the CLI performs the switch itself
|
|
11
|
+
# (a subprocess, not an agent Bash-tool call the hook can intercept).
|
|
12
|
+
# AC3: locking to the branch already checked out just rewrites the target
|
|
13
|
+
# (no switch needed, exit 0).
|
|
14
|
+
#
|
|
15
|
+
# End-to-end against throwaway git repos: branch-lock.sh runs main() on every
|
|
16
|
+
# invocation, so it cannot be sourced for pure helpers — drive it as a subprocess.
|
|
17
|
+
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
|
|
20
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
21
|
+
SCRIPTS="$(dirname "$HERE")"
|
|
22
|
+
CLI="$SCRIPTS/branch-lock.sh"
|
|
23
|
+
HOOK="$SCRIPTS/branch-lock-hook.sh"
|
|
24
|
+
|
|
25
|
+
pass=0
|
|
26
|
+
fail=0
|
|
27
|
+
|
|
28
|
+
expect_eq() {
|
|
29
|
+
local label="$1" expected="$2" actual="$3"
|
|
30
|
+
if [[ "$expected" == "$actual" ]]; then
|
|
31
|
+
echo "PASS $label"; pass=$((pass + 1))
|
|
32
|
+
else
|
|
33
|
+
printf 'FAIL %s\n expected: %q\n actual: %q\n' "$label" "$expected" "$actual"
|
|
34
|
+
fail=$((fail + 1))
|
|
35
|
+
fi
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
expect_contains() {
|
|
39
|
+
local label="$1" needle="$2" haystack="$3"
|
|
40
|
+
if [[ "$haystack" == *"$needle"* ]]; then
|
|
41
|
+
echo "PASS $label"; pass=$((pass + 1))
|
|
42
|
+
else
|
|
43
|
+
printf 'FAIL %s\n missing: %q\n in: %q\n' "$label" "$needle" "$haystack"
|
|
44
|
+
fail=$((fail + 1))
|
|
45
|
+
fi
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# A throwaway repo with two branches, left checked out on `main`. The dev plugin
|
|
49
|
+
# is opted in (ADR 0067 gate) so the PreToolUse hook is armed for run_hook.
|
|
50
|
+
mk_repo() {
|
|
51
|
+
local dir="$1"
|
|
52
|
+
git init -q -b main "$dir"
|
|
53
|
+
git -C "$dir" config user.email t@t.t
|
|
54
|
+
git -C "$dir" config user.name t
|
|
55
|
+
git -C "$dir" commit -q --allow-empty -m init
|
|
56
|
+
git -C "$dir" branch feature
|
|
57
|
+
mkdir -p "$dir/.red"
|
|
58
|
+
printf 'plugins:\n dev:\n enabled: true\n' > "$dir/.red/config.yaml"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
current_branch() { git -C "$1" rev-parse --abbrev-ref HEAD; }
|
|
62
|
+
locked_branch() { cat "$1/.red/tmp/branch-lock.yaml" 2>/dev/null | tr -d '\n'; }
|
|
63
|
+
|
|
64
|
+
# Run the CLI from inside the repo (git operates on cwd) with the root pinned.
|
|
65
|
+
run_cli() { local root="$1"; shift; ( cd "$root" && CLAUDE_PROJECT_DIR="$root" bash "$CLI" "$@" ); }
|
|
66
|
+
# Run the PreToolUse hook against a Bash command; echoes the exit code.
|
|
67
|
+
run_hook() {
|
|
68
|
+
local root="$1" cmd="$2"
|
|
69
|
+
( cd "$root" && CLAUDE_PROJECT_DIR="$root" bash "$HOOK" \
|
|
70
|
+
<<<"{\"tool_input\":{\"command\":\"$cmd\"}}" ) >/dev/null 2>&1
|
|
71
|
+
echo $?
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# ===========================================================================
|
|
75
|
+
# AC1 — set <new> from a different branch: lock moves AND tree lands on <new>.
|
|
76
|
+
# ===========================================================================
|
|
77
|
+
R1="$(mktemp -d)/red-skills"; mk_repo "$R1"
|
|
78
|
+
OUT1="$(run_cli "$R1" set feature)"; RC1=$?
|
|
79
|
+
expect_eq "AC1 set <new>: exit 0" "0" "$RC1"
|
|
80
|
+
expect_eq "AC1 set <new>: lock target rewritten" "feature" "$(locked_branch "$R1")"
|
|
81
|
+
expect_eq "AC1 set <new>: working tree on new branch" "feature" "$(current_branch "$R1")"
|
|
82
|
+
expect_contains "AC1 set <new>: reports the lock" "locked to 'feature'" "$OUT1"
|
|
83
|
+
|
|
84
|
+
# ===========================================================================
|
|
85
|
+
# AC2 — the intended switch is never blocked by the hook.
|
|
86
|
+
# Lock to main first; a raw `git switch feature` is then hook-blocked, but the
|
|
87
|
+
# CLI's `set feature` still succeeds (it relocks first, then switches).
|
|
88
|
+
# ===========================================================================
|
|
89
|
+
R2="$(mktemp -d)/red-skills"; mk_repo "$R2"
|
|
90
|
+
run_cli "$R2" set main >/dev/null
|
|
91
|
+
expect_eq "AC2 raw switch to <new> is hook-blocked while locked to old" \
|
|
92
|
+
"2" "$(run_hook "$R2" "git switch feature")"
|
|
93
|
+
OUT2="$(run_cli "$R2" set feature)"; RC2=$?
|
|
94
|
+
expect_eq "AC2 CLI set <new>: exit 0 despite the old lock" "0" "$RC2"
|
|
95
|
+
expect_eq "AC2 CLI set <new>: lock now on new branch" "feature" "$(locked_branch "$R2")"
|
|
96
|
+
expect_eq "AC2 CLI set <new>: tree landed on new branch" "feature" "$(current_branch "$R2")"
|
|
97
|
+
# Untouchable primary (ADR 0083): a raw agent switch to the new target is still
|
|
98
|
+
# refused — the primary is never movable by the agent, even back to the lock
|
|
99
|
+
# target. The CLI succeeds because it switches itself, outside the hook's reach.
|
|
100
|
+
expect_eq "AC2 post-relock: hook still blocks raw switch to the new target" \
|
|
101
|
+
"2" "$(run_hook "$R2" "git switch feature")"
|
|
102
|
+
|
|
103
|
+
# ===========================================================================
|
|
104
|
+
# AC3 — locking to the branch already checked out just rewrites the target.
|
|
105
|
+
# ===========================================================================
|
|
106
|
+
R3="$(mktemp -d)/red-skills"; mk_repo "$R3" # on main, unlocked
|
|
107
|
+
OUT3="$(run_cli "$R3" set main)"; RC3=$?
|
|
108
|
+
expect_eq "AC3 set <current>: exit 0" "0" "$RC3"
|
|
109
|
+
expect_eq "AC3 set <current>: lock target written" "main" "$(locked_branch "$R3")"
|
|
110
|
+
expect_eq "AC3 set <current>: still on the same branch" "main" "$(current_branch "$R3")"
|
|
111
|
+
|
|
112
|
+
echo
|
|
113
|
+
echo "summary: $pass passed, $fail failed"
|
|
114
|
+
[ "$fail" -eq 0 ]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Unit test for the Claude plugin PreToolUse(Bash) branch guard wiring.
|
|
3
|
+
|
|
4
|
+
set -uo pipefail
|
|
5
|
+
|
|
6
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
7
|
+
PLUGIN_ROOT="$(cd "$HERE/../../../../.." && pwd)"
|
|
8
|
+
MANIFEST="$PLUGIN_ROOT/hooks/claude.hooks.json"
|
|
9
|
+
|
|
10
|
+
pass=0
|
|
11
|
+
fail=0
|
|
12
|
+
|
|
13
|
+
ok() { echo "PASS $1"; pass=$((pass + 1)); }
|
|
14
|
+
bad() { echo "FAIL $1"; fail=$((fail + 1)); }
|
|
15
|
+
|
|
16
|
+
expect_eq() {
|
|
17
|
+
local label="$1" expected="$2" actual="$3"
|
|
18
|
+
if [[ "$expected" == "$actual" ]]; then
|
|
19
|
+
ok "$label"
|
|
20
|
+
else
|
|
21
|
+
bad "$label"
|
|
22
|
+
printf ' expected: %q\n actual: %q\n' "$expected" "$actual"
|
|
23
|
+
fi
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
expect_contains() {
|
|
27
|
+
local label="$1" needle="$2" haystack="$3"
|
|
28
|
+
if [[ "$haystack" == *"$needle"* ]]; then
|
|
29
|
+
ok "$label"
|
|
30
|
+
else
|
|
31
|
+
bad "$label"
|
|
32
|
+
printf ' missing: %q\n in: %q\n' "$needle" "$haystack"
|
|
33
|
+
fi
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
tmp="$(mktemp -d -t branch-lock-claude.XXXXXX)"
|
|
37
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
38
|
+
|
|
39
|
+
primary="$tmp/red-skills"
|
|
40
|
+
mkdir -p "$primary/.red"
|
|
41
|
+
# No lock file and no `dev.lock.primary-branch` toggle: the untouchable-primary
|
|
42
|
+
# rule (ADR 0083) must block branch movement anyway.
|
|
43
|
+
cat > "$primary/.red/config.yaml" <<'EOF'
|
|
44
|
+
plugins:
|
|
45
|
+
dev:
|
|
46
|
+
enabled: true
|
|
47
|
+
EOF
|
|
48
|
+
|
|
49
|
+
payload() {
|
|
50
|
+
local cmd="$1"
|
|
51
|
+
jq -nc --arg cmd "$cmd" '{hook_event_name:"PreToolUse", tool_input:{command:$cmd}}'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
manifest_matcher="$(jq -r '.hooks.PreToolUse[0].matcher' "$MANIFEST")"
|
|
55
|
+
manifest_hook="$(jq -r '.hooks.PreToolUse[0].hooks[0].command' "$MANIFEST")"
|
|
56
|
+
expect_eq "manifest: matcher is Bash" "Bash" "$manifest_matcher"
|
|
57
|
+
expect_contains "manifest: wires branch-lock-hook.sh" "branch-lock-hook.sh" "$manifest_hook"
|
|
58
|
+
expect_contains "manifest: wrapper drains stdin before hook" 'cat >"$tmp"' "$manifest_hook"
|
|
59
|
+
|
|
60
|
+
out="$tmp/out"
|
|
61
|
+
err="$tmp/err"
|
|
62
|
+
CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" CLAUDE_PROJECT_DIR="$primary" bash -lc "$manifest_hook" \
|
|
63
|
+
>"$out" 2>"$err" <<<"$(payload "git switch feature")"
|
|
64
|
+
rc=$?
|
|
65
|
+
expect_eq "primary guard: switch blocked with no lock and no config" "0" "$rc"
|
|
66
|
+
expect_contains "primary guard: error names ADR 0083" "ADR 0083" "$(<"$err")"
|
|
67
|
+
|
|
68
|
+
# issue #1024 — git reset (any form) and git stash (all subcommands) blocked,
|
|
69
|
+
# and the refusal explains the reason + the sanctioned worktree alternative.
|
|
70
|
+
CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" CLAUDE_PROJECT_DIR="$primary" bash -lc "$manifest_hook" \
|
|
71
|
+
>"$out" 2>"$err" <<<"$(payload "git reset --hard")"
|
|
72
|
+
rc=$?
|
|
73
|
+
expect_eq "primary guard: reset --hard is blocked when flag is on" "0" "$rc"
|
|
74
|
+
expect_contains "primary guard: reset refusal points to worktree" ".red/tmp/work-" "$(<"$err")"
|
|
75
|
+
|
|
76
|
+
CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" CLAUDE_PROJECT_DIR="$primary" bash -lc "$manifest_hook" \
|
|
77
|
+
>"$out" 2>"$err" <<<"$(payload "git stash")"
|
|
78
|
+
rc=$?
|
|
79
|
+
expect_eq "primary guard: stash is blocked when flag is on" "0" "$rc"
|
|
80
|
+
|
|
81
|
+
CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" CLAUDE_PROJECT_DIR="$primary" bash -lc "$manifest_hook" \
|
|
82
|
+
>"$out" 2>"$err" <<<"$(payload "git rebase --autostash main")"
|
|
83
|
+
rc=$?
|
|
84
|
+
expect_eq "primary guard: rebase --autostash is blocked when flag is on" "0" "$rc"
|
|
85
|
+
|
|
86
|
+
CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" CLAUDE_PROJECT_DIR="$primary" bash -lc "$manifest_hook" \
|
|
87
|
+
>"$out" 2>"$err" <<<"$(payload "git commit -m wip")"
|
|
88
|
+
rc=$?
|
|
89
|
+
expect_eq "primary guard: commit is allowed" "0" "$rc"
|
|
90
|
+
|
|
91
|
+
# The legacy toggle stays readable but can no longer *enable* switching: with it
|
|
92
|
+
# explicitly false the guard still blocks, because primary movement is
|
|
93
|
+
# unconditional (ADR 0083 §2).
|
|
94
|
+
cat > "$primary/.red/config.yaml" <<'EOF'
|
|
95
|
+
plugins:
|
|
96
|
+
dev:
|
|
97
|
+
enabled: true
|
|
98
|
+
lock:
|
|
99
|
+
primary-branch: false
|
|
100
|
+
EOF
|
|
101
|
+
CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" CLAUDE_PROJECT_DIR="$primary" bash -lc "$manifest_hook" \
|
|
102
|
+
>"$out" 2>"$err" <<<"$(payload "git switch feature")"
|
|
103
|
+
rc=$?
|
|
104
|
+
expect_eq "primary guard: legacy toggle off still blocks switch" "0" "$rc"
|
|
105
|
+
expect_contains "primary guard: legacy-off error names ADR 0083" "ADR 0083" "$(<"$err")"
|
|
106
|
+
|
|
107
|
+
missing_root="$tmp/missing-plugin-root"
|
|
108
|
+
mkdir -p "$missing_root"
|
|
109
|
+
CLAUDE_PLUGIN_ROOT="$missing_root" CLAUDE_PROJECT_DIR="$primary" bash -lc "$manifest_hook" \
|
|
110
|
+
>"$out" 2>"$err" <<<"$(payload "git switch feature")"
|
|
111
|
+
rc=$?
|
|
112
|
+
expect_eq "manifest: missing hook fails open" "0" "$rc"
|
|
113
|
+
expect_eq "manifest: missing hook prints empty JSON" "{}" "$(<"$out")"
|
|
114
|
+
|
|
115
|
+
echo
|
|
116
|
+
echo "summary: $pass passed, $fail failed"
|
|
117
|
+
[[ "$fail" -eq 0 ]]
|