@reddb-io/red-skills 4.4.0 → 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 +234 -230
  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,113 @@
1
+ #!/usr/bin/env bash
2
+ # branch-lock-hook.sh — PreToolUse(Bash) hook that blocks the agent from
3
+ # switching away from the locked branch. Self-contained: composes the three
4
+ # pure modules (lock-store, scope-resolver, git-command-classifier) into one
5
+ # allow/block verdict, with no dependency on the git-guardrails skill.
6
+ #
7
+ # Reads the Claude Code PreToolUse payload on stdin, extracts the Bash command,
8
+ # and:
9
+ # 1. resolves the project root (CLAUDE_PROJECT_DIR, else the git toplevel);
10
+ # 2. exits 0 (allow) when the checkout is an /afk worktree — scope exemption;
11
+ # 3. exits 0 (allow) when no lock file is present — unlocked is the default;
12
+ # 4. classifies the command; on "block" prints a clear message to stderr and
13
+ # exits 2 (the Claude Code convention for "deny this tool call").
14
+ #
15
+ # Any verdict other than block is a silent exit 0, so the hook never adds noise
16
+ # to allowed commands.
17
+
18
+ set -uo pipefail
19
+
20
+ allow() {
21
+ printf '{}'
22
+ exit 0
23
+ }
24
+
25
+ deny() {
26
+ local reason="$1"
27
+ printf '%s\n' "$reason" >&2
28
+ jq -nc --arg reason "$reason" '{
29
+ decision: "block",
30
+ reason: $reason,
31
+ hookSpecificOutput: {
32
+ hookEventName: "PreToolUse",
33
+ permissionDecision: "deny",
34
+ permissionDecisionReason: $reason
35
+ }
36
+ }'
37
+ exit 0
38
+ }
39
+
40
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
41
+ # shellcheck source=lib/lock-store.sh
42
+ source "$HERE/lib/lock-store.sh"
43
+ # shellcheck source=lib/scope-resolver.sh
44
+ source "$HERE/lib/scope-resolver.sh"
45
+ # shellcheck source=lib/git-command-classifier.sh
46
+ source "$HERE/lib/git-command-classifier.sh"
47
+ # shellcheck source=lib/dev-config.sh
48
+ source "$HERE/lib/dev-config.sh"
49
+
50
+ INPUT="$(timeout "${RED_SKILLS_HOOK_STDIN_TIMEOUT_S:-5s}" cat 2>/dev/null || true)"
51
+ COMMAND="$(jq -r '.tool_input.command // empty' <<<"$INPUT" 2>/dev/null)"
52
+ [[ -z "$COMMAND" ]] && allow
53
+
54
+ # Project root: prefer the harness-provided dir, fall back to the git toplevel.
55
+ ROOT="${CLAUDE_PROJECT_DIR:-}"
56
+ if [[ -z "$ROOT" ]]; then
57
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
58
+ fi
59
+ [[ -z "$ROOT" ]] && allow
60
+
61
+ # Per-directory plugin gate (ADR 0067): the dev plugin's hooks are installed
62
+ # globally but must stay fully inert in any repo that did not opt in. Exit before
63
+ # any lock/scope work unless `plugins.dev.enabled: true` is set here.
64
+ dev_plugin_enabled "$ROOT/.red/config.yaml" || allow
65
+
66
+ # Scope: /afk worktrees are exempt even when a lock is active.
67
+ scope_should_enforce "$ROOT" || allow
68
+
69
+ # Untouchable primary (ADR 0083 §2): an agent may never move the primary
70
+ # checkout's branch. This block is unconditional — it no longer arms with the
71
+ # `dev.lock.primary-branch` toggle (which stays readable for backward
72
+ # compatibility but can no longer *enable* switching; /red-doctor may flag it as
73
+ # redundant). Human terminals are unaffected: this is an agent-only pre-tool hook
74
+ # (ADR 0006).
75
+ if [[ "$(classify_primary_branch_switch_guard "$COMMAND")" == "block" ]]; then
76
+ deny "$(cat <<EOF
77
+ BLOCKED by the untouchable-primary rule (ADR 0083): an agent can never switch
78
+ the primary checkout's branch or destroy work in it, regardless of
79
+ configuration or lock state. The command '$COMMAND' would move the agent's
80
+ primary checkout to another branch or destroy work in it (branch switch,
81
+ 'git reset' in any form, 'git stash', or 'git rebase --autostash'). Parallel
82
+ human WIP lives in this primary checkout, and these commands have destroyed
83
+ in-progress work before.
84
+
85
+ Do branch work in an isolated worktree under .red/tmp/worktrees/manual/<slug> instead
86
+ ('git worktree add .red/tmp/worktrees/manual/<slug> -b <branch> origin/main'). If the local
87
+ trunk diverged from origin, leave it alone and base on the fresh remote ref
88
+ (ADR 0083) — never reset or stash the primary to reconcile it.
89
+
90
+ Allowed in the primary checkout: git commit, git worktree add, read-only git,
91
+ and other non-destructive commands. To change the primary branch, ask the user.
92
+ EOF
93
+ )"
94
+ fi
95
+
96
+ LOCKFILE="$ROOT/.red/tmp/branch-lock.yaml"
97
+ LOCK_BRANCH="$(lock_store_read "$LOCKFILE")" || allow # absent => unlocked
98
+
99
+ if [[ "$(classify_git_command "$LOCK_BRANCH" "$COMMAND")" == "block" ]]; then
100
+ deny "$(cat <<EOF
101
+ BLOCKED by branch lock: this session is locked to '$LOCK_BRANCH'.
102
+ The command '$COMMAND' would switch the agent away from the locked branch or
103
+ discard working-tree changes (stash, clean -f, reset --hard, whole-tree restore).
104
+
105
+ Allowed while locked: switching back to '$LOCK_BRANCH', targeted file restore
106
+ ('git checkout -- <path>', 'git restore <path>'), read-only stash, dry-run clean,
107
+ soft/mixed reset, and 'git worktree add'. To change or release the lock, ask the
108
+ user — they drive it with '/branch-lock <branch>' or '/branch-lock clear'.
109
+ EOF
110
+ )"
111
+ fi
112
+
113
+ allow
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+ # branch-lock-session-start.sh — SessionStart hook that offers, once at session
3
+ # start, to lock the agent to the current branch. Self-contained: reuses the
4
+ # same two pure modules as the PreToolUse hook (lock-store, scope-resolver) so
5
+ # the prompt obeys the exact same scope rule as enforcement.
6
+ #
7
+ # On SessionStart it:
8
+ # 1. resolves the project root (CLAUDE_PROJECT_DIR, else the git toplevel);
9
+ # 2. stays silent (exit 0, no output) inside an /afk worktree — the lock
10
+ # protects the interactive primary checkout, never the autonomous loop;
11
+ # 3. stays silent when a lock is already present — nothing to offer;
12
+ # 4. stays silent on a detached HEAD — there is no branch to lock;
13
+ # 5. otherwise emits a SessionStart additionalContext block instructing the
14
+ # agent to ask the user whether to lock to the current branch before doing
15
+ # anything else.
16
+ #
17
+ # The hook never writes the lock itself: it only injects the instruction. The
18
+ # agent runs `branch-lock.sh set <branch>` (or the user's /branch-lock command)
19
+ # on a "yes", and leaves the repo unlocked on a "no".
20
+
21
+ set -uo pipefail
22
+
23
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ # shellcheck source=lib/lock-store.sh
25
+ source "$HERE/lib/lock-store.sh"
26
+ # shellcheck source=lib/scope-resolver.sh
27
+ source "$HERE/lib/scope-resolver.sh"
28
+
29
+ # session_start_should_prompt <root> <lockfile> <branch>
30
+ # Returns 0 (prompt) only in the primary checkout, while unlocked, with a real
31
+ # branch checked out. Returns 1 (stay silent) otherwise. No output.
32
+ session_start_should_prompt() {
33
+ local _root="$1" _lockfile="$2" _branch="$3"
34
+ scope_should_enforce "$_root" || return 1 # worktree => exempt
35
+ lock_store_is_locked "$_lockfile" && return 1 # already locked => nothing to offer
36
+ [[ -z "$_branch" || "$_branch" == "HEAD" ]] && return 1 # detached / no branch
37
+ return 0
38
+ }
39
+
40
+ # session_start_prompt_text <branch>
41
+ # The instruction injected into the agent's context.
42
+ session_start_prompt_text() {
43
+ local _branch="$1"
44
+ cat <<EOF
45
+ branch-lock: this session is on branch '$_branch' in the primary checkout, and the branch is not locked. Before doing anything else, ask the user whether to lock the agent to '$_branch' for this session, showing the branch name. If they say yes, lock it with the /branch-lock command (or run scripts/branch-lock.sh set '$_branch'). If they say no, leave the repo unlocked and proceed. Do not lock without an explicit yes.
46
+ EOF
47
+ }
48
+
49
+ main() {
50
+ # Drain stdin (SessionStart sends a JSON payload we don't need to inspect).
51
+ cat >/dev/null 2>&1 || true
52
+
53
+ local root="${CLAUDE_PROJECT_DIR:-}"
54
+ if [[ -z "$root" ]]; then
55
+ root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
56
+ fi
57
+ [[ -z "$root" ]] && exit 0
58
+
59
+ local lockfile="$root/.red/tmp/branch-lock.yaml"
60
+ local branch
61
+ branch="$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
62
+
63
+ session_start_should_prompt "$root" "$lockfile" "$branch" || exit 0
64
+
65
+ local text
66
+ text="$(session_start_prompt_text "$branch")"
67
+
68
+ if command -v jq >/dev/null 2>&1; then
69
+ jq -nc --arg ctx "$text" \
70
+ '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $ctx}}'
71
+ else
72
+ # Fallback: bare stdout is added to the session context too.
73
+ printf '%s\n' "$text"
74
+ fi
75
+ exit 0
76
+ }
77
+
78
+ # Run main only when executed directly, not when sourced by tests.
79
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
80
+ main "$@"
81
+ fi
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ # branch-lock.sh — the CLI behind the /branch-lock command. Sets, clears, and
3
+ # reports the branch lock, keeping lib/lock-store.sh the single writer of the
4
+ # lock file. The lock always lives at <root>/.red/tmp/branch-lock.yaml, where
5
+ # <root> is CLAUDE_PROJECT_DIR (set by the harness) or the git toplevel.
6
+ #
7
+ # Usage:
8
+ # branch-lock.sh set [branch] lock to <branch> (default: current branch),
9
+ # atomic relock-then-switch — rewrite the lock
10
+ # target first so the move "back to the lock
11
+ # target" is itself allowed by the hook, then
12
+ # switch the working tree if not already there.
13
+ # branch-lock.sh clear release the lock.
14
+ # branch-lock.sh status print the locked branch, or "unlocked".
15
+
16
+ set -uo pipefail
17
+
18
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ # shellcheck source=lib/lock-store.sh
20
+ source "$HERE/lib/lock-store.sh"
21
+
22
+ resolve_root() {
23
+ local root="${CLAUDE_PROJECT_DIR:-}"
24
+ [[ -z "$root" ]] && root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
25
+ [[ -z "$root" ]] && { echo "branch-lock: not inside a git repo and CLAUDE_PROJECT_DIR unset" >&2; return 1; }
26
+ printf '%s' "$root"
27
+ }
28
+
29
+ main() {
30
+ local action="${1:-status}"
31
+ local root lockfile
32
+ root="$(resolve_root)" || return 1
33
+ lockfile="$root/.red/tmp/branch-lock.yaml"
34
+
35
+ case "$action" in
36
+ set)
37
+ local branch="${2:-}"
38
+ [[ -z "$branch" ]] && branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
39
+ [[ -z "$branch" ]] && { echo "branch-lock: no branch given and HEAD is detached" >&2; return 1; }
40
+ # Atomic relock-then-switch: rewrite the target before moving, so the
41
+ # subsequent switch is "return to the lock target" and the hook allows it.
42
+ lock_store_write "$lockfile" "$branch" || return 1
43
+ local current; current="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
44
+ if [[ -n "$current" && "$current" != "$branch" ]]; then
45
+ git switch "$branch" || { echo "branch-lock: locked to '$branch' but switch failed (resolve manually)" >&2; return 1; }
46
+ fi
47
+ echo "branch-lock: locked to '$branch'"
48
+ ;;
49
+ clear)
50
+ lock_store_clear "$lockfile"
51
+ echo "branch-lock: cleared (unlocked)"
52
+ ;;
53
+ status)
54
+ local b
55
+ if b="$(lock_store_read "$lockfile")"; then
56
+ echo "branch-lock: locked to '$b'"
57
+ else
58
+ echo "branch-lock: unlocked"
59
+ fi
60
+ ;;
61
+ *)
62
+ echo "usage: branch-lock.sh <set [branch]|clear|status>" >&2
63
+ return 2
64
+ ;;
65
+ esac
66
+ }
67
+
68
+ main "$@"
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bash
2
+ # lib/dev-config.sh — tiny runtime reader for `.red/config.yaml` dev flags.
3
+ #
4
+ # The hook must be dormant by default: missing config file or missing key means
5
+ # disabled. This parser intentionally mirrors the constrained YAML shape used by
6
+ # the dev runtime: nested 2-space mappings with scalar leaves. It exposes the
7
+ # few boolean flags the dev hooks need (ADR 0043 lock guard, ADR 0067 gate).
8
+
9
+ # _dev_config_scalar_true <config.yaml> <dotted-key...>
10
+ # Returns 0 when ANY of the given dotted keys is the exact scalar `true`, 1
11
+ # otherwise (including a missing file). Shared parser for the public readers.
12
+ _dev_config_scalar_true() {
13
+ local _file="$1"
14
+ shift
15
+ local -a _keys=("$@")
16
+ [[ -f "$_file" ]] || return 1
17
+
18
+ local -a _stack=()
19
+ local -a _indents=()
20
+ local _raw _line _indent_str _indent _rest _key _value _full _i
21
+ local _inner _before_close _close_len _tail
22
+
23
+ while IFS= read -r _raw || [[ -n "$_raw" ]]; do
24
+ _raw="${_raw%$'\r'}"
25
+ _line="$_raw"
26
+
27
+ if [[ "$_line" != *\"* && "$_line" != *\'* ]]; then
28
+ _line="${_line%%#*}"
29
+ fi
30
+ _line="${_line%"${_line##*[![:space:]]}"}"
31
+ [[ -z "${_line//[[:space:]]/}" ]] && continue
32
+
33
+ _indent_str="${_line%%[![:space:]]*}"
34
+ _indent=${#_indent_str}
35
+ (( _indent % 2 == 0 )) || return 1
36
+ _rest="${_line:$_indent}"
37
+
38
+ # Skip block-sequence items (`- value`) — they never contain dev flags.
39
+ # A top-level sequence (empty stack) mirrors the TS parser: malformed → bail.
40
+ if [[ "$_rest" =~ ^-([[:space:]]|$) ]]; then
41
+ (( ${#_stack[@]} == 0 )) && return 1
42
+ continue
43
+ fi
44
+
45
+ [[ "$_rest" =~ ^[A-Za-z_][A-Za-z0-9_-]*: ]] || return 1
46
+ _key="${_rest%%:*}"
47
+ _value="${_rest#*:}"
48
+ _value="${_value#"${_value%%[![:space:]]*}"}"
49
+
50
+ while ((${#_indents[@]} > 0 && _indents[${#_indents[@]} - 1] >= _indent)); do
51
+ local _last_stack=$(( ${#_stack[@]} - 1 ))
52
+ local _last_indent=$(( ${#_indents[@]} - 1 ))
53
+ unset "_stack[$_last_stack]"
54
+ unset "_indents[$_last_indent]"
55
+ done
56
+
57
+ _full=""
58
+ for ((_i = 0; _i < ${#_stack[@]}; _i++)); do
59
+ [[ -n "$_full" ]] && _full+="."
60
+ _full+="${_stack[$_i]}"
61
+ done
62
+ [[ -n "$_full" ]] && _full+="."
63
+ _full+="$_key"
64
+
65
+ if [[ -z "$_value" ]]; then
66
+ _stack+=("$_key")
67
+ _indents+=("$_indent")
68
+ continue
69
+ fi
70
+
71
+ # Strip an inline comment that follows a closing quote (e.g. `key: "v" # note`).
72
+ if [[ "${_value:0:1}" == '"' ]]; then
73
+ _inner="${_value:1}"
74
+ _before_close="${_inner%%\"*}"
75
+ if [[ "$_inner" == *'"'* ]]; then
76
+ _close_len=$(( 1 + ${#_before_close} + 1 ))
77
+ _tail="${_value:$_close_len}"
78
+ if [[ "$_tail" =~ ^[[:space:]]*(#.*)?$ ]]; then
79
+ _value="${_value:0:$_close_len}"
80
+ fi
81
+ fi
82
+ elif [[ "${_value:0:1}" == "'" ]]; then
83
+ _inner="${_value:1}"
84
+ _before_close="${_inner%%\'*}"
85
+ if [[ "$_inner" == *"'"* ]]; then
86
+ _close_len=$(( 1 + ${#_before_close} + 1 ))
87
+ _tail="${_value:$_close_len}"
88
+ if [[ "$_tail" =~ ^[[:space:]]*(#.*)?$ ]]; then
89
+ _value="${_value:0:$_close_len}"
90
+ fi
91
+ fi
92
+ fi
93
+
94
+ if [[ "$_value" == \"*\" && "$_value" == *\" ]]; then
95
+ _value="${_value:1:${#_value}-2}"
96
+ elif [[ "$_value" == \'*\' && "$_value" == *\' ]]; then
97
+ _value="${_value:1:${#_value}-2}"
98
+ fi
99
+
100
+ local _k
101
+ for _k in "${_keys[@]}"; do
102
+ if [[ "$_full" == "$_k" ]]; then
103
+ [[ "$_value" == "true" ]] && return 0
104
+ fi
105
+ done
106
+ done < "$_file"
107
+
108
+ return 1
109
+ }
110
+
111
+ # dev_config_lock_primary_branch_enabled <config.yaml>
112
+ # Returns 0 when the primary-branch lock is on, 1 otherwise. The flag lives at
113
+ # `dev.lock.primary-branch` (top-level) or `plugins.dev.lock.primary-branch`
114
+ # (namespaced); either set to exactly `true` enables the guard.
115
+ dev_config_lock_primary_branch_enabled() {
116
+ _dev_config_scalar_true "$1" "dev.lock.primary-branch" "plugins.dev.lock.primary-branch"
117
+ }
118
+
119
+ # dev_plugin_enabled <config.yaml>
120
+ # The per-directory plugin activation gate (ADR 0067): returns 0 only when
121
+ # `plugins.dev.enabled` is the exact scalar `true`. A missing file or missing
122
+ # flag → 1 (dormant). Every dev shell hook must early-exit on a non-zero return
123
+ # so it stays fully inert in repos that did not opt into the dev plugin.
124
+ dev_plugin_enabled() {
125
+ _dev_config_scalar_true "$1" "plugins.dev.enabled"
126
+ }
@@ -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
+ }