@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.
- package/.claude-plugin/plugin.json +5 -0
- package/agents/agent.md +21 -0
- package/agents/pipeline.md +152 -0
- package/agents/plan.md +85 -0
- package/agents/policy.md +43 -0
- package/agents/wip.md +79 -0
- package/bin/install.mjs +42 -0
- package/hooks/git-push-gate.sh +117 -0
- package/hooks/hooks.json +24 -0
- package/hooks/lib/gate-helpers.sh +174 -0
- package/hooks/lib/pipeline-state.sh +318 -0
- package/hooks/lib/risk-gate.sh +85 -0
- package/hooks/plan-risk-guidance.sh +52 -0
- package/hooks/risk-hash-refresh.sh +28 -0
- package/hooks/risk-policy-enforce-edit.sh +42 -0
- package/hooks/risk-policy-reset-marker.sh +17 -0
- package/hooks/risk-score-commit-gate.sh +64 -0
- package/hooks/risk-score-mark.sh +120 -0
- package/hooks/risk-score-plan-enforce.sh +31 -0
- package/hooks/risk-score-reset.sh +17 -0
- package/hooks/risk-score.sh +29 -0
- package/hooks/secret-leak-gate.sh +72 -0
- package/hooks/test/risk-gate.bats +107 -0
- package/hooks/wip-risk-gate.sh +44 -0
- package/hooks/wip-risk-mark.sh +32 -0
- package/package.json +28 -0
- package/skills/wr:risk-policy/SKILL.md +178 -0
|
@@ -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
|