@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.
Files changed (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +30 -0
  3. package/package.json +32 -0
  4. package/skills/engineering/README.md +36 -0
  5. package/skills/engineering/_report-runtime/WRAPPER.md +47 -0
  6. package/skills/engineering/afk/AGENT-PROMPT.md +295 -0
  7. package/skills/engineering/afk/SAFETY.md +103 -0
  8. package/skills/engineering/afk/SKILL.md +160 -0
  9. package/skills/engineering/afk/TROUBLESHOOTING.md +260 -0
  10. package/skills/engineering/afk/actions-lane.md +213 -0
  11. package/skills/engineering/afk/bin/README.md +29 -0
  12. package/skills/engineering/afk/bin/afk.mjs +51 -0
  13. package/skills/engineering/afk/defaults/cargo-pre-worktree.sh +37 -0
  14. package/skills/engineering/afk/defaults/envelope-post-attempt.sh +47 -0
  15. package/skills/engineering/afk/defaults/gradle-pre-worktree.sh +42 -0
  16. package/skills/engineering/afk/defaults/heartbeat-post-attempt.sh +43 -0
  17. package/skills/engineering/afk/defaults/validation-post-merge.sh +77 -0
  18. package/skills/engineering/afk/detectors/README.md +72 -0
  19. package/skills/engineering/afk/detectors/cargo.sh +27 -0
  20. package/skills/engineering/afk/detectors/gradle.sh +35 -0
  21. package/skills/engineering/afk/docs/BOOT-SWEEPS.md +81 -0
  22. package/skills/engineering/afk/docs/CONFIG.md +238 -0
  23. package/skills/engineering/afk/docs/ENVELOPE.md +177 -0
  24. package/skills/engineering/afk/docs/HANDOFF.md +70 -0
  25. package/skills/engineering/afk/docs/LIVENESS.md +56 -0
  26. package/skills/engineering/afk/docs/OPERATIONS.md +409 -0
  27. package/skills/engineering/afk/examples/only-mine.sh +32 -0
  28. package/skills/engineering/afk/examples/red-afk-attempt-action.yml +53 -0
  29. package/skills/engineering/afk/examples/rs-afk-attempt.yml +76 -0
  30. package/skills/engineering/afk/fleet.md +127 -0
  31. package/skills/engineering/afk/hooks/red-cargo +44 -0
  32. package/skills/engineering/afk/hooks/red-envelope +50 -0
  33. package/skills/engineering/afk/hooks/red-gradle +50 -0
  34. package/skills/engineering/afk/hooks/red-heartbeat +46 -0
  35. package/skills/engineering/afk/hooks/red-validation +79 -0
  36. package/skills/engineering/afk/monitor.md +170 -0
  37. package/skills/engineering/afk/runner-claude-minimax.md +71 -0
  38. package/skills/engineering/afk/runner-claude.md +67 -0
  39. package/skills/engineering/afk/runner-codex.md +138 -0
  40. package/skills/engineering/afk/runner-hermes.md +40 -0
  41. package/skills/engineering/afk/runner-opencode.md +142 -0
  42. package/skills/engineering/ask-red/SKILL.md +137 -0
  43. package/skills/engineering/audit-skills/SKILL.md +46 -0
  44. package/skills/engineering/code-review/SKILL.md +106 -0
  45. package/skills/engineering/context/SKILL.md +142 -0
  46. package/skills/engineering/curate/SKILL.md +133 -0
  47. package/skills/engineering/daily-review/SKILL.md +68 -0
  48. package/skills/engineering/dashboard/SKILL.md +44 -0
  49. package/skills/engineering/diagnose/SKILL.md +155 -0
  50. package/skills/engineering/diagnose/scripts/hitl-loop.template.sh +41 -0
  51. package/skills/engineering/go/SKILL.md +144 -0
  52. package/skills/engineering/go/TROUBLESHOOTING.md +81 -0
  53. package/skills/engineering/ground-truth/SKILL.md +84 -0
  54. package/skills/engineering/hitl/SKILL.md +171 -0
  55. package/skills/engineering/hitl/TROUBLESHOOTING.md +76 -0
  56. package/skills/engineering/implement/SKILL.md +69 -0
  57. package/skills/engineering/improve-codebase-architecture/DEEPENING.md +37 -0
  58. package/skills/engineering/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
  59. package/skills/engineering/improve-codebase-architecture/LANGUAGE.md +53 -0
  60. package/skills/engineering/improve-codebase-architecture/SKILL.md +81 -0
  61. package/skills/engineering/model-tier-policy/SKILL.md +83 -0
  62. package/skills/engineering/prototype/LOGIC.md +79 -0
  63. package/skills/engineering/prototype/SKILL.md +30 -0
  64. package/skills/engineering/prototype/UI.md +112 -0
  65. package/skills/engineering/red-doctor/APPLY.md +35 -0
  66. package/skills/engineering/red-doctor/SKILL.md +172 -0
  67. package/skills/engineering/red-gains/SKILL.md +49 -0
  68. package/skills/engineering/red-setup/INTERVIEW.md +266 -0
  69. package/skills/engineering/red-setup/ISSUE-SWEEP.md +18 -0
  70. package/skills/engineering/red-setup/REFERENCE.md +35 -0
  71. package/skills/engineering/red-setup/SKILL.md +58 -0
  72. package/skills/engineering/red-setup/WORKFLOWS.md +74 -0
  73. package/skills/engineering/red-setup/WRITE-CONTRACT.md +151 -0
  74. package/skills/engineering/red-setup/config-template.yaml +174 -0
  75. package/skills/engineering/red-setup/domain.md +52 -0
  76. package/skills/engineering/red-setup/issue-tracker-github.md +88 -0
  77. package/skills/engineering/red-setup/rsp-retirement-runbook.md +35 -0
  78. package/skills/engineering/red-setup/scripts/install-runtime-shim.sh +189 -0
  79. package/skills/engineering/red-setup/triage-labels.md +176 -0
  80. package/skills/engineering/red-setup/workflows/red-issues-needs-triage.yml +36 -0
  81. package/skills/engineering/red-statusline/HOST-NOTES.md +180 -0
  82. package/skills/engineering/red-statusline/SKILL.md +58 -0
  83. package/skills/engineering/report-bug/SKILL.md +155 -0
  84. package/skills/engineering/resolving-merge-conflicts/SKILL.md +143 -0
  85. package/skills/engineering/retake/SKILL.md +184 -0
  86. package/skills/engineering/review-adrs/SKILL.md +125 -0
  87. package/skills/engineering/start/ADR-FORMAT.md +47 -0
  88. package/skills/engineering/start/CONTEXT-FORMAT.md +77 -0
  89. package/skills/engineering/start/DOC-LANDING-FINALIZER.md +9 -0
  90. package/skills/engineering/start/SKILL.md +179 -0
  91. package/skills/engineering/tdd/SKILL.md +96 -0
  92. package/skills/engineering/tdd/deep-modules.md +33 -0
  93. package/skills/engineering/tdd/interface-design.md +31 -0
  94. package/skills/engineering/tdd/mocking.md +59 -0
  95. package/skills/engineering/tdd/tests.md +85 -0
  96. package/skills/engineering/to-spec/SKILL.md +104 -0
  97. package/skills/engineering/to-tickets/SKILL.md +158 -0
  98. package/skills/engineering/triage/AGENT-BRIEF.md +207 -0
  99. package/skills/engineering/triage/OUT-OF-SCOPE.md +101 -0
  100. package/skills/engineering/triage/SKILL.md +176 -0
  101. package/skills/engineering/verify/SKILL.md +136 -0
  102. package/skills/engineering/wayfinder/SKILL.md +128 -0
  103. package/skills/engineering/zoom-out/SKILL.md +51 -0
  104. package/skills/engineering/zoom-out/scripts/graph-mode-plumbing.sh +36 -0
  105. package/skills/engineering/zoom-out/scripts/tests/contract.test.sh +168 -0
  106. package/skills/knowledge/README.md +5 -0
  107. package/skills/knowledge/research/SKILL.md +76 -0
  108. package/skills/misc/README.md +8 -0
  109. package/skills/misc/branch-lock/SKILL.md +187 -0
  110. package/skills/misc/branch-lock/scripts/branch-lock-hook.sh +113 -0
  111. package/skills/misc/branch-lock/scripts/branch-lock-session-start.sh +81 -0
  112. package/skills/misc/branch-lock/scripts/branch-lock.sh +68 -0
  113. package/skills/misc/branch-lock/scripts/lib/dev-config.sh +126 -0
  114. package/skills/misc/branch-lock/scripts/lib/git-command-classifier.sh +198 -0
  115. package/skills/misc/branch-lock/scripts/lib/lock-store.sh +67 -0
  116. package/skills/misc/branch-lock/scripts/lib/scope-resolver.sh +43 -0
  117. package/skills/misc/branch-lock/scripts/tests/branch-lock-cli.test.sh +114 -0
  118. package/skills/misc/branch-lock/scripts/tests/claude-plugin-hook.test.sh +117 -0
  119. package/skills/misc/branch-lock/scripts/tests/codex-hook.test.sh +229 -0
  120. package/skills/misc/branch-lock/scripts/tests/dev-config.test.sh +99 -0
  121. package/skills/misc/branch-lock/scripts/tests/git-command-classifier.test.sh +187 -0
  122. package/skills/misc/branch-lock/scripts/tests/lock-store.test.sh +90 -0
  123. package/skills/misc/branch-lock/scripts/tests/scope-resolver.test.sh +63 -0
  124. package/skills/misc/branch-lock/scripts/tests/session-start.test.sh +123 -0
  125. package/skills/misc/git-guardrails-claude-code/SKILL.md +105 -0
  126. package/skills/misc/git-guardrails-claude-code/scripts/block-dangerous-git.sh +231 -0
  127. package/skills/misc/git-guardrails-claude-code/scripts/tests/block-dangerous-git.test.sh +143 -0
  128. package/skills/misc/migrate-to-shoehorn/SKILL.md +96 -0
  129. package/skills/misc/setup-pre-commit/SKILL.md +94 -0
  130. package/skills/productivity/README.md +8 -0
  131. package/skills/productivity/ff/SKILL.md +116 -0
  132. package/skills/productivity/handoff/SKILL.md +37 -0
  133. package/skills/productivity/reflect/SKILL.md +18 -0
  134. package/skills/productivity/write-a-skill/SKILL.md +190 -0
  135. 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 ]]