@paths.design/caws-cli 11.0.0 → 11.1.1

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 (119) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/init/harness-detect.d.ts +18 -0
  4. package/dist/init/harness-detect.d.ts.map +1 -0
  5. package/dist/init/harness-detect.js +90 -0
  6. package/dist/init/harness-detect.js.map +1 -0
  7. package/dist/init/hook-install.d.ts +53 -0
  8. package/dist/init/hook-install.d.ts.map +1 -0
  9. package/dist/init/hook-install.js +421 -0
  10. package/dist/init/hook-install.js.map +1 -0
  11. package/dist/init/hook-packs/manifest-claude-code.d.ts +4 -0
  12. package/dist/init/hook-packs/manifest-claude-code.d.ts.map +1 -0
  13. package/dist/init/hook-packs/manifest-claude-code.js +190 -0
  14. package/dist/init/hook-packs/manifest-claude-code.js.map +1 -0
  15. package/dist/init/hook-packs/register.d.ts +19 -0
  16. package/dist/init/hook-packs/register.d.ts.map +1 -0
  17. package/dist/init/hook-packs/register.js +37 -0
  18. package/dist/init/hook-packs/register.js.map +1 -0
  19. package/dist/init/hook-packs/types.d.ts +123 -0
  20. package/dist/init/hook-packs/types.d.ts.map +1 -0
  21. package/dist/init/hook-packs/types.js +29 -0
  22. package/dist/init/hook-packs/types.js.map +1 -0
  23. package/dist/shell/commands/gates.d.ts.map +1 -1
  24. package/dist/shell/commands/gates.js +28 -1
  25. package/dist/shell/commands/gates.js.map +1 -1
  26. package/dist/shell/commands/init.d.ts +9 -0
  27. package/dist/shell/commands/init.d.ts.map +1 -1
  28. package/dist/shell/commands/init.js +131 -27
  29. package/dist/shell/commands/init.js.map +1 -1
  30. package/dist/shell/commands/specs.d.ts +41 -0
  31. package/dist/shell/commands/specs.d.ts.map +1 -0
  32. package/dist/shell/commands/specs.js +264 -0
  33. package/dist/shell/commands/specs.js.map +1 -0
  34. package/dist/shell/commands/worktree.d.ts +38 -0
  35. package/dist/shell/commands/worktree.d.ts.map +1 -0
  36. package/dist/shell/commands/worktree.js +286 -0
  37. package/dist/shell/commands/worktree.js.map +1 -0
  38. package/dist/shell/gates/disposition.d.ts.map +1 -1
  39. package/dist/shell/gates/disposition.js +33 -3
  40. package/dist/shell/gates/disposition.js.map +1 -1
  41. package/dist/shell/gates/local-evaluators/budget-limit.d.ts +24 -0
  42. package/dist/shell/gates/local-evaluators/budget-limit.d.ts.map +1 -0
  43. package/dist/shell/gates/local-evaluators/budget-limit.js +67 -0
  44. package/dist/shell/gates/local-evaluators/budget-limit.js.map +1 -0
  45. package/dist/shell/gates/local-evaluators/diff-helpers.d.ts +25 -0
  46. package/dist/shell/gates/local-evaluators/diff-helpers.d.ts.map +1 -0
  47. package/dist/shell/gates/local-evaluators/diff-helpers.js +74 -0
  48. package/dist/shell/gates/local-evaluators/diff-helpers.js.map +1 -0
  49. package/dist/shell/gates/local-evaluators/index.d.ts +28 -0
  50. package/dist/shell/gates/local-evaluators/index.d.ts.map +1 -0
  51. package/dist/shell/gates/local-evaluators/index.js +67 -0
  52. package/dist/shell/gates/local-evaluators/index.js.map +1 -0
  53. package/dist/shell/gates/local-evaluators/scope-boundary.d.ts +23 -0
  54. package/dist/shell/gates/local-evaluators/scope-boundary.d.ts.map +1 -0
  55. package/dist/shell/gates/local-evaluators/scope-boundary.js +67 -0
  56. package/dist/shell/gates/local-evaluators/scope-boundary.js.map +1 -0
  57. package/dist/shell/gates/local-evaluators/spec-completeness.d.ts +12 -0
  58. package/dist/shell/gates/local-evaluators/spec-completeness.d.ts.map +1 -0
  59. package/dist/shell/gates/local-evaluators/spec-completeness.js +73 -0
  60. package/dist/shell/gates/local-evaluators/spec-completeness.js.map +1 -0
  61. package/dist/shell/index.d.ts +4 -0
  62. package/dist/shell/index.d.ts.map +1 -1
  63. package/dist/shell/index.js +13 -1
  64. package/dist/shell/index.js.map +1 -1
  65. package/dist/shell/register.d.ts.map +1 -1
  66. package/dist/shell/register.js +192 -2
  67. package/dist/shell/register.js.map +1 -1
  68. package/dist/shell/render/init-hook-pack.d.ts +16 -0
  69. package/dist/shell/render/init-hook-pack.d.ts.map +1 -0
  70. package/dist/shell/render/init-hook-pack.js +206 -0
  71. package/dist/shell/render/init-hook-pack.js.map +1 -0
  72. package/dist/store/atomic-write.d.ts +20 -2
  73. package/dist/store/atomic-write.d.ts.map +1 -1
  74. package/dist/store/atomic-write.js +44 -2
  75. package/dist/store/atomic-write.js.map +1 -1
  76. package/dist/store/lifecycle-lock.d.ts +34 -0
  77. package/dist/store/lifecycle-lock.d.ts.map +1 -0
  78. package/dist/store/lifecycle-lock.js +168 -0
  79. package/dist/store/lifecycle-lock.js.map +1 -0
  80. package/dist/store/lifecycle-transaction.d.ts +79 -0
  81. package/dist/store/lifecycle-transaction.d.ts.map +1 -0
  82. package/dist/store/lifecycle-transaction.js +319 -0
  83. package/dist/store/lifecycle-transaction.js.map +1 -0
  84. package/dist/store/rules.d.ts +16 -0
  85. package/dist/store/rules.d.ts.map +1 -1
  86. package/dist/store/rules.js +17 -0
  87. package/dist/store/rules.js.map +1 -1
  88. package/dist/store/specs-writer.d.ts +61 -0
  89. package/dist/store/specs-writer.d.ts.map +1 -0
  90. package/dist/store/specs-writer.js +506 -0
  91. package/dist/store/specs-writer.js.map +1 -0
  92. package/dist/store/worktrees-writer.d.ts +77 -0
  93. package/dist/store/worktrees-writer.d.ts.map +1 -0
  94. package/dist/store/worktrees-writer.js +674 -0
  95. package/dist/store/worktrees-writer.js.map +1 -0
  96. package/dist/store/yaml-patch.d.ts +7 -0
  97. package/dist/store/yaml-patch.d.ts.map +1 -0
  98. package/dist/store/yaml-patch.js +250 -0
  99. package/dist/store/yaml-patch.js.map +1 -0
  100. package/package.json +7 -4
  101. package/templates/hook-packs/claude-code/CLAUDE.md +172 -0
  102. package/templates/hook-packs/claude-code/audit.sh +121 -0
  103. package/templates/hook-packs/claude-code/block-dangerous.sh +158 -0
  104. package/templates/hook-packs/claude-code/classify_command.py +1064 -0
  105. package/templates/hook-packs/claude-code/dispatch/post_tool_use.sh +63 -0
  106. package/templates/hook-packs/claude-code/dispatch/pre_tool_use.sh +50 -0
  107. package/templates/hook-packs/claude-code/dispatch/session_start.sh +41 -0
  108. package/templates/hook-packs/claude-code/dispatch/stop.sh +37 -0
  109. package/templates/hook-packs/claude-code/guard-strikes.sh +140 -0
  110. package/templates/hook-packs/claude-code/lib/parse-input.sh +127 -0
  111. package/templates/hook-packs/claude-code/lib/run-handlers.sh +212 -0
  112. package/templates/hook-packs/claude-code/reset-danger-latch.sh +21 -0
  113. package/templates/hook-packs/claude-code/reset-strikes.sh +243 -0
  114. package/templates/hook-packs/claude-code/runtime-paths.sh +80 -0
  115. package/templates/hook-packs/claude-code/scope-guard.sh +392 -0
  116. package/templates/hook-packs/claude-code/session-caws-status.sh +171 -0
  117. package/templates/hook-packs/claude-code/session-log.sh +180 -0
  118. package/templates/hook-packs/claude-code/worktree-guard.sh +240 -0
  119. package/templates/hook-packs/claude-code/worktree-write-guard.sh +77 -0
@@ -0,0 +1,63 @@
1
+ #!/bin/bash
2
+ # CAWS-MANAGED-HOOK
3
+ # hook_pack: claude-code
4
+ # hook_pack_version: 2
5
+ # caws_min_major: 11
6
+ # lineage_refs: 8,16
7
+ # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
+ # PostToolUse dispatcher for Claude Code hooks.
9
+ #
10
+ # Single entry point invoked from settings.json's PostToolUse block. Reads
11
+ # stdin ONCE, sanitizes via lib/parse-input.sh, then invokes every
12
+ # registered handler with HOOK_* env vars inherited and the sanitized
13
+ # JSON piped to each handler's stdin.
14
+ #
15
+ # Differences from pre_tool_use.sh:
16
+ # - HANDLERS entries may carry a positional argument (e.g. "audit.sh
17
+ # tool-use"). Entries are split on whitespace and passed to the
18
+ # handler as argv, so existing scripts that dispatch on $1 keep
19
+ # working without change.
20
+ # - Exit 2 is a no-op for PostToolUse semantically (the tool has
21
+ # already run) but we still honor it to short-circuit the chain and
22
+ # propagate the blocker's stderr, matching the pre-tool-use
23
+ # contract.
24
+ #
25
+ # Stdout: last non-empty handler buffer wins. Most PostToolUse handlers
26
+ # write hookSpecificOutput JSON (quality-check, validate-spec, naming,
27
+ # doc-frontmatter). Since each of those self-filters on file type, only
28
+ # one of them emits stdout for any given Write/Edit. If two ever collide
29
+ # (e.g., a YAML file that happens to match both the spec validator and
30
+ # the naming check), the later-in-HANDLERS wins. Order below is set so
31
+ # the more informative check runs last.
32
+ #
33
+ # Stderr: prefixed with "[<handler>]" so the source of any message is
34
+ # visible to the agent.
35
+ #
36
+ # Fail-open: parser or lib failure returns exit 0 silently.
37
+
38
+ set -uo pipefail
39
+
40
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
41
+ HOOKS_DIR="$(dirname "$SCRIPT_DIR")"
42
+
43
+ # shellcheck source=../lib/parse-input.sh
44
+ source "$HOOKS_DIR/lib/parse-input.sh" 2>/dev/null || exit 0
45
+ parse_hook_input || exit 0
46
+
47
+ # shellcheck source=../lib/run-handlers.sh
48
+ source "$HOOKS_DIR/lib/run-handlers.sh" 2>/dev/null || exit 0
49
+
50
+ # Registered handlers in execution order. Mirrors the pre-registry
51
+ # settings.json groups so ordering-sensitive behavior (stdout "last
52
+ # wins" policy, audit log ordering) is preserved.
53
+ HANDLERS=(
54
+ # "quality-check.sh"
55
+ # "validate-spec.sh"
56
+ # "naming-check.sh"
57
+ # "doc-frontmatter-check.sh"
58
+ # "audit.sh tool-use"
59
+ # "plan-transcript-snapshot.sh"
60
+ "session-log.sh"
61
+ )
62
+
63
+ run_handlers "${HANDLERS[@]}"
@@ -0,0 +1,50 @@
1
+ #!/bin/bash
2
+ # CAWS-MANAGED-HOOK
3
+ # hook_pack: claude-code
4
+ # hook_pack_version: 2
5
+ # caws_min_major: 11
6
+ # lineage_refs: 8,11,17
7
+ # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
+ #
9
+ # PreToolUse dispatcher for Claude Code hooks.
10
+ #
11
+ # Single entry point invoked from settings.json's PreToolUse block. Reads
12
+ # stdin ONCE, sanitizes it via lib/parse-input.sh, then invokes every
13
+ # registered handler with HOOK_* env vars inherited and the sanitized
14
+ # JSON piped to each handler's stdin.
15
+ #
16
+ # Handlers self-filter via their own matcher predicate (a case statement
17
+ # on $HOOK_TOOL_NAME at the top of the script).
18
+ #
19
+ # Exit-code aggregation:
20
+ # - First handler exiting 2 short-circuits the remaining handlers and
21
+ # the dispatcher returns 2 (blocking).
22
+ # - Non-zero non-2 exits are warnings; the dispatcher continues and
23
+ # returns the max non-2 code at the end.
24
+ #
25
+ # Fail-open: if the dispatcher itself errors before any handler runs
26
+ # (parser crash, missing lib), it exits 0 rather than blocking the tool.
27
+ # Guard infrastructure must not turn its own bugs into tool-call blocks.
28
+
29
+ set -uo pipefail
30
+
31
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
32
+ HOOKS_DIR="$(dirname "$SCRIPT_DIR")"
33
+
34
+ # shellcheck source=../lib/parse-input.sh
35
+ source "$HOOKS_DIR/lib/parse-input.sh" 2>/dev/null || exit 0
36
+ parse_hook_input || exit 0
37
+
38
+ # shellcheck source=../lib/run-handlers.sh
39
+ source "$HOOKS_DIR/lib/run-handlers.sh" 2>/dev/null || exit 0
40
+
41
+ # Registered handlers in execution order. Each handler self-filters
42
+ # on $HOOK_TOOL_NAME; non-matching cases return exit 0 cheaply.
43
+ HANDLERS=(
44
+ block-dangerous.sh
45
+ worktree-guard.sh
46
+ scope-guard.sh
47
+ worktree-write-guard.sh
48
+ )
49
+
50
+ run_handlers --short-circuit-on-block "${HANDLERS[@]}"
@@ -0,0 +1,41 @@
1
+ #!/bin/bash
2
+ # CAWS-MANAGED-HOOK
3
+ # hook_pack: claude-code
4
+ # hook_pack_version: 2
5
+ # caws_min_major: 11
6
+ # lineage_refs: 10,11
7
+ # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
+ # SessionStart dispatcher for Claude Code hooks.
9
+ #
10
+ # Fires once per session. Same fan-out semantics as pre_tool_use.sh and
11
+ # post_tool_use.sh: reads stdin once, exports HOOK_* env vars, invokes
12
+ # each registered handler with stdin piped and stderr prefixed.
13
+ #
14
+ # HANDLERS entries may carry a positional argument (e.g. "audit.sh
15
+ # session-start" -- audit.sh's event type is an argv, not a field in
16
+ # the stdin payload). Entries are split on whitespace and passed as argv.
17
+ #
18
+ # SessionStart semantics: these hooks inform the agent about session
19
+ # state (CAWS briefing, audit log bootstrap, session-log meta file).
20
+ # None should block. Exit 2 is treated the same as exit 1 here --
21
+ # recorded as max_exit but does not short-circuit.
22
+
23
+ set -uo pipefail
24
+
25
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
+ HOOKS_DIR="$(dirname "$SCRIPT_DIR")"
27
+
28
+ # shellcheck source=../lib/parse-input.sh
29
+ source "$HOOKS_DIR/lib/parse-input.sh" 2>/dev/null || exit 0
30
+ parse_hook_input || exit 0
31
+
32
+ # shellcheck source=../lib/run-handlers.sh
33
+ source "$HOOKS_DIR/lib/run-handlers.sh" 2>/dev/null || exit 0
34
+
35
+ HANDLERS=(
36
+ "audit.sh session-start"
37
+ # "session-caws-status.sh session-start"
38
+ "session-log.sh"
39
+ )
40
+
41
+ run_handlers "${HANDLERS[@]}"
@@ -0,0 +1,37 @@
1
+ #!/bin/bash
2
+ # CAWS-MANAGED-HOOK
3
+ # hook_pack: claude-code
4
+ # hook_pack_version: 2
5
+ # caws_min_major: 11
6
+ # lineage_refs: 10
7
+ # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
+ # Stop dispatcher for Claude Code hooks.
9
+ #
10
+ # Fires at end of session. Same fan-out semantics as the other dispatchers.
11
+ # Handlers here finalize session artifacts: audit log closeout, worktree
12
+ # cleanup reminder, plan-transcript finalize, session-log handoff.
13
+ #
14
+ # Stop semantics: none of these handlers should block the user -- the
15
+ # session is already ending. All non-zero exits are treated as warnings;
16
+ # max_exit is reported but no handler short-circuits the chain.
17
+
18
+ set -uo pipefail
19
+
20
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ HOOKS_DIR="$(dirname "$SCRIPT_DIR")"
22
+
23
+ # shellcheck source=../lib/parse-input.sh
24
+ source "$HOOKS_DIR/lib/parse-input.sh" 2>/dev/null || exit 0
25
+ parse_hook_input || exit 0
26
+
27
+ # shellcheck source=../lib/run-handlers.sh
28
+ source "$HOOKS_DIR/lib/run-handlers.sh" 2>/dev/null || exit 0
29
+
30
+ HANDLERS=(
31
+ # "audit.sh stop"
32
+ # "stop-worktree-check.sh"
33
+ "plan-transcript-finalize.sh"
34
+ "session-log.sh"
35
+ )
36
+
37
+ run_handlers "${HANDLERS[@]}"
@@ -0,0 +1,140 @@
1
+ #!/bin/bash
2
+ # CAWS-MANAGED-HOOK
3
+ # hook_pack: claude-code
4
+ # hook_pack_version: 2
5
+ # caws_min_major: 11
6
+ # lineage_refs: 8,16
7
+ # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
+ # Shared progressive strike handling for Claude pre-write guard hooks.
9
+ #
10
+ # If you are reading this because a guard blocked you, do not edit this file or
11
+ # the generated guard-strikes JSON directly to bypass enforcement. The correct
12
+ # recovery paths are:
13
+ # 1. Switch into the right CAWS worktree.
14
+ # 2. Bring the target file into the active spec's scope.in (if it legitimately
15
+ # belongs there), then ask the user to reset your strikes by running:
16
+ # bash .claude/hooks/reset-strikes.sh --current
17
+ # or the equivalent narrower reset (see --help).
18
+ # 3. Ask the user to resolve the conflict explicitly.
19
+ # Never edit guard-strikes-*.json files by hand — use reset-strikes.sh so the
20
+ # reason is logged to .claude/logs/strike-resets.log.
21
+
22
+ guard_worktree_state_dir() {
23
+ local cwd_hint="${1:-}"
24
+ local project_dir="${CLAUDE_PROJECT_DIR:-.}"
25
+ local worktree_dir=""
26
+
27
+ if [[ -n "$cwd_hint" ]] && [[ "$cwd_hint" =~ ^(.*\/\.caws\/worktrees\/[^/]+)($|/) ]]; then
28
+ worktree_dir="${BASH_REMATCH[1]}"
29
+ fi
30
+
31
+ if [[ -z "$worktree_dir" ]] && [[ "$project_dir" =~ ^(.*\/\.caws\/worktrees\/[^/]+)($|/) ]]; then
32
+ worktree_dir="${BASH_REMATCH[1]}"
33
+ fi
34
+
35
+ if [[ -n "$worktree_dir" ]] && [[ -d "$worktree_dir" ]]; then
36
+ mkdir -p "$worktree_dir/tmp"
37
+ printf '%s\n' "$worktree_dir/tmp"
38
+ return 0
39
+ fi
40
+
41
+ return 1
42
+ }
43
+
44
+ guard_strikes_file() {
45
+ local project_dir="${CLAUDE_PROJECT_DIR:-.}"
46
+ local cwd_hint="${2:-}"
47
+ local log_dir="$project_dir/.claude/logs"
48
+ local session_id="$1"
49
+ local safe_session
50
+
51
+ if guard_worktree_state_dir "$cwd_hint" >/dev/null 2>&1; then
52
+ log_dir=$(guard_worktree_state_dir "$cwd_hint")
53
+ else
54
+ mkdir -p "$log_dir"
55
+ fi
56
+
57
+ safe_session=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
58
+ printf '%s/guard-strikes-%s.json' "$log_dir" "$safe_session"
59
+ }
60
+
61
+ guard_record_strike() {
62
+ local session_id="$1"
63
+ local guard_name="$2"
64
+ local cwd_hint="${3:-}"
65
+ local state_file
66
+ local current_count
67
+
68
+ state_file=$(guard_strikes_file "$session_id" "$cwd_hint")
69
+ if [[ ! -f "$state_file" ]]; then
70
+ printf '{}\n' > "$state_file"
71
+ fi
72
+
73
+ current_count=$(jq -r --arg guard "$guard_name" '.[$guard] // 0' "$state_file" 2>/dev/null || printf '0')
74
+ if [[ ! "$current_count" =~ ^[0-9]+$ ]]; then
75
+ current_count=0
76
+ fi
77
+
78
+ current_count=$((current_count + 1))
79
+
80
+ jq --arg guard "$guard_name" --argjson count "$current_count" '.[$guard] = $count' "$state_file" > "$state_file.tmp"
81
+ mv "$state_file.tmp" "$state_file"
82
+
83
+ printf '%s\n' "$current_count"
84
+ }
85
+
86
+ guard_emit_warning_allow() {
87
+ local message="$1"
88
+
89
+ jq -n --arg msg "$message" '{
90
+ hookSpecificOutput: {
91
+ hookEventName: "PreToolUse",
92
+ additionalContext: $msg
93
+ }
94
+ }'
95
+ }
96
+
97
+ guard_emit_permission_ask() {
98
+ local message="$1"
99
+
100
+ jq -n --arg msg "$message" '{
101
+ hookSpecificOutput: {
102
+ hookEventName: "PreToolUse",
103
+ permissionDecision: "ask",
104
+ permissionDecisionReason: $msg
105
+ }
106
+ }'
107
+ }
108
+
109
+ guard_emit_block() {
110
+ local message="$1"
111
+
112
+ jq -n --arg msg "$message" '{
113
+ decision: "block",
114
+ reason: $msg
115
+ }'
116
+ }
117
+
118
+ guard_enforce_progressive_strikes() {
119
+ local session_id="$1"
120
+ local guard_name="$2"
121
+ local cwd_hint="$3"
122
+ local first_message="$4"
123
+ local second_message="$5"
124
+ local third_message="$6"
125
+ local strike
126
+
127
+ strike=$(guard_record_strike "$session_id" "$guard_name" "$cwd_hint")
128
+
129
+ case "$strike" in
130
+ 1)
131
+ guard_emit_warning_allow "$first_message"
132
+ ;;
133
+ 2)
134
+ guard_emit_permission_ask "$second_message"
135
+ ;;
136
+ *)
137
+ guard_emit_block "$third_message"
138
+ ;;
139
+ esac
140
+ }
@@ -0,0 +1,127 @@
1
+ #!/bin/bash
2
+ # CAWS-MANAGED-HOOK
3
+ # hook_pack: claude-code
4
+ # hook_pack_version: 2
5
+ # caws_min_major: 11
6
+ # lineage_refs: 8,16
7
+ # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
+ # Shared hook input parser for Claude Code hooks.
9
+ #
10
+ # Handlers source this file and call `parse_hook_input` to get HOOK_* env
11
+ # vars populated from the tool-call payload. This replaces the per-handler
12
+ # pattern of `INPUT=$(read_hook_input_json)` followed by 3-5 `jq` calls.
13
+ #
14
+ # Two invocation modes:
15
+ # 1. Standalone handler (legacy): reads stdin via read_hook_input_json,
16
+ # parses, exports HOOK_*.
17
+ # 2. Via dispatcher (planned Phase 2): HOOK_INPUT_JSON already exported
18
+ # by the router; parse_hook_input just re-extracts scalar fields.
19
+ #
20
+ # Why one shared parser: before this lib, each of 17 handlers independently
21
+ # ran read_hook_input_json + 3-5 jq calls on the same payload. A bug in the
22
+ # parser (like the control-char failure fixed in runtime-paths.sh) ripples
23
+ # to every handler. One parser, one bug surface, one fix.
24
+ #
25
+ # Idempotent source: safe to source multiple times.
26
+
27
+ if [[ -n "${_HOOK_PARSE_INPUT_LOADED:-}" ]]; then
28
+ return 0 2>/dev/null || exit 0
29
+ fi
30
+ _HOOK_PARSE_INPUT_LOADED=1
31
+
32
+ _hook_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
33
+ # shellcheck source=../runtime-paths.sh
34
+ source "$_hook_lib_dir/../runtime-paths.sh"
35
+
36
+ parse_hook_input() {
37
+ # Fast path: the dispatcher already parsed the input and exported
38
+ # HOOK_* env vars to the handler's environment. Re-extracting from
39
+ # HOOK_INPUT_JSON would be a wasted python subprocess. HOOK_TOOL_NAME
40
+ # is the canonical "parse completed" marker -- after a completed parse
41
+ # it's always defined (possibly empty for malformed input), so the
42
+ # `${HOOK_TOOL_NAME+set}` test distinguishes "parser ran" from
43
+ # "handler invoked standalone and parser hasn't run yet".
44
+ if [[ -n "${HOOK_TOOL_NAME+set}" ]]; then
45
+ return 0
46
+ fi
47
+
48
+ # If HOOK_INPUT_JSON is set but HOOK_TOOL_NAME is not, a caller staged
49
+ # the sanitized payload but didn't run the extractor. Extract now.
50
+ # Otherwise (standalone handler), read stdin via the sanitizer.
51
+ if [[ -z "${HOOK_INPUT_JSON:-}" ]]; then
52
+ HOOK_INPUT_JSON="$(read_hook_input_json)"
53
+ export HOOK_INPUT_JSON
54
+ fi
55
+
56
+ # Extract all common scalar fields in ONE python call, emitting
57
+ # shlex-quoted bash assignments. Compared to 3-5 separate `jq` calls,
58
+ # this is one subprocess per handler instead of many. Values are sh-safe
59
+ # via shlex.quote, so `eval` is not a code-injection hazard.
60
+ local assignments
61
+ assignments=$(printf '%s' "$HOOK_INPUT_JSON" | python3 -c '
62
+ import json
63
+ import shlex
64
+ import sys
65
+
66
+ try:
67
+ data = json.loads(sys.stdin.read() or "{}")
68
+ except Exception:
69
+ data = {}
70
+ if not isinstance(data, dict):
71
+ data = {}
72
+
73
+ tool_input = data.get("tool_input")
74
+ if not isinstance(tool_input, dict):
75
+ tool_input = {}
76
+
77
+ tool_response = data.get("tool_response")
78
+ if not isinstance(tool_response, dict):
79
+ tool_response = {}
80
+
81
+ fields = {
82
+ "HOOK_TOOL_NAME": data.get("tool_name") or "",
83
+ "HOOK_FILE_PATH": tool_input.get("file_path") or "",
84
+ "HOOK_COMMAND": tool_input.get("command") or "",
85
+ "HOOK_CWD": data.get("cwd") or "",
86
+ "HOOK_SESSION_ID": data.get("session_id") or "unknown",
87
+ "HOOK_TRANSCRIPT_PATH": data.get("transcript_path") or "",
88
+ "HOOK_EVENT_NAME": data.get("hook_event_name") or "",
89
+ "HOOK_MODEL": data.get("model") or "",
90
+ "HOOK_SOURCE": data.get("source") or "",
91
+ "HOOK_PERMISSION_MODE": data.get("permission_mode") or "default",
92
+ "HOOK_TOOL_USE_ID": data.get("tool_use_id") or "",
93
+ "HOOK_STOP_HOOK_ACTIVE": "1" if data.get("stop_hook_active") else "0",
94
+ # Whole objects as JSON strings -- consumed by audit.sh for log payloads.
95
+ # Always valid JSON ("{}" at minimum) so `jq --argjson` works without
96
+ # a defensive check in every caller.
97
+ "HOOK_TOOL_INPUT_JSON": json.dumps(tool_input),
98
+ "HOOK_TOOL_RESPONSE_JSON": json.dumps(tool_response),
99
+ }
100
+
101
+ for k, v in fields.items():
102
+ print(f"{k}={shlex.quote(str(v))}")
103
+ ' 2>/dev/null || true)
104
+
105
+ # Fail-open: if the python subprocess failed for any reason, leave
106
+ # HOOK_* vars unset/empty. Handlers will see empty tool_name and
107
+ # short-circuit on their own matcher predicate. Guard infrastructure
108
+ # must never block a tool call because its parser crashed.
109
+ if [[ -n "$assignments" ]]; then
110
+ eval "$assignments"
111
+ fi
112
+
113
+ export HOOK_TOOL_NAME="${HOOK_TOOL_NAME:-}" \
114
+ HOOK_FILE_PATH="${HOOK_FILE_PATH:-}" \
115
+ HOOK_COMMAND="${HOOK_COMMAND:-}" \
116
+ HOOK_CWD="${HOOK_CWD:-}" \
117
+ HOOK_SESSION_ID="${HOOK_SESSION_ID:-unknown}" \
118
+ HOOK_TRANSCRIPT_PATH="${HOOK_TRANSCRIPT_PATH:-}" \
119
+ HOOK_EVENT_NAME="${HOOK_EVENT_NAME:-}" \
120
+ HOOK_MODEL="${HOOK_MODEL:-}" \
121
+ HOOK_SOURCE="${HOOK_SOURCE:-}" \
122
+ HOOK_PERMISSION_MODE="${HOOK_PERMISSION_MODE:-default}" \
123
+ HOOK_TOOL_USE_ID="${HOOK_TOOL_USE_ID:-}" \
124
+ HOOK_STOP_HOOK_ACTIVE="${HOOK_STOP_HOOK_ACTIVE:-0}" \
125
+ HOOK_TOOL_INPUT_JSON="${HOOK_TOOL_INPUT_JSON:-{\}}" \
126
+ HOOK_TOOL_RESPONSE_JSON="${HOOK_TOOL_RESPONSE_JSON:-{\}}"
127
+ }
@@ -0,0 +1,212 @@
1
+ #!/bin/bash
2
+ # CAWS-MANAGED-HOOK
3
+ # hook_pack: claude-code
4
+ # hook_pack_version: 2
5
+ # caws_min_major: 11
6
+ # lineage_refs: 8,16
7
+ # do_not_edit_directly: update via `caws init --agent-surface claude-code`
8
+ # Shared handler-dispatch loop for Claude Code hook dispatchers.
9
+ #
10
+ # Source this file from a dispatcher script, then call:
11
+ #
12
+ # run_handlers [--short-circuit-on-block] <handler-entry>...
13
+ #
14
+ # Each handler-entry is a whitespace-separated string whose first token is
15
+ # the handler script filename (relative to HOOKS_DIR) and whose remaining
16
+ # tokens are positional arguments forwarded to that script. Example:
17
+ #
18
+ # run_handlers "cwd-guard.sh" "audit.sh tool-use" "session-log.sh"
19
+ #
20
+ # The caller must set HOOKS_DIR before sourcing this file (the dispatcher
21
+ # boilerplate does this already).
22
+ #
23
+ # Environment variables consumed:
24
+ # HOOK_INPUT_JSON — the sanitized JSON payload piped to every handler.
25
+ # If not yet set, parse_hook_input is called to
26
+ # populate it along with all HOOK_* scalar vars.
27
+ # CLAUDE_HOOK_DRY_RUN — if non-empty and non-zero, still invoke every
28
+ # handler but always return 0 from run_handlers and
29
+ # emit "[DRY-RUN] <handler>.sh would have exited <N>"
30
+ # to stderr for any non-zero exit.
31
+ # CLAUDE_HOOK_TIMING — if non-empty and non-zero, emit
32
+ # "[timing] <handler>.sh: <N>ms" to stderr after
33
+ # each handler invocation. Does not affect exit codes
34
+ # or stdout behavior.
35
+ #
36
+ # Stdout: the last non-empty buffer written to a handler's stdout is forwarded
37
+ # to run_handlers' caller's stdout ("last wins").
38
+ #
39
+ # Return value: the maximum exit code across all handlers (or 2 immediately if
40
+ # --short-circuit-on-block is set and any handler exits 2). When
41
+ # CLAUDE_HOOK_DRY_RUN is set the effective return is always 0.
42
+ #
43
+ # Idempotent source: safe to source multiple times.
44
+
45
+ if [[ -n "${_HOOK_RUN_HANDLERS_LOADED:-}" ]]; then
46
+ return 0 2>/dev/null || exit 0
47
+ fi
48
+ _HOOK_RUN_HANDLERS_LOADED=1
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # _rh_is_truthy <value>
52
+ # Returns 0 (true) when value is non-empty and not "0".
53
+ # ---------------------------------------------------------------------------
54
+ _rh_is_truthy() {
55
+ local val="${1:-}"
56
+ [[ -n "$val" && "$val" != "0" ]]
57
+ }
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # _rh_ms_now
61
+ # Prints current Unix time in milliseconds (integer).
62
+ # Uses date +%s%N if available, falls back to python3.
63
+ # ---------------------------------------------------------------------------
64
+ _rh_ms_now() {
65
+ local ns
66
+ ns=$(date +%s%N 2>/dev/null)
67
+ # macOS date does not support %N; it prints literally "%N"
68
+ if [[ "$ns" == *%N* ]]; then
69
+ python3 -c 'import time; print(int(time.time() * 1000))'
70
+ else
71
+ printf '%d\n' "$(( ns / 1000000 ))"
72
+ fi
73
+ }
74
+
75
+ _rh_stdout_priority() {
76
+ local payload="$1"
77
+ local decision
78
+ decision=$(printf '%s' "$payload" | jq -r '.decision // .hookSpecificOutput.permissionDecision // ""' 2>/dev/null || true)
79
+ case "$decision" in
80
+ block) printf '3\n' ;;
81
+ ask) printf '2\n' ;;
82
+ *) printf '1\n' ;;
83
+ esac
84
+ }
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # run_handlers [--short-circuit-on-block] <handler-entry>...
88
+ # ---------------------------------------------------------------------------
89
+ run_handlers() {
90
+ local short_circuit=0
91
+ if [[ "${1:-}" == "--short-circuit-on-block" ]]; then
92
+ short_circuit=1
93
+ shift
94
+ fi
95
+
96
+ # Ensure the input is parsed and HOOK_INPUT_JSON is available.
97
+ # parse-input.sh is idempotent (guarded by _HOOK_PARSE_INPUT_LOADED).
98
+ if [[ -z "${HOOK_INPUT_JSON:-}" ]]; then
99
+ # HOOKS_DIR must be set by the caller (dispatcher boilerplate).
100
+ # shellcheck source=parse-input.sh
101
+ source "${HOOKS_DIR}/lib/parse-input.sh" 2>/dev/null || return 0
102
+ parse_hook_input || return 0
103
+ fi
104
+
105
+ local dry_run=0
106
+ _rh_is_truthy "${CLAUDE_HOOK_DRY_RUN:-}" && dry_run=1
107
+
108
+ local timing=0
109
+ _rh_is_truthy "${CLAUDE_HOOK_TIMING:-}" && timing=1
110
+
111
+ local max_exit=0
112
+ local last_stdout=""
113
+ local last_stdout_priority=0
114
+
115
+ # Snapshot the outer $@ into an array so `set --` inside the loop can safely
116
+ # clobber positional params without breaking iteration. Using "$@" directly
117
+ # with `for entry in "$@"` captures at loop start on modern bash, but this
118
+ # is safer across shells and makes the intent explicit.
119
+ local entries
120
+ entries=("$@")
121
+
122
+ local entry
123
+ for entry in "${entries[@]}"; do
124
+ # Split on whitespace: first token = script, rest = positional args.
125
+ # shellcheck disable=SC2086
126
+ set -- $entry
127
+ local handler="$1"
128
+ shift
129
+ # "$@" now holds the handler's positional args (may be empty). Use it
130
+ # directly rather than stashing into a local array -- bash 3.2 (macOS
131
+ # default) has quirky ${arr[@]+"${arr[@]}"} expansion behavior for
132
+ # empty arrays under set -u in certain command-substitution contexts.
133
+ # "$@" has no such quirks: empty positional params under set -u is a
134
+ # normal, non-error case.
135
+
136
+ local handler_path="${HOOKS_DIR}/${handler}"
137
+ if [[ ! -x "$handler_path" ]]; then
138
+ continue
139
+ fi
140
+
141
+ local t_start=0
142
+ if (( timing )); then
143
+ t_start=$(_rh_ms_now)
144
+ fi
145
+
146
+ local stderr_file
147
+ stderr_file=$(mktemp)
148
+ local stdout_buf
149
+ stdout_buf=$(printf '%s' "$HOOK_INPUT_JSON" \
150
+ | "$handler_path" "$@" 2>"$stderr_file")
151
+ local exit_code=$?
152
+
153
+ local t_elapsed=0
154
+ if (( timing )); then
155
+ local t_end
156
+ t_end=$(_rh_ms_now)
157
+ t_elapsed=$(( t_end - t_start ))
158
+ fi
159
+
160
+ # Re-emit handler stderr prefixed with handler name.
161
+ if [[ -s "$stderr_file" ]]; then
162
+ while IFS= read -r line; do
163
+ printf '[%s] %s\n' "$handler" "$line" >&2
164
+ done < "$stderr_file"
165
+ fi
166
+ rm -f "$stderr_file"
167
+
168
+ # Timing annotation (after handler stderr so they don't interleave).
169
+ if (( timing )); then
170
+ printf '[timing] %s: %dms\n' "$handler" "$t_elapsed" >&2
171
+ fi
172
+
173
+ # Dry-run annotation for non-zero exits.
174
+ if (( dry_run )) && (( exit_code != 0 )); then
175
+ printf '[DRY-RUN] %s would have exited %d\n' "$handler" "$exit_code" >&2
176
+ exit_code=0
177
+ fi
178
+
179
+ # Accumulate stdout. Structured block/ask decisions outrank lower-priority
180
+ # hook context so a later handler cannot accidentally erase a safety
181
+ # boundary emitted by an earlier handler.
182
+ if [[ -n "$stdout_buf" ]]; then
183
+ local stdout_priority
184
+ stdout_priority=$(_rh_stdout_priority "$stdout_buf")
185
+ if [[ "$stdout_priority" -eq 3 ]]; then
186
+ printf '%s\n' "$stdout_buf"
187
+ return 2
188
+ fi
189
+ if [[ "$stdout_priority" -ge "$last_stdout_priority" ]]; then
190
+ last_stdout="$stdout_buf"
191
+ last_stdout_priority="$stdout_priority"
192
+ fi
193
+ fi
194
+
195
+ # Short-circuit on blocking exit (exit 2), unless dry-run zeroed it.
196
+ if (( short_circuit )) && [[ "$exit_code" -eq 2 ]]; then
197
+ [[ -n "$last_stdout" ]] && printf '%s\n' "$last_stdout"
198
+ return 2
199
+ fi
200
+
201
+ if [[ "$exit_code" -gt "$max_exit" ]]; then
202
+ max_exit="$exit_code"
203
+ fi
204
+ done
205
+
206
+ [[ -n "$last_stdout" ]] && printf '%s\n' "$last_stdout"
207
+
208
+ if (( dry_run )); then
209
+ return 0
210
+ fi
211
+ return "$max_exit"
212
+ }