@windyroad/risk-scorer 0.2.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.
@@ -0,0 +1,174 @@
1
+ #!/bin/bash
2
+ # Shared portable helpers for gate enforcement hooks.
3
+ # Sourced by architect-gate.sh, risk-gate.sh, and all hook scripts.
4
+ # Provides: _mtime, _hashcmd, _doc_exclusions, _err_trap, _get_*
5
+
6
+ # ---------------------------------------------------------------------------
7
+ # Portable utilities
8
+ # ---------------------------------------------------------------------------
9
+
10
+ # Portable mtime: tries GNU stat, falls back to macOS stat
11
+ _mtime() { stat -c%Y "$1" 2>/dev/null || /usr/bin/stat -f%m "$1" 2>/dev/null || echo 0; }
12
+
13
+ # Portable hash: tries md5sum, falls back to md5 -r, then shasum
14
+ _hashcmd() { md5sum 2>/dev/null || md5 -r 2>/dev/null || shasum 2>/dev/null; }
15
+
16
+ # Paths excluded from pipeline state hashing and docs-only detection.
17
+ _doc_exclusions() {
18
+ echo ':!docs/' ':!.risk-reports/' ':!.changeset/' ':!governance/' ':!.claude/plans/' ':!CLAUDE.md' ':!AGENTS.md' ':!PRINCIPLES.md' ':!DECISION-MANAGEMENT.md' ':!AGENTIC_RISK_REGISTER.md' ':!PROBLEM-MANAGEMENT.md'
19
+ }
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # ERR trap: outputs diagnostic JSON on hook errors (P010)
23
+ # Usage: source gate-helpers.sh at top of hook, then call _enable_err_trap
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _enable_err_trap() {
27
+ trap '_err_trap_handler "$BASH_SOURCE" "$LINENO" "$BASH_COMMAND"' ERR
28
+ }
29
+
30
+ _err_trap_handler() {
31
+ local script="$1" line="$2" cmd="$3"
32
+ local name
33
+ name=$(basename "$script" 2>/dev/null || echo "$script")
34
+ # Output diagnostic as systemMessage so it's visible in conversation
35
+ cat <<EOF
36
+ {
37
+ "systemMessage": "Hook error in ${name} at line ${line}: ${cmd}"
38
+ }
39
+ EOF
40
+ }
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # JSON input parsing: standardised helpers replacing inline python3
44
+ # Each reads from _HOOK_INPUT (set by the hook before calling these)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ # Store hook input for reuse by parsing helpers
48
+ _HOOK_INPUT=""
49
+
50
+ _parse_input() {
51
+ _HOOK_INPUT=$(cat)
52
+ }
53
+
54
+ _get_tool_name() {
55
+ echo "$_HOOK_INPUT" | python3 -c "
56
+ import sys, json
57
+ try:
58
+ data = json.load(sys.stdin)
59
+ print(data.get('tool_name', ''))
60
+ except:
61
+ print('')
62
+ " 2>/dev/null || echo ""
63
+ }
64
+
65
+ _get_session_id() {
66
+ echo "$_HOOK_INPUT" | python3 -c "
67
+ import sys, json
68
+ try:
69
+ data = json.load(sys.stdin)
70
+ print(data.get('session_id', ''))
71
+ except:
72
+ print('')
73
+ " 2>/dev/null || echo ""
74
+ }
75
+
76
+ _get_command() {
77
+ echo "$_HOOK_INPUT" | python3 -c "
78
+ import sys, json
79
+ try:
80
+ data = json.load(sys.stdin)
81
+ print(data.get('tool_input', {}).get('command', ''))
82
+ except:
83
+ print('')
84
+ " 2>/dev/null || echo ""
85
+ }
86
+
87
+ _get_file_path() {
88
+ echo "$_HOOK_INPUT" | python3 -c "
89
+ import sys, json
90
+ try:
91
+ data = json.load(sys.stdin)
92
+ ti = data.get('tool_input', {})
93
+ print(ti.get('file_path', ti.get('path', '')))
94
+ except:
95
+ print('')
96
+ " 2>/dev/null || echo ""
97
+ }
98
+
99
+ _get_subagent_type() {
100
+ echo "$_HOOK_INPUT" | python3 -c "
101
+ import sys, json
102
+ try:
103
+ data = json.load(sys.stdin)
104
+ print(data.get('tool_input', {}).get('subagent_type', ''))
105
+ except:
106
+ print('')
107
+ " 2>/dev/null || echo ""
108
+ }
109
+
110
+ _get_user_prompt() {
111
+ echo "$_HOOK_INPUT" | python3 -c "
112
+ import sys, json
113
+ try:
114
+ data = json.load(sys.stdin)
115
+ print(data.get('user_prompt', ''))
116
+ except:
117
+ print('')
118
+ " 2>/dev/null || echo ""
119
+ }
120
+
121
+ _get_tool_output() {
122
+ echo "$_HOOK_INPUT" | python3 -c "
123
+ import sys, json
124
+ try:
125
+ data = json.load(sys.stdin)
126
+ # PostToolUse provides tool_response (dict with content array), not tool_output
127
+ tr = data.get('tool_response', {})
128
+ if isinstance(tr, dict):
129
+ content = tr.get('content', [])
130
+ if isinstance(content, list):
131
+ texts = [c.get('text', '') for c in content if isinstance(c, dict) and c.get('type') == 'text']
132
+ if texts:
133
+ print('\n'.join(texts))
134
+ sys.exit(0)
135
+ # Fallback for older/different hook formats
136
+ print(data.get('tool_output', ''))
137
+ except:
138
+ print('')
139
+ " 2>/dev/null || echo ""
140
+ }
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Session-scoped tmp directory for risk files
144
+ # ---------------------------------------------------------------------------
145
+
146
+ # Returns the session-scoped directory for risk temp files.
147
+ # Creates the directory if it doesn't exist.
148
+ # Usage: DIR=$(_risk_dir "$SESSION_ID"); echo "1" > "$DIR/commit"
149
+ _risk_dir() {
150
+ local sid="$1"
151
+ local dir="${TMPDIR:-/tmp}/claude-risk-${sid}"
152
+ mkdir -p "$dir"
153
+ echo "$dir"
154
+ }
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Non-doc file detection for WIP gating
158
+ # ---------------------------------------------------------------------------
159
+
160
+ _is_doc_file() {
161
+ local file_path="$1"
162
+ local EXCL
163
+ EXCL=$(_doc_exclusions)
164
+ for pattern in $EXCL; do
165
+ local clean="${pattern#:!}"
166
+ case "$file_path" in
167
+ *"$clean"*) return 0 ;;
168
+ esac
169
+ done
170
+ case "$file_path" in
171
+ *.claude/*|*.risk-reports/*|*RISK-POLICY.md) return 0 ;;
172
+ esac
173
+ return 1
174
+ }
@@ -0,0 +1,318 @@
1
+ #!/bin/bash
2
+ # Standalone script: Gathers pipeline state and outputs structured text to stdout.
3
+ # Usage: pipeline-state.sh [--uncommitted] [--unpushed] [--unreleased] [--stale] [--all] [--hash-inputs]
4
+ # No network calls (no gh commands). Local git operations only.
5
+
6
+ set -euo pipefail
7
+
8
+ # Source shared helpers for _doc_exclusions
9
+ _PIPELINE_STATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ source "$_PIPELINE_STATE_DIR/gate-helpers.sh"
11
+
12
+ # --- Parse flags ---
13
+ SHOW_UNCOMMITTED=false
14
+ SHOW_UNPUSHED=false
15
+ SHOW_UNRELEASED=false
16
+ SHOW_STALE=false
17
+ SHOW_HASH_INPUTS=false
18
+
19
+ for arg in "$@"; do
20
+ case "$arg" in
21
+ --uncommitted) SHOW_UNCOMMITTED=true ;;
22
+ --unpushed) SHOW_UNPUSHED=true ;;
23
+ --unreleased) SHOW_UNRELEASED=true ;;
24
+ --stale) SHOW_STALE=true ;;
25
+ --hash-inputs) SHOW_HASH_INPUTS=true ;;
26
+ --all)
27
+ SHOW_UNCOMMITTED=true
28
+ SHOW_UNPUSHED=true
29
+ SHOW_UNRELEASED=true
30
+ SHOW_STALE=true
31
+ ;;
32
+ esac
33
+ done
34
+
35
+ # --- Fast hash inputs mode: output only metadata for drift detection hash ---
36
+ # NOTE: Do NOT include `git status --porcelain` (changes on staging) or
37
+ # `git rev-parse HEAD` (changes on commit). Both cause false drift detection.
38
+ # Use diff --stat which reflects actual content changes only.
39
+ if [ "$SHOW_HASH_INPUTS" = true ]; then
40
+ # Single stable diff: all local changes (staged + committed) vs remote.
41
+ # This is stable across commit and push operations — content moves between
42
+ # pipeline stages (staged → committed → pushed) without changing the hash.
43
+ # Previous approach used git diff HEAD + git diff origin/main..HEAD which
44
+ # broke on every commit because content shifted between the two diffs.
45
+ # Exclude docs/governance paths from hash — they cannot affect the running application
46
+ EXCL=$(_doc_exclusions)
47
+ if git rev-parse --verify origin/main >/dev/null 2>&1; then
48
+ eval "git diff origin/main --stat -- $EXCL" 2>/dev/null || true
49
+ elif git rev-parse --verify origin/master >/dev/null 2>&1; then
50
+ eval "git diff origin/master --stat -- $EXCL" 2>/dev/null || true
51
+ else
52
+ eval "git diff HEAD --stat -- $EXCL" 2>/dev/null || true
53
+ fi
54
+ # Changeset count (affects release/changeset risk)
55
+ if [ -d ".changeset" ]; then
56
+ find .changeset -name '*.md' -not -name 'README.md' 2>/dev/null | wc -l | tr -d ' '
57
+ fi
58
+ exit 0
59
+ fi
60
+
61
+ # --- Noise filter: lockfiles, binary assets, OS junk ---
62
+ NOISE='(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|Gemfile\.lock|Pipfile\.lock|poetry\.lock|composer\.lock|Cargo\.lock|go\.sum|shrinkwrap\.json|\.DS_Store|node_modules|\.png$|\.svg$|\.jpg$|\.jpeg$|\.gif$|\.ico$|\.woff2?$|\.ttf$|\.eot$)'
63
+
64
+ # --- File categorisation ---
65
+ categorise_files() {
66
+ python3 -c "
67
+ import sys
68
+ cats = {
69
+ 'hooks': [], 'config': [], 'ci': [], 'ui': [], 'styles': [],
70
+ 'content': [], 'generated': [], 'skills': [], 'docs': [],
71
+ 'tests': [], 'lib': [], 'agents': [], 'other': []
72
+ }
73
+ for line in sys.stdin:
74
+ f = line.strip()
75
+ if not f:
76
+ continue
77
+ if '.claude/hooks/' in f:
78
+ cats['hooks'].append(f)
79
+ elif '.claude/agents/' in f:
80
+ cats['agents'].append(f)
81
+ elif '.claude/skills/' in f:
82
+ cats['skills'].append(f)
83
+ elif '.claude/' in f or f.endswith(('.json', '.toml', '.yml', '.yaml', '.mjs', '.cjs')) and '/' not in f:
84
+ cats['config'].append(f)
85
+ elif '.github/' in f:
86
+ cats['ci'].append(f)
87
+ elif f.endswith(('.tsx', '.jsx', '.html', '.vue', '.svelte')):
88
+ cats['ui'].append(f)
89
+ elif f.endswith(('.scss', '.css', '.less')):
90
+ cats['styles'].append(f)
91
+ elif f.endswith(('.md', '.mdx')):
92
+ if 'generated' in f or 'architecture' in f:
93
+ cats['generated'].append(f)
94
+ elif 'articles/' in f or 'content/' in f or 'posts/' in f:
95
+ cats['content'].append(f)
96
+ else:
97
+ cats['docs'].append(f)
98
+ elif 'generated/' in f:
99
+ cats['generated'].append(f)
100
+ elif f.endswith(('.test.ts', '.test.tsx', '.spec.ts', '.spec.tsx')):
101
+ cats['tests'].append(f)
102
+ elif f.endswith(('.ts', '.js')):
103
+ cats['lib'].append(f)
104
+ else:
105
+ cats['other'].append(f)
106
+ out = []
107
+ for cat, files in cats.items():
108
+ if files:
109
+ out.append(f' {cat}: {len(files)} file(s)')
110
+ if out:
111
+ print('\n'.join(out))
112
+ " 2>/dev/null || echo " (could not categorise)"
113
+ }
114
+
115
+ # --- Section: Uncommitted changes ---
116
+ if [ "$SHOW_UNCOMMITTED" = true ]; then
117
+ DIFF_STAT=$(git diff HEAD --stat -- . \
118
+ ':(exclude)package-lock.json' ':(exclude)yarn.lock' \
119
+ ':(exclude)pnpm-lock.yaml' ':(exclude)bun.lockb' \
120
+ ':(exclude)Gemfile.lock' ':(exclude)Pipfile.lock' \
121
+ ':(exclude)poetry.lock' ':(exclude)composer.lock' \
122
+ ':(exclude)Cargo.lock' ':(exclude)go.sum' \
123
+ ':(exclude)shrinkwrap.json' 2>/dev/null || echo "")
124
+ DIFF_NAMES=$(git diff HEAD --name-only 2>/dev/null | grep -vE "$NOISE" || echo "")
125
+ UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null | grep -vE "$NOISE" || true)
126
+
127
+ echo "=== UNCOMMITTED CHANGES ==="
128
+ if [ -n "$DIFF_STAT" ]; then
129
+ echo "Tracked changes (git diff HEAD --stat):"
130
+ echo "$DIFF_STAT"
131
+ echo ""
132
+ # Include actual diff content for risk assessment
133
+ DIFF_CONTENT=$(git diff HEAD -- . \
134
+ ':(exclude)package-lock.json' ':(exclude)yarn.lock' \
135
+ ':(exclude)pnpm-lock.yaml' ':(exclude)bun.lockb' \
136
+ ':(exclude)Gemfile.lock' ':(exclude)Pipfile.lock' \
137
+ ':(exclude)poetry.lock' ':(exclude)composer.lock' \
138
+ ':(exclude)Cargo.lock' ':(exclude)go.sum' \
139
+ ':(exclude)shrinkwrap.json' 2>/dev/null || echo "")
140
+ if [ -n "$DIFF_CONTENT" ]; then
141
+ echo "Diff content:"
142
+ echo "$DIFF_CONTENT"
143
+ echo ""
144
+ fi
145
+ fi
146
+ if [ -n "$UNTRACKED" ]; then
147
+ UNTRACKED_COUNT=$(echo "$UNTRACKED" | wc -l | tr -d ' ')
148
+ echo "Untracked files (${UNTRACKED_COUNT}):"
149
+ echo "$UNTRACKED"
150
+ echo ""
151
+ fi
152
+ if [ -z "$DIFF_NAMES" ] && [ -z "$UNTRACKED" ]; then
153
+ echo "No uncommitted changes."
154
+ echo ""
155
+ else
156
+ ALL_FILES=""
157
+ if [ -n "$DIFF_NAMES" ]; then
158
+ ALL_FILES="$DIFF_NAMES"
159
+ fi
160
+ if [ -n "$UNTRACKED" ]; then
161
+ if [ -n "$ALL_FILES" ]; then
162
+ ALL_FILES="${ALL_FILES}"$'\n'"${UNTRACKED}"
163
+ else
164
+ ALL_FILES="$UNTRACKED"
165
+ fi
166
+ fi
167
+ echo "Categories:"
168
+ echo "$ALL_FILES" | categorise_files
169
+ echo ""
170
+ fi
171
+ fi
172
+
173
+ # --- Detect default remote branch ---
174
+ DEFAULT_BRANCH=""
175
+ if git rev-parse --verify origin/main >/dev/null 2>&1; then
176
+ DEFAULT_BRANCH="origin/main"
177
+ elif git rev-parse --verify origin/master >/dev/null 2>&1; then
178
+ DEFAULT_BRANCH="origin/master"
179
+ fi
180
+
181
+ # --- Section: Unpushed changes ---
182
+ if [ "$SHOW_UNPUSHED" = true ]; then
183
+ echo "=== UNPUSHED CHANGES ==="
184
+
185
+ if [ -n "$DEFAULT_BRANCH" ]; then
186
+ UNPUSHED_LOG=$(git log --oneline $DEFAULT_BRANCH..HEAD 2>/dev/null || echo "")
187
+ UNPUSHED_STAT=$(git diff --stat $DEFAULT_BRANCH..HEAD -- . \
188
+ ':(exclude)package-lock.json' ':(exclude)yarn.lock' \
189
+ ':(exclude)pnpm-lock.yaml' ':(exclude)bun.lockb' \
190
+ ':(exclude)Gemfile.lock' ':(exclude)Pipfile.lock' \
191
+ ':(exclude)poetry.lock' ':(exclude)composer.lock' \
192
+ ':(exclude)Cargo.lock' ':(exclude)go.sum' \
193
+ ':(exclude)shrinkwrap.json' 2>/dev/null || echo "")
194
+ UNPUSHED_NAMES=$(git diff --name-only $DEFAULT_BRANCH..HEAD 2>/dev/null | grep -vE "$NOISE" || echo "")
195
+ UNPUSHED_COUNT=$(git rev-list --count $DEFAULT_BRANCH..HEAD 2>/dev/null || echo "0")
196
+
197
+ if [ "$UNPUSHED_COUNT" -eq 0 ]; then
198
+ echo "No unpushed commits."
199
+ else
200
+ echo "Unpushed commits (${UNPUSHED_COUNT}):"
201
+ echo "$UNPUSHED_LOG"
202
+ echo ""
203
+ if [ -n "$UNPUSHED_STAT" ]; then
204
+ echo "Accumulated unpushed diff:"
205
+ echo "$UNPUSHED_STAT"
206
+ echo ""
207
+ UNPUSHED_DIFF=$(git diff $DEFAULT_BRANCH..HEAD -- . \
208
+ ':(exclude)package-lock.json' ':(exclude)yarn.lock' \
209
+ ':(exclude)pnpm-lock.yaml' ':(exclude)bun.lockb' \
210
+ ':(exclude)Gemfile.lock' ':(exclude)Pipfile.lock' \
211
+ ':(exclude)poetry.lock' ':(exclude)composer.lock' \
212
+ ':(exclude)Cargo.lock' ':(exclude)go.sum' \
213
+ ':(exclude)shrinkwrap.json' 2>/dev/null || echo "")
214
+ if [ -n "$UNPUSHED_DIFF" ]; then
215
+ echo "Unpushed diff content:"
216
+ echo "$UNPUSHED_DIFF"
217
+ echo ""
218
+ fi
219
+ fi
220
+ if [ -n "$UNPUSHED_NAMES" ]; then
221
+ echo "Unpushed categories:"
222
+ echo "$UNPUSHED_NAMES" | categorise_files
223
+ fi
224
+ fi
225
+ else
226
+ echo "No remote tracking branch (origin/main and origin/master not found)."
227
+ fi
228
+ echo ""
229
+ fi
230
+
231
+ # --- Section: Unreleased changes ---
232
+ if [ "$SHOW_UNRELEASED" = true ]; then
233
+ echo "=== UNRELEASED CHANGES ==="
234
+
235
+ # Count pending changesets
236
+ CHANGESET_COUNT=0
237
+ if [ -d ".changeset" ]; then
238
+ CHANGESET_COUNT=$(find .changeset -name '*.md' -not -name 'README.md' 2>/dev/null | wc -l | tr -d ' ')
239
+ fi
240
+
241
+ # Check if origin/publish exists
242
+ if git rev-parse --verify origin/publish >/dev/null 2>&1; then
243
+ UNRELEASED_STAT=$(git diff --stat origin/publish..$DEFAULT_BRANCH -- . \
244
+ ':(exclude)package-lock.json' ':(exclude)yarn.lock' \
245
+ ':(exclude)pnpm-lock.yaml' ':(exclude)bun.lockb' \
246
+ ':(exclude)Gemfile.lock' ':(exclude)Pipfile.lock' \
247
+ ':(exclude)poetry.lock' ':(exclude)composer.lock' \
248
+ ':(exclude)Cargo.lock' ':(exclude)go.sum' \
249
+ ':(exclude)shrinkwrap.json' 2>/dev/null || echo "")
250
+ UNRELEASED_NAMES=$(git diff --name-only origin/publish..$DEFAULT_BRANCH 2>/dev/null | grep -vE "$NOISE" || echo "")
251
+
252
+ if [ -z "$UNRELEASED_STAT" ] && [ "$CHANGESET_COUNT" -eq 0 ]; then
253
+ echo "No unreleased changes."
254
+ else
255
+ if [ "$CHANGESET_COUNT" -gt 0 ]; then
256
+ echo "Pending changesets: ${CHANGESET_COUNT}"
257
+ fi
258
+ if [ -n "$UNRELEASED_STAT" ]; then
259
+ echo "Accumulated unreleased diff (origin/publish..$DEFAULT_BRANCH):"
260
+ echo "$UNRELEASED_STAT"
261
+ echo ""
262
+ UNRELEASED_DIFF=$(git diff origin/publish..$DEFAULT_BRANCH -- . \
263
+ ':(exclude)package-lock.json' ':(exclude)yarn.lock' \
264
+ ':(exclude)pnpm-lock.yaml' ':(exclude)bun.lockb' \
265
+ ':(exclude)Gemfile.lock' ':(exclude)Pipfile.lock' \
266
+ ':(exclude)poetry.lock' ':(exclude)composer.lock' \
267
+ ':(exclude)Cargo.lock' ':(exclude)go.sum' \
268
+ ':(exclude)shrinkwrap.json' 2>/dev/null || echo "")
269
+ if [ -n "$UNRELEASED_DIFF" ]; then
270
+ echo "Unreleased diff content:"
271
+ echo "$UNRELEASED_DIFF"
272
+ echo ""
273
+ fi
274
+ fi
275
+ if [ -n "$UNRELEASED_NAMES" ]; then
276
+ echo "Unreleased categories:"
277
+ echo "$UNRELEASED_NAMES" | categorise_files
278
+ fi
279
+ fi
280
+ else
281
+ echo "No publish branch (origin/publish not found)."
282
+ if [ "$CHANGESET_COUNT" -gt 0 ]; then
283
+ echo "Pending changesets: ${CHANGESET_COUNT}"
284
+ fi
285
+ fi
286
+ echo ""
287
+ fi
288
+
289
+ # --- Section: Stale files ---
290
+ if [ "$SHOW_STALE" = true ]; then
291
+ echo "=== STALE FILES ==="
292
+
293
+ STALE_FILES=$(git diff --name-only HEAD 2>/dev/null | python3 -c "
294
+ import sys, os, time
295
+ threshold = time.time() - 86400
296
+ stale = []
297
+ for line in sys.stdin:
298
+ f = line.strip()
299
+ if not f or not os.path.isfile(f):
300
+ continue
301
+ try:
302
+ if os.path.getmtime(f) < threshold:
303
+ stale.append(f)
304
+ except OSError:
305
+ pass
306
+ if stale:
307
+ print('\n'.join(stale))
308
+ " 2>/dev/null || echo "")
309
+
310
+ if [ -n "$STALE_FILES" ]; then
311
+ STALE_COUNT=$(echo "$STALE_FILES" | wc -l | tr -d ' ')
312
+ echo "Modified files uncommitted for over 24h (${STALE_COUNT}):"
313
+ echo "$STALE_FILES"
314
+ else
315
+ echo "No stale files."
316
+ fi
317
+ echo ""
318
+ fi
@@ -0,0 +1,85 @@
1
+ #!/bin/bash
2
+ # Shared gate logic for risk scoring enforcement hooks.
3
+ # Sourced by risk-score-commit-gate.sh, git-push-gate.sh, risk-score-plan-enforce.sh.
4
+ # Provides: check_risk_gate, risk_gate_deny
5
+
6
+ # Source shared portable helpers (_mtime, _hashcmd)
7
+ _RISK_GATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ source "$_RISK_GATE_DIR/gate-helpers.sh"
9
+
10
+ # Check risk gate for a given action. Returns 0 if allowed, 1 if denied.
11
+ # Sets RISK_GATE_REASON on failure with human-readable message.
12
+ # Usage: check_risk_gate "$SESSION_ID" "commit"
13
+ check_risk_gate() {
14
+ local SESSION_ID="$1"
15
+ local ACTION="$2"
16
+ local RDIR
17
+ RDIR=$(_risk_dir "$SESSION_ID")
18
+ local SCORE_FILE="${RDIR}/${ACTION}"
19
+ local HASH_FILE="${RDIR}/state-hash"
20
+ local TTL_SECONDS="${RISK_TTL:-1800}"
21
+
22
+ # 1. Score file must exist (fail-closed)
23
+ if [ ! -f "$SCORE_FILE" ]; then
24
+ RISK_GATE_REASON="No ${ACTION} risk score found. The risk-scorer agent must run first. It runs automatically on each prompt."
25
+ return 1
26
+ fi
27
+
28
+ # 2. TTL check — score file mtime must be within TTL
29
+ local NOW=$(date +%s)
30
+ local SCORE_TIME=$(_mtime "$SCORE_FILE")
31
+ local AGE=$(( NOW - SCORE_TIME ))
32
+ if [ "$AGE" -ge "$TTL_SECONDS" ]; then
33
+ RISK_GATE_REASON="Risk score expired (${AGE}s old, TTL ${TTL_SECONDS}s). Stage all files with git add first, then submit a new prompt — the scorer runs automatically. Then call git commit in that response."
34
+ return 1
35
+ fi
36
+
37
+ # 3. Drift detection — pipeline state hash must match
38
+ # The hash is computed from git diff HEAD --stat at prompt submit time.
39
+ # If you staged files AFTER the prompt, the hash will differ.
40
+ # Fix: stage everything BEFORE submitting the prompt, then commit in the response.
41
+ if [ -f "$HASH_FILE" ]; then
42
+ local STORED_HASH=$(cat "$HASH_FILE")
43
+ local CURRENT_HASH
44
+ CURRENT_HASH=$("$_RISK_GATE_DIR/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
45
+ if [ "$STORED_HASH" != "$CURRENT_HASH" ]; then
46
+ RISK_GATE_REASON="Pipeline state drift: git diff changed between scoring and ${ACTION}. The hash is computed at prompt submit time. If you staged files (git add) after the prompt, re-submit: stage all files first, then submit a new prompt, then commit in that response."
47
+ return 1
48
+ fi
49
+ fi
50
+ # No hash file = backward compat, skip drift check
51
+
52
+ # 4. Read and validate score
53
+ local SCORE=$(cat "$SCORE_FILE" 2>/dev/null || echo "")
54
+ if ! echo "$SCORE" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
55
+ RISK_GATE_REASON="Risk score file contains an invalid value. Re-run the risk-scorer agent."
56
+ return 1
57
+ fi
58
+
59
+ # 5. Threshold check
60
+ local DENIED=$(python3 -c "
61
+ score = float('$SCORE')
62
+ print('yes' if score >= 5 else 'no')
63
+ " 2>/dev/null || echo "no")
64
+
65
+ if [ "$DENIED" = "yes" ]; then
66
+ RISK_GATE_REASON="${ACTION} risk score ${SCORE}/25 (Medium or above). Reduce changes or address outstanding risk first, then re-run the risk-scorer agent."
67
+ return 1
68
+ fi
69
+
70
+ return 0
71
+ }
72
+
73
+ # Emit fail-closed deny JSON for PreToolUse hooks.
74
+ risk_gate_deny() {
75
+ local REASON="$1"
76
+ cat <<EOF
77
+ {
78
+ "hookSpecificOutput": {
79
+ "hookEventName": "PreToolUse",
80
+ "permissionDecision": "deny",
81
+ "permissionDecisionReason": "$REASON"
82
+ }
83
+ }
84
+ EOF
85
+ }
@@ -0,0 +1,52 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: Fires on EnterPlanMode to inject release risk context.
3
+ # Provides preemptive guidance so the plan author knows the unreleased queue
4
+ # state and release risk before writing the plan.
5
+
6
+ set -euo pipefail
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
+ source "$SCRIPT_DIR/lib/gate-helpers.sh"
10
+ _enable_err_trap
11
+
12
+ _parse_input
13
+
14
+ SESSION_ID=$(_get_session_id)
15
+
16
+ # --- Gather pipeline state summary ---
17
+ UNRELEASED_SUMMARY=""
18
+ if [ -x "$SCRIPT_DIR/lib/pipeline-state.sh" ]; then
19
+ UNRELEASED_SUMMARY=$("$SCRIPT_DIR/lib/pipeline-state.sh" --unreleased 2>/dev/null | head -20 || echo "Unable to determine unreleased changes.")
20
+ fi
21
+
22
+ # --- Check for existing release risk score ---
23
+ RELEASE_SCORE="not yet scored"
24
+ RDIR=$(_risk_dir "$SESSION_ID")
25
+ RELEASE_SCORE_FILE="${RDIR}/release"
26
+ if [ -n "$SESSION_ID" ] && [ -f "$RELEASE_SCORE_FILE" ]; then
27
+ SCORE_VAL=$(cat "$RELEASE_SCORE_FILE" 2>/dev/null || echo "")
28
+ if [[ "$SCORE_VAL" =~ ^[0-9]+$ ]]; then
29
+ RELEASE_SCORE="${SCORE_VAL}/25"
30
+ fi
31
+ fi
32
+
33
+ # --- Read appetite from RISK-POLICY.md ---
34
+ APPETITE="5"
35
+ if [ -f "RISK-POLICY.md" ]; then
36
+ EXTRACTED=$(grep -oP 'Threshold:\s*\K[0-9]+' RISK-POLICY.md 2>/dev/null | head -1 || echo "")
37
+ if [ -n "$EXTRACTED" ]; then
38
+ APPETITE="$EXTRACTED"
39
+ fi
40
+ fi
41
+
42
+ # --- Emit guidance (allow — advisory only, not a gate) ---
43
+ cat <<EOF
44
+ {
45
+ "hookSpecificOutput": {
46
+ "hookEventName": "PreToolUse",
47
+ "permissionDecision": "allow",
48
+ "systemMessage": "RELEASE RISK GUIDANCE FOR PLANNING:\nThe unreleased queue currently contains:\n${UNRELEASED_SUMMARY}\n\nCurrent release risk score: ${RELEASE_SCORE}.\nRisk appetite threshold: ${APPETITE} (Medium).\n\nYour plan MUST account for projected release risk. If the plan's proposed changes would push projected release risk above appetite when combined with the existing unreleased queue, the plan MUST include one or more of:\n- Release the current unreleased queue first (before implementing the plan)\n- Split the plan into smaller batches that keep projected release risk within appetite\n- Include specific risk-reducing steps (additional tests, rollback procedures)\n\nThe risk-scorer will assess projected release risk at ExitPlanMode and FAIL plans that exceed appetite without a release strategy."
49
+ }
50
+ }
51
+ EOF
52
+ exit 0
@@ -0,0 +1,28 @@
1
+ #!/bin/bash
2
+ # PostToolUse:Bash hook — refreshes the pipeline state hash after git add.
3
+ # Eliminates the "stage before prompt" protocol: when files are staged within
4
+ # a prompt, the hash stays current so the commit gate doesn't detect false drift.
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
+ source "$SCRIPT_DIR/lib/gate-helpers.sh"
8
+
9
+ _parse_input
10
+
11
+ COMMAND=$(_get_command)
12
+
13
+ # Only act on commands that change git state
14
+ echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*git (add|commit|stash|reset|checkout|restore)' || exit 0
15
+
16
+ SESSION_ID=$(_get_session_id)
17
+ [ -n "$SESSION_ID" ] || exit 0
18
+
19
+ RDIR=$(_risk_dir "$SESSION_ID")
20
+ HASH_FILE="${RDIR}/state-hash"
21
+ [ -f "$HASH_FILE" ] || exit 0 # No hash file yet — scorer hasn't run
22
+
23
+ CURRENT_HASH=$("$SCRIPT_DIR/lib/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
24
+ if [ -n "$CURRENT_HASH" ]; then
25
+ echo "$CURRENT_HASH" > "$HASH_FILE"
26
+ fi
27
+
28
+ exit 0