@reddb-io/red-skills 4.4.1 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/bin/build-info.mjs +87 -0
  2. package/bin/cli-frontdoor.mjs +14 -0
  3. package/bin/red-skills-brain.mjs +17 -22
  4. package/bin/red-skills-content.mjs +17 -0
  5. package/bin/red-skills-hook.mjs +45 -0
  6. package/bin/red-skills-memory.mjs +17 -22
  7. package/bin/red-skills-redskilled-mcp.mjs +2 -0
  8. package/bin/red-skills-resource.mjs +21 -0
  9. package/bin/runtime-compatibility.mjs +17 -0
  10. package/bin/runtime-paths.mjs +13 -0
  11. package/dist/code-nav.bundle.min.mjs +1 -1
  12. package/dist/opencode-host.bundle.min.mjs +1 -1
  13. package/dist/redskilled-mcp.bundle.min.mjs +36 -36
  14. package/dist/redskilled.bundle.min.mjs +1 -1
  15. package/dist/release.bundle.min.mjs +1 -1
  16. package/dist/rsp-core.bundle.min.mjs +1 -1
  17. package/dist/rsp.bundle.min.mjs +1 -1
  18. package/dist/statusline.bundle.min.mjs +1 -1
  19. package/package.json +9 -4
  20. package/runtime/hook-routes.json +125 -0
  21. package/runtime/plugins/brain/scripts/bootstrap.mjs +656 -0
  22. package/runtime/plugins/brain/scripts/structural-smoke.sh +7 -0
  23. package/runtime/plugins/dev/hooks/branch-lock-codex.sh +105 -0
  24. package/runtime/plugins/dev/hooks/code-nav-mcp.sh +63 -0
  25. package/runtime/plugins/dev/hooks/command-guard.sh +893 -0
  26. package/runtime/plugins/dev/hooks/ensure-codex-statusline.mjs +144 -0
  27. package/runtime/plugins/dev/hooks/red-fetch.mjs +38 -0
  28. package/runtime/plugins/dev/hooks/redskilled-mcp.sh +64 -0
  29. package/runtime/plugins/dev/hooks/rsp-hook.sh +343 -0
  30. package/runtime/plugins/dev/hooks/tests/command-guard.test.sh +378 -0
  31. package/runtime/plugins/dev/hooks/tests/rsp-hook.test.sh +229 -0
  32. package/runtime/plugins/dev/scripts/memory-bridge.sh +230 -0
  33. package/runtime/plugins/dev/scripts/structural-smoke.sh +7 -0
  34. package/runtime/plugins/dev/scripts/tests/memory-bridge.test.sh +555 -0
  35. package/runtime/plugins/dev/skills/engineering/afk/defaults/cargo-pre-worktree.sh +37 -0
  36. package/runtime/plugins/dev/skills/engineering/afk/defaults/envelope-post-attempt.sh +47 -0
  37. package/runtime/plugins/dev/skills/engineering/afk/defaults/gradle-pre-worktree.sh +45 -0
  38. package/runtime/plugins/dev/skills/engineering/afk/defaults/heartbeat-post-attempt.sh +43 -0
  39. package/runtime/plugins/dev/skills/engineering/afk/defaults/validation-post-merge.sh +77 -0
  40. package/runtime/plugins/dev/skills/engineering/afk/detectors/cargo.sh +27 -0
  41. package/runtime/plugins/dev/skills/engineering/afk/detectors/gradle.sh +38 -0
  42. package/runtime/plugins/dev/skills/engineering/afk/examples/only-mine.sh +32 -0
  43. package/runtime/plugins/dev/skills/engineering/afk/examples/red-afk-attempt-action.yml +53 -0
  44. package/runtime/plugins/dev/skills/engineering/afk/examples/rs-afk-attempt.yml +76 -0
  45. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-cargo +38 -0
  46. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-envelope +50 -0
  47. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-gradle +42 -0
  48. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-heartbeat +46 -0
  49. package/runtime/plugins/dev/skills/engineering/afk/hooks/red-validation +79 -0
  50. package/runtime/plugins/dev/skills/engineering/diagnose/scripts/hitl-loop.template.sh +41 -0
  51. package/runtime/plugins/dev/skills/engineering/red-setup/scripts/install-runtime-shim.sh +181 -0
  52. package/runtime/plugins/dev/skills/engineering/red-setup/workflows/red-issues-needs-triage.yml +36 -0
  53. package/runtime/plugins/dev/skills/engineering/wizard/template.sh +211 -0
  54. package/runtime/plugins/dev/skills/engineering/zoom-out/scripts/graph-mode-plumbing.sh +36 -0
  55. package/runtime/plugins/dev/skills/engineering/zoom-out/scripts/tests/contract.test.sh +168 -0
  56. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/branch-lock-hook.sh +113 -0
  57. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/branch-lock-session-start.sh +81 -0
  58. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/branch-lock.sh +68 -0
  59. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/dev-config.sh +126 -0
  60. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/git-command-classifier.sh +198 -0
  61. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/lock-store.sh +67 -0
  62. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/lib/scope-resolver.sh +43 -0
  63. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/branch-lock-cli.test.sh +114 -0
  64. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/claude-plugin-hook.test.sh +117 -0
  65. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/codex-hook.test.sh +230 -0
  66. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/dev-config.test.sh +99 -0
  67. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/git-command-classifier.test.sh +187 -0
  68. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/lock-store.test.sh +90 -0
  69. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/scope-resolver.test.sh +63 -0
  70. package/runtime/plugins/dev/skills/misc/branch-lock/scripts/tests/session-start.test.sh +123 -0
  71. package/runtime/plugins/dev/skills/misc/git-guardrails-claude-code/scripts/block-dangerous-git.sh +231 -0
  72. package/runtime/plugins/dev/skills/misc/git-guardrails-claude-code/scripts/tests/block-dangerous-git.test.sh +143 -0
  73. package/runtime/plugins/internal/scripts/structural-smoke.sh +7 -0
  74. package/runtime/plugins/internal/skills/maintainer/create-plugin/scripts/create-plugin.sh +308 -0
  75. package/runtime/plugins/memory/scripts/bootstrap.mjs +659 -0
  76. package/runtime/plugins/memory/scripts/structural-smoke.sh +7 -0
  77. package/scripts/check-skills-boundary.mjs +32 -0
@@ -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 ]]
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env bash
2
+ # Unit test for the Codex plugin PreToolUse branch-lock hook.
3
+
4
+ set -uo pipefail
5
+
6
+ HERE="$(cd "$(dirname "$0")" && pwd)"
7
+ PLUGIN_ROOT="$(cd "$HERE/../../../../.." && pwd)"
8
+ HOOK="$PLUGIN_ROOT/hooks/branch-lock-codex.sh"
9
+ MANIFEST="$PLUGIN_ROOT/hooks/codex.hooks.json"
10
+
11
+ pass=0
12
+ fail=0
13
+
14
+ ok() { echo "PASS $1"; pass=$((pass + 1)); }
15
+ bad() { echo "FAIL $1"; fail=$((fail + 1)); }
16
+
17
+ expect_eq() {
18
+ local label="$1" expected="$2" actual="$3"
19
+ if [[ "$expected" == "$actual" ]]; then
20
+ ok "$label"
21
+ else
22
+ bad "$label"
23
+ printf ' expected: %q\n actual: %q\n' "$expected" "$actual"
24
+ fi
25
+ }
26
+
27
+ expect_contains() {
28
+ local label="$1" needle="$2" haystack="$3"
29
+ if [[ "$haystack" == *"$needle"* ]]; then
30
+ ok "$label"
31
+ else
32
+ bad "$label"
33
+ printf ' missing: %q\n in: %q\n' "$needle" "$haystack"
34
+ fi
35
+ }
36
+
37
+ tmp="$(mktemp -d -t branch-lock-codex.XXXXXX)"
38
+ trap 'rm -rf "$tmp"' EXIT
39
+
40
+ primary="$tmp/red-skills"
41
+ mkdir -p "$primary/.red/tmp"
42
+ cat > "$primary/.red/config.yaml" <<'EOF'
43
+ plugins:
44
+ dev:
45
+ enabled: true
46
+ EOF
47
+ printf 'main\n' > "$primary/.red/tmp/branch-lock.yaml"
48
+
49
+ run_hook() {
50
+ local root="$1" payload="$2" out err rc
51
+ out="$tmp/out"
52
+ err="$tmp/err"
53
+ CODEX_PLUGIN_ROOT="$PLUGIN_ROOT" "$HOOK" >"$out" 2>"$err" <<<"$payload"
54
+ rc=$?
55
+ printf '%s\n---stdout---\n%s\n---stderr---\n%s\n' "$rc" "$(<"$out")" "$(<"$err")"
56
+ }
57
+
58
+ payload() {
59
+ local root="$1" cmd="$2"
60
+ jq -nc --arg cwd "$root" --arg cmd "$cmd" \
61
+ '{hook_event_name:"PreToolUse", cwd:$cwd, tool_input:{cmd:$cmd}}'
62
+ }
63
+
64
+ manifest_hook="$(jq -r '.hooks.PreToolUse[0].hooks[] | select(.command | contains("branch-lock-codex.sh")) | .command' "$MANIFEST")"
65
+ expect_contains "manifest: wires branch-lock-codex.sh" "branch-lock-codex.sh" "$manifest_hook"
66
+ expect_contains "manifest: wrapper drains stdin before hook" 'cat >"$tmp"' "$manifest_hook"
67
+ expect_contains "manifest: invokes bash-dependent hook explicitly" 'bash "$hook"' "$manifest_hook"
68
+
69
+ out="$tmp/manifest-out"
70
+ err="$tmp/manifest-err"
71
+ CODEX_PLUGIN_ROOT="$PLUGIN_ROOT" bash -lc "$manifest_hook" >"$out" 2>"$err" \
72
+ <<<"$(payload "$primary" "git status")"
73
+ rc=$?
74
+ expect_eq "manifest: command executes through shell" "0" "$rc"
75
+ expect_eq "manifest: command prints empty JSON" "{}" "$(<"$out")"
76
+
77
+ missing_root="$tmp/missing-plugin-root"
78
+ mkdir -p "$missing_root"
79
+ CODEX_PLUGIN_ROOT="$missing_root" bash -lc "$manifest_hook" >"$out" 2>"$err" \
80
+ <<<"$(payload "$primary" "git switch main")"
81
+ rc=$?
82
+ expect_eq "manifest: missing hook fails open" "0" "$rc"
83
+ expect_eq "manifest: missing hook prints empty JSON" "{}" "$(<"$out")"
84
+
85
+ early_root="$tmp/early-plugin-root"
86
+ mkdir -p "$early_root/hooks"
87
+ cat > "$early_root/hooks/branch-lock-codex.sh" <<'EOF'
88
+ #!/usr/bin/env sh
89
+ printf '{}'
90
+ exit 0
91
+ EOF
92
+ chmod +x "$early_root/hooks/branch-lock-codex.sh"
93
+ set -o pipefail
94
+ (head -c 1048576 /dev/zero | tr '\0' x) \
95
+ | CODEX_PLUGIN_ROOT="$early_root" bash -lc "$manifest_hook" >"$out" 2>"$err"
96
+ rc=$?
97
+ set +o pipefail
98
+ expect_eq "manifest: early hook still drains Codex stdin" "0" "$rc"
99
+ expect_eq "manifest: early hook prints empty JSON" "{}" "$(<"$out")"
100
+
101
+ CODEX_PLUGIN_ROOT="$missing_root" "$HOOK" >"$out" 2>"$err" \
102
+ <<<"$(payload "$primary" "git switch main")"
103
+ rc=$?
104
+ expect_eq "hook: incomplete plugin root fails open" "0" "$rc"
105
+ expect_eq "hook: incomplete plugin root prints empty JSON" "{}" "$(<"$out")"
106
+
107
+ # Branch movement in the primary is now blocked unconditionally by the
108
+ # untouchable-primary rule (ADR 0083) — it fires before the branch-lock path, so
109
+ # switching away, and even switching *back* to the locked branch, is refused.
110
+ result="$(run_hook "$primary" "$(payload "$primary" "git switch feature")")"
111
+ rc="$(sed -n '1p' <<<"$result")"
112
+ stderr="$(sed -n '/---stderr---/,$p' <<<"$result")"
113
+ expect_eq "locked: switch away is blocked" "0" "$rc"
114
+ expect_contains "locked: error names ADR 0083" "ADR 0083" "$stderr"
115
+
116
+ result="$(run_hook "$primary" "$(payload "$primary" "git switch main")")"
117
+ rc="$(sed -n '1p' <<<"$result")"
118
+ stderr="$(sed -n '/---stderr---/,$p' <<<"$result")"
119
+ expect_eq "locked: switch back is also blocked (untouchable primary)" "0" "$rc"
120
+ expect_contains "locked: switch-back error names ADR 0083" "ADR 0083" "$stderr"
121
+
122
+ # Work-loss commands are caught by the unconditional untouchable-primary guard
123
+ # (#1024 reset/stash family + #1025 unconditional structure) even while a lock
124
+ # is active — the guard runs before the lock read, so the refusal names
125
+ # ADR 0083, not the branch lock.
126
+ result="$(run_hook "$primary" "$(payload "$primary" "git reset --hard HEAD~1")")"
127
+ rc="$(sed -n '1p' <<<"$result")"
128
+ stderr="$(sed -n '/---stderr---/,$p' <<<"$result")"
129
+ expect_eq "locked: work-loss reset --hard is blocked" "0" "$rc"
130
+ expect_contains "locked: work-loss error names untouchable primary" "ADR 0083" "$stderr"
131
+
132
+ rm -f "$primary/.red/tmp/branch-lock.yaml"
133
+ result="$(run_hook "$primary" "$(payload "$primary" "git switch feature")")"
134
+ rc="$(sed -n '1p' <<<"$result")"
135
+ stderr="$(sed -n '/---stderr---/,$p' <<<"$result")"
136
+ expect_eq "unlocked: switch is still blocked (untouchable primary)" "0" "$rc"
137
+ expect_contains "unlocked: error names ADR 0083" "ADR 0083" "$stderr"
138
+
139
+ # With no lock file the untouchable-primary guard still blocks every branch
140
+ # move, regardless of the legacy `dev.lock.primary-branch` toggle value.
141
+ mkdir -p "$primary/.red"
142
+ cat > "$primary/.red/config.yaml" <<'EOF'
143
+ plugins:
144
+ dev:
145
+ enabled: true
146
+ EOF
147
+
148
+ result="$(run_hook "$primary" "$(payload "$primary" "git switch feature")")"
149
+ rc="$(sed -n '1p' <<<"$result")"
150
+ stderr="$(sed -n '/---stderr---/,$p' <<<"$result")"
151
+ expect_eq "primary guard: switch blocked with no lock and no config" "0" "$rc"
152
+ expect_contains "primary guard: error names ADR 0083" "ADR 0083" "$stderr"
153
+
154
+ result="$(run_hook "$primary" "$(payload "$primary" "git checkout feature")")"
155
+ rc="$(sed -n '1p' <<<"$result")"
156
+ expect_eq "primary guard: checkout branch is blocked" "0" "$rc"
157
+
158
+ # issue #1024 — git reset (any form) and git stash (all subcommands) blocked,
159
+ # and the refusal explains the reason + the sanctioned worktree alternative.
160
+ result="$(run_hook "$primary" "$(payload "$primary" "git reset --hard")")"
161
+ rc="$(sed -n '1p' <<<"$result")"
162
+ stderr="$(sed -n '/---stderr---/,$p' <<<"$result")"
163
+ expect_eq "primary guard: reset --hard is blocked when flag is on" "0" "$rc"
164
+ expect_contains "primary guard: reset refusal points to worktree" ".red/tmp/work-" "$stderr"
165
+
166
+ result="$(run_hook "$primary" "$(payload "$primary" "git reset --soft HEAD~1")")"
167
+ rc="$(sed -n '1p' <<<"$result")"
168
+ expect_eq "primary guard: reset --soft is blocked when flag is on" "0" "$rc"
169
+
170
+ result="$(run_hook "$primary" "$(payload "$primary" "git stash")")"
171
+ rc="$(sed -n '1p' <<<"$result")"
172
+ expect_eq "primary guard: stash is blocked when flag is on" "0" "$rc"
173
+
174
+ result="$(run_hook "$primary" "$(payload "$primary" "git rebase --autostash main")")"
175
+ rc="$(sed -n '1p' <<<"$result")"
176
+ expect_eq "primary guard: rebase --autostash is blocked when flag is on" "0" "$rc"
177
+
178
+ result="$(run_hook "$primary" "$(payload "$primary" "git switch -b new")")"
179
+ rc="$(sed -n '1p' <<<"$result")"
180
+ expect_eq "primary guard: switch -b is blocked" "0" "$rc"
181
+
182
+ result="$(run_hook "$primary" "$(payload "$primary" "git commit -m wip")")"
183
+ rc="$(sed -n '1p' <<<"$result")"
184
+ expect_eq "primary guard: commit is allowed" "0" "$rc"
185
+
186
+ result="$(run_hook "$primary" "$(payload "$primary" "git worktree add ../wt feature")")"
187
+ rc="$(sed -n '1p' <<<"$result")"
188
+ expect_eq "primary guard: worktree add is allowed" "0" "$rc"
189
+
190
+ result="$(run_hook "$primary" "$(payload "$primary" "git status")")"
191
+ rc="$(sed -n '1p' <<<"$result")"
192
+ expect_eq "primary guard: read-only git is allowed" "0" "$rc"
193
+
194
+ # The legacy toggle is still readable but can no longer *enable* switching:
195
+ # explicitly false must not re-open primary branch movement (ADR 0083 §2).
196
+ cat > "$primary/.red/config.yaml" <<'EOF'
197
+ plugins:
198
+ dev:
199
+ enabled: true
200
+ lock:
201
+ primary-branch: false
202
+ EOF
203
+ result="$(run_hook "$primary" "$(payload "$primary" "git switch feature")")"
204
+ rc="$(sed -n '1p' <<<"$result")"
205
+ expect_eq "primary guard: legacy toggle off still blocks switch" "0" "$rc"
206
+
207
+ worktree="$primary/.red/tmp/work-wAAAA-i1/worktree"
208
+ mkdir -p "$worktree" "$primary/.red/tmp"
209
+ printf 'main\n' > "$primary/.red/tmp/branch-lock.yaml"
210
+ result="$(run_hook "$worktree" "$(payload "$worktree" "git switch feature")")"
211
+ rc="$(sed -n '1p' <<<"$result")"
212
+ expect_eq "worktree: branch lock is exempt" "0" "$rc"
213
+
214
+ mkdir -p "$worktree/.red"
215
+ cat > "$worktree/.red/config.yaml" <<'EOF'
216
+ dev:
217
+ lock:
218
+ primary-branch: true
219
+ EOF
220
+ result="$(run_hook "$worktree" "$(payload "$worktree" "git switch feature")")"
221
+ rc="$(sed -n '1p' <<<"$result")"
222
+ expect_eq "worktree: primary guard is exempt" "0" "$rc"
223
+
224
+ result="$(run_hook "$primary" "$(jq -nc --arg cwd "$primary" '{hook_event_name:"PreToolUse", cwd:$cwd, tool_input:{file:"x"}}')")"
225
+ rc="$(sed -n '1p' <<<"$result")"
226
+ expect_eq "non-shell payload: no-op allowed" "0" "$rc"
227
+
228
+ echo
229
+ echo "summary: $pass passed, $fail failed"
230
+ [[ "$fail" -eq 0 ]]
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ # Unit test for lib/dev-config.sh — runtime reader for dev.lock.primary-branch.
3
+
4
+ set -uo pipefail
5
+
6
+ HERE="$(cd "$(dirname "$0")" && pwd)"
7
+ LIB="$(dirname "$HERE")/lib/dev-config.sh"
8
+
9
+ # shellcheck source=../lib/dev-config.sh
10
+ source "$LIB"
11
+
12
+ pass=0
13
+ fail=0
14
+
15
+ expect_rc() {
16
+ local label="$1" expected="$2" file="$3"
17
+ dev_config_lock_primary_branch_enabled "$file"
18
+ local actual=$?
19
+ if [[ "$actual" == "$expected" ]]; then
20
+ echo "PASS $label"; pass=$((pass + 1))
21
+ else
22
+ printf 'FAIL %s\n expected rc: %q\n actual rc: %q\n' "$label" "$expected" "$actual"
23
+ fail=$((fail + 1))
24
+ fi
25
+ }
26
+
27
+ tmp="$(mktemp -d)"
28
+ trap 'rm -rf "$tmp"' EXIT
29
+
30
+ missing="$tmp/missing.yaml"
31
+ expect_rc "missing file: disabled" 1 "$missing"
32
+
33
+ cfg="$tmp/config.yaml"
34
+
35
+ cat > "$cfg" <<'EOF'
36
+ dev:
37
+ lock:
38
+ primary-branch: true
39
+ EOF
40
+ expect_rc "nested dev flag true: enabled" 0 "$cfg"
41
+
42
+ cat > "$cfg" <<'EOF'
43
+ dev:
44
+ lock:
45
+ primary-branch: false
46
+ EOF
47
+ expect_rc "nested dev flag false: disabled" 1 "$cfg"
48
+
49
+ cat > "$cfg" <<'EOF'
50
+ dev:
51
+ lock:
52
+ primary-branch: "true"
53
+ EOF
54
+ expect_rc "quoted true: enabled" 0 "$cfg"
55
+
56
+ cat > "$cfg" <<'EOF'
57
+ afk:
58
+ default_runner: codex
59
+ EOF
60
+ expect_rc "absent key: disabled" 1 "$cfg"
61
+
62
+ cat > "$cfg" <<'EOF'
63
+ dev:
64
+ lock:
65
+ primary-branch: true
66
+ EOF
67
+ expect_rc "malformed indentation: disabled" 1 "$cfg"
68
+
69
+ cat > "$cfg" <<'EOF'
70
+ dev:
71
+ lock:
72
+ primary-branch: "true" # branch protection on
73
+ EOF
74
+ expect_rc "inline comment after quoted true: enabled" 0 "$cfg"
75
+
76
+ cat > "$cfg" <<'EOF'
77
+ dev:
78
+ lock:
79
+ primary-branch: true
80
+ afk:
81
+ backpressure:
82
+ - npm run test
83
+ - npm run lint
84
+ EOF
85
+ expect_rc "block-sequence sibling does not disable guard: enabled" 0 "$cfg"
86
+
87
+ cat > "$cfg" <<'EOF'
88
+ afk:
89
+ backpressure:
90
+ - npm run test
91
+ dev:
92
+ lock:
93
+ primary-branch: true
94
+ EOF
95
+ expect_rc "block-sequence before dev key: enabled" 0 "$cfg"
96
+
97
+ echo
98
+ echo "summary: $pass passed, $fail failed"
99
+ [[ "$fail" -eq 0 ]]