@windyroad/risk-scorer 0.2.0 → 0.2.1-preview.70

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/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @windyroad/risk-scorer
2
+
3
+ **Pipeline risk scoring, commit/push gates, and secret leak detection for Claude Code.** Scores every change for risk and blocks high-risk commits and pushes before they happen.
4
+
5
+ Part of [Windy Road Agent Plugins](../../README.md).
6
+
7
+ ## What It Does
8
+
9
+ The risk-scorer plugin brings ISO 31000-aligned risk management to your AI coding workflow. It:
10
+
11
+ 1. **Scores risk** on every edit, assessing cumulative pipeline risk as changes build up
12
+ 2. **Gates commits** -- blocks `git commit` when cumulative risk exceeds your policy threshold
13
+ 3. **Gates pushes** -- blocks `git push` for high-risk changesets (use `npm run push:watch` instead)
14
+ 4. **Detects secrets** -- scans edits for API keys, tokens, passwords, and other credentials before they're written
15
+ 5. **Reviews plans** -- scores implementation plans for risk before you start building
16
+
17
+ All thresholds are configurable through your project's `RISK-POLICY.md`.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npx @windyroad/risk-scorer
23
+ ```
24
+
25
+ Restart Claude Code after installing.
26
+
27
+ ## Usage
28
+
29
+ The plugin works automatically once installed. On first run in a project without a risk policy, it blocks edits and directs you to generate one:
30
+
31
+ ```
32
+ /wr-risk-scorer:update-policy
33
+ ```
34
+
35
+ This creates a `RISK-POLICY.md` tailored to your project, defining impact levels, likelihood scales, risk appetite, and the risk matrix -- all aligned to ISO 31000.
36
+
37
+ ## How It Works
38
+
39
+ | Hook | Trigger | What it does |
40
+ |------|---------|-------------|
41
+ | `risk-score.sh` | Every prompt | Injects risk scoring context |
42
+ | `secret-leak-gate.sh` | Edit or Write | Blocks writes containing secrets |
43
+ | `wip-risk-gate.sh` | Edit or Write | Blocks edits if WIP risk hasn't been assessed |
44
+ | `risk-policy-enforce-edit.sh` | Edit or Write | Blocks edits if no `RISK-POLICY.md` exists |
45
+ | `git-push-gate.sh` | Bash (git push) | Blocks direct `git push`; requires `npm run push:watch` |
46
+ | `risk-score-commit-gate.sh` | Bash (git commit) | Blocks commits when risk exceeds threshold |
47
+ | `risk-score-plan-enforce.sh` | ExitPlanMode | Ensures plans are risk-scored before execution |
48
+ | `plan-risk-guidance.sh` | EnterPlanMode | Injects risk guidance into plan mode |
49
+ | `wip-risk-mark.sh` | After edit | Records WIP risk assessment |
50
+ | `risk-score-mark.sh` | Agent completes | Marks risk review as done |
51
+ | `risk-hash-refresh.sh` | After Bash | Refreshes content hashes |
52
+
53
+ ## Agents
54
+
55
+ The plugin includes five specialised agents:
56
+
57
+ | Agent | Purpose |
58
+ |-------|---------|
59
+ | `wr-risk-scorer:agent` | Routes to the appropriate mode-specific agent |
60
+ | `wr-risk-scorer:wip` | Assesses cumulative risk after each edit |
61
+ | `wr-risk-scorer:pipeline` | Scores pipeline actions (commit, push, release) |
62
+ | `wr-risk-scorer:plan` | Reviews implementation plans for risk |
63
+ | `wr-risk-scorer:policy` | Validates `RISK-POLICY.md` for ISO 31000 compliance |
64
+
65
+ ## Updating and Uninstalling
66
+
67
+ ```bash
68
+ npx @windyroad/risk-scorer --update
69
+ npx @windyroad/risk-scorer --uninstall
70
+ ```
71
+
72
+ ## Licence
73
+
74
+ [MIT](../../LICENSE)
@@ -98,18 +98,35 @@ Assess whether each action is risk-reducing, risk-neutral, or risk-increasing. I
98
98
 
99
99
  For live incidents (outage, security, information disclosure), include `RISK_BYPASS: incident`.
100
100
 
101
- ## Downstream Back-Pressure
101
+ ## Below-Appetite Output Rule (ADR-013 Rule 5)
102
102
 
103
- - **Commit**: If adding this commit would push the push queue risk >= 5, warn.
104
- - **Push**: If pushing would push the release queue risk >= 5, warn.
103
+ When ALL cumulative scores are within appetite (≤ 4 per RISK-POLICY.md), your output MUST contain ONLY:
104
+ 1. The Pipeline Risk Report structure (layers, risk items, summary table)
105
+ 2. `RISK_SCORES: commit=N push=N release=N`
106
+ 3. `RISK_BYPASS: reducing` (if applicable)
105
107
 
106
- ## Confidential Information Disclosure
108
+ Do NOT emit: "Suggested Actions", "Your call:", advisory warnings, back-pressure notes, or any prose that implies the user needs to make a decision. Policy-authorised releases proceed silently.
107
109
 
108
- Check diffs for business metrics (revenue, user counts, pricing, traffic volumes). Flag as a standalone risk if found.
110
+ ## Above-Appetite Remediations
111
+
112
+ When ANY cumulative score exceeds appetite (> 4), emit a structured `RISK_REMEDIATIONS:` block after the `RISK_SCORES:` line. This gives the calling skill machine-readable input for structured decision prompts.
113
+
114
+ Format:
115
+ ```
116
+ RISK_REMEDIATIONS:
117
+ - R1 | <description of remediation> | <files affected>
118
+ - R2 | <description of remediation> | <files affected>
119
+ ```
120
+
121
+ Include downstream back-pressure in the remediation list:
122
+ - **Commit**: If adding this commit would push the push queue risk >= 5, include a remediation to split the commit.
123
+ - **Push**: If pushing would push the release queue risk >= 5, include a remediation to release first.
109
124
 
110
- ## Suggested Actions
125
+ Do NOT emit free-text "Your call:" or "consider splitting" prose. The structured `RISK_REMEDIATIONS:` block is the only output for above-appetite guidance.
111
126
 
112
- If any cumulative risk >= 5, suggest specific actions referencing which layer is driving the risk.
127
+ ## Confidential Information Disclosure
128
+
129
+ Check diffs for business metrics (revenue, user counts, pricing, traffic volumes). Flag as a standalone risk if found.
113
130
 
114
131
  ## Report History
115
132
 
package/agents/plan.md CHANGED
@@ -22,8 +22,8 @@ You are the Risk Scorer in plan review mode. Assess both the plan's own risk AND
22
22
 
23
23
  ## Verdict Logic
24
24
 
25
- - **PASS** if both the plan's own residual risk AND projected release risk are within appetite
26
- - **FAIL** if either exceeds appetite — explain which and what the plan should include
25
+ - **PASS** if both the plan's own residual risk AND projected release risk are within appetite. Do NOT emit advisory prose, suggestions, or "consider" recommendations on PASS — the plan is policy-authorised (ADR-013 Rule 5).
26
+ - **FAIL** if either exceeds appetite — emit a structured `RISK_REMEDIATIONS:` block (see below) explaining which dimension failed and what the plan should include.
27
27
 
28
28
  ## Output Format
29
29
 
@@ -49,6 +49,14 @@ You are the Risk Scorer in plan review mode. Assess both the plan's own risk AND
49
49
 
50
50
  End your report with `RISK_VERDICT: PASS` or `RISK_VERDICT: FAIL` on its own line. A PostToolUse hook reads this and writes the marker files — do NOT write files yourself.
51
51
 
52
+ On FAIL, emit a structured `RISK_REMEDIATIONS:` block after the verdict:
53
+ ```
54
+ RISK_REMEDIATIONS:
55
+ - R1 | <description of what the plan must add/change> | <affected area>
56
+ ```
57
+
58
+ Do NOT emit free-text "consider" or "you should" prose. The structured block is the only output for above-appetite guidance.
59
+
52
60
  ## Control Discovery
53
61
 
54
62
  For each control claimed to reduce risk:
package/agents/wip.md CHANGED
@@ -45,15 +45,25 @@ Always provide the cumulative risk picture:
45
45
  - [specific guidance based on current pipeline state]
46
46
  ```
47
47
 
48
- If cumulative risk is **within appetite** (< 5): provide the assessment and say "Continue." The verdict is CONTINUE.
48
+ ### Below-Appetite Rule (ADR-013 Rule 5)
49
49
 
50
- If cumulative risk **exceeds appetite** (>= 5): provide specific risk-reducing suggestions:
51
- - "Commit your current changes to move WIP forward"
52
- - "Write tests for [risk item from report]" — name the specific risk and test file
53
- - "The release report flags [X] — address it before adding more changes"
54
- - "Push your commits to get CI feedback"
50
+ If cumulative risk is **within appetite** (< 5): provide the assessment table and verdict only. Do NOT emit advisory prose, recommendations, or suggestions. The verdict is `RISK_VERDICT: CONTINUE`.
55
51
 
56
- The verdict is PAUSE. This blocks the next edit until the risk is addressed.
52
+ ### Above-Appetite Remediations
53
+
54
+ If cumulative risk **exceeds appetite** (>= 5): provide the assessment table, then emit a structured `RISK_REMEDIATIONS:` block with specific risk-reducing actions:
55
+
56
+ ```
57
+ RISK_REMEDIATIONS:
58
+ - R1 | Commit current changes to move WIP forward | <uncommitted files>
59
+ - R2 | Write tests for <risk item from report> | <test file to create/extend>
60
+ - R3 | Address release report risk <X> before adding more changes | <affected files>
61
+ - R4 | Push commits to get CI feedback | N/A
62
+ ```
63
+
64
+ Do NOT emit free-text suggestions as prose. The structured block is the only output for above-appetite guidance.
65
+
66
+ The verdict is `RISK_VERDICT: PAUSE`. This blocks the next edit until the risk is addressed.
57
67
 
58
68
  ## Control Discovery
59
69
 
package/bin/install.mjs CHANGED
@@ -4,7 +4,7 @@ import { resolve, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
- const utils = await import(resolve(__dirname, "../../shared/install-utils.mjs"));
7
+ const utils = await import(resolve(__dirname, "../lib/install-utils.mjs"));
8
8
 
9
9
  const PLUGIN = "wr-risk-scorer";
10
10
  const DEPS = [];
@@ -20,6 +20,7 @@ Pipeline risk scoring, commit/push gates, and secret leak detection
20
20
  Options:
21
21
  --update Update this plugin and its skills
22
22
  --uninstall Remove this plugin
23
+ --scope Installation scope: project (default) or user
23
24
  --dry-run Show what would be done without executing
24
25
  --help, -h Show this help
25
26
  `);
@@ -38,5 +39,5 @@ if (flags.uninstall) {
38
39
  } else if (flags.update) {
39
40
  utils.updatePackage(PLUGIN);
40
41
  } else {
41
- utils.installPackage(PLUGIN, { deps: DEPS });
42
+ utils.installPackage(PLUGIN, { deps: DEPS, scope: flags.scope });
42
43
  }
@@ -47,7 +47,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm run push:watch(\s|$)'; then
47
47
  fi
48
48
  PUSH_SCORE_FILE="${RDIR}/push"
49
49
  if [ ! -f "$PUSH_SCORE_FILE" ]; then
50
- risk_gate_deny "Push blocked: No push risk score found. Delegate to risk-scorer-pipeline (subagent_type: 'risk-scorer-pipeline') to assess cumulative pipeline risk."
50
+ risk_gate_deny "Push blocked: No push risk score found. Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to assess cumulative pipeline risk."
51
51
  exit 0
52
52
  fi
53
53
  PUSH_NOW=$(date +%s)
@@ -65,19 +65,26 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm run push:watch(\s|$)'; then
65
65
  fi
66
66
  PUSH_DENIED=$(python3 -c "print('yes' if float('${PUSH_SCORE}') >= 5 else 'no')" 2>/dev/null || echo "no")
67
67
  if [ "$PUSH_DENIED" = "yes" ]; then
68
- risk_gate_deny "Push blocked: Push risk score ${PUSH_SCORE}/25 (Medium or above). To proceed: (1) release first via \`npm run release:watch\`, (2) split the push, or (3) add risk-reducing measures. If risk-neutral or risk-reducing, delegate to risk-scorer-pipeline (subagent_type: 'risk-scorer-pipeline') — it will create a bypass marker."
68
+ risk_gate_deny "Push blocked: Push risk score ${PUSH_SCORE}/25 (Medium or above). To proceed: (1) release first via \`npm run release:watch\`, (2) split the push, or (3) add risk-reducing measures. If risk-neutral or risk-reducing, delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') — it will create a bypass marker."
69
69
  exit 0
70
70
  fi
71
71
  fi
72
72
  exit 0
73
73
  fi
74
74
 
75
+ # Block `changeset version` — versioning is done by the release pipeline,
76
+ # not locally. Creating changesets (`npx changeset`) is fine.
77
+ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*(npx changeset|npm run changeset)\s+version(\s|$)'; then
78
+ risk_gate_deny "Do not run \`changeset version\` locally. The release pipeline handles versioning automatically. To release: (1) push your changes with \`npm run push:watch\`, (2) the pipeline creates a release PR via changesets, (3) merge the release PR to publish. If you need to create a changeset, use \`npx changeset\` (without \`version\`)."
79
+ exit 0
80
+ fi
81
+
75
82
  # Gate changeset creation on release risk score (fail-closed).
76
83
  # Changesets feed directly into releases, so gate on the release score.
77
84
  if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*(npx changeset|npm run changeset)(\s|$)'; then
78
85
  if [ -n "$SESSION_ID" ]; then
79
86
  if ! check_risk_gate "$SESSION_ID" "release"; then
80
- risk_gate_deny "Changeset blocked: ${RISK_GATE_REASON}"
87
+ risk_gate_deny "Changeset blocked: ${RISK_GATE_REASON}. To create a changeset, the release risk score must be within appetite. Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to assess."
81
88
  exit 0
82
89
  fi
83
90
  fi
@@ -101,7 +108,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm run release:watch(\s|$)'; the
101
108
  exit 0
102
109
  fi
103
110
  if ! check_risk_gate "$SESSION_ID" "release"; then
104
- risk_gate_deny "Release blocked: ${RISK_GATE_REASON}. To proceed: (1) split the release, (2) add risk-reducing measures, or (3) for a LIVE INCIDENT, delegate to risk-scorer-pipeline (subagent_type: 'risk-scorer-pipeline') with incident context for an incident bypass."
111
+ risk_gate_deny "Release blocked: ${RISK_GATE_REASON}"
105
112
  exit 0
106
113
  fi
107
114
  fi
@@ -110,7 +117,16 @@ fi
110
117
 
111
118
  # Match gh pr merge. Should go via npm run release:watch instead.
112
119
  if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr merge(\s|$)'; then
113
- risk_gate_deny "Use \`npm run release:watch\` instead of \`gh pr merge\`. It merges the release PR, watches the publish pipeline, and surfaces the production URL when live -- or tells you what failed and how to fix it."
120
+ # Check if the project has a release:watch script
121
+ if [ -f "package.json" ] && python3 -c "
122
+ import json, sys
123
+ pkg = json.load(open('package.json'))
124
+ sys.exit(0 if 'release:watch' in pkg.get('scripts', {}) else 1)
125
+ " 2>/dev/null; then
126
+ risk_gate_deny "Use \`npm run release:watch\` instead of \`gh pr merge\`. It merges the release PR, watches the publish pipeline, and surfaces the production URL when live -- or tells you what failed and how to fix it."
127
+ else
128
+ risk_gate_deny "Direct \`gh pr merge\` is blocked (no release:watch script found). Create a release:watch npm script that: (1) finds and merges the release PR with \`gh pr merge\`, (2) waits for the CI workflow with \`gh run list\`, and (3) watches it with \`gh run watch --exit-status\`. Then run \`npm run release:watch\` to release."
129
+ fi
114
130
  exit 0
115
131
  fi
116
132
 
package/hooks/hooks.json CHANGED
@@ -15,10 +15,6 @@
15
15
  { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/wip-risk-mark.sh" }] },
16
16
  { "matcher": "Agent", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-score-mark.sh" }] },
17
17
  { "matcher": "Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-hash-refresh.sh" }] }
18
- ],
19
- "Stop": [
20
- { "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-score-reset.sh" }] },
21
- { "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/risk-policy-reset-marker.sh" }] }
22
18
  ]
23
19
  }
24
20
  }
@@ -21,7 +21,7 @@ check_risk_gate() {
21
21
 
22
22
  # 1. Score file must exist (fail-closed)
23
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."
24
+ RISK_GATE_REASON="No ${ACTION} risk score found. Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to assess cumulative pipeline risk."
25
25
  return 1
26
26
  fi
27
27
 
@@ -30,7 +30,7 @@ check_risk_gate() {
30
30
  local SCORE_TIME=$(_mtime "$SCORE_FILE")
31
31
  local AGE=$(( NOW - SCORE_TIME ))
32
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."
33
+ RISK_GATE_REASON="Risk score expired (${AGE}s old, TTL ${TTL_SECONDS}s). Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to rescore."
34
34
  return 1
35
35
  fi
36
36
 
@@ -43,7 +43,7 @@ check_risk_gate() {
43
43
  local CURRENT_HASH
44
44
  CURRENT_HASH=$("$_RISK_GATE_DIR/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
45
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."
46
+ RISK_GATE_REASON="Pipeline state drift: working tree changed since the last ${ACTION} risk assessment. Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to rescore against the current state."
47
47
  return 1
48
48
  fi
49
49
  fi
@@ -63,7 +63,7 @@ print('yes' if score >= 5 else 'no')
63
63
  " 2>/dev/null || echo "no")
64
64
 
65
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."
66
+ RISK_GATE_REASON="${ACTION} risk score ${SCORE}/25 (Medium or above). To proceed: (1) split the ${ACTION}, (2) add risk-reducing measures, or (3) for a LIVE INCIDENT, delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') with incident context for an incident bypass."
67
67
  return 1
68
68
  fi
69
69
 
@@ -18,6 +18,16 @@ if [ -z "$SESSION_ID" ] || [ -z "$FILE_PATH" ]; then
18
18
  exit 0
19
19
  fi
20
20
 
21
+ # P004: Only gate files inside the project root.
22
+ case "$FILE_PATH" in
23
+ /*)
24
+ case "$FILE_PATH" in
25
+ "$PWD"/*) ;;
26
+ *) exit 0 ;;
27
+ esac
28
+ ;;
29
+ esac
30
+
21
31
  # Only gate RISK-POLICY.md
22
32
  BASENAME=$(basename "$FILE_PATH")
23
33
  if [ "$BASENAME" != "RISK-POLICY.md" ]; then
@@ -35,7 +45,7 @@ cat <<'EOF'
35
45
  "hookSpecificOutput": {
36
46
  "hookEventName": "PreToolUse",
37
47
  "permissionDecision": "deny",
38
- "permissionDecisionReason": "BLOCKED: Cannot edit RISK-POLICY.md directly. Run the /risk-policy skill first -- it enforces ISO 31000 compliance (reads the risk-scorer contract, discovers project context, checks for incidents, validates with you, and smoke-tests the result). Use the Skill tool with skill: \"risk-policy\"."
48
+ "permissionDecisionReason": "BLOCKED: Cannot edit RISK-POLICY.md directly. Run /wr-risk-scorer:update-policy first -- it enforces ISO 31000 compliance (reads the risk-scorer contract, discovers project context, checks for incidents, validates with you, and smoke-tests the result). Use the Skill tool with skill: \"wr-risk-scorer:update-policy\"."
39
49
  }
40
50
  }
41
51
  EOF
@@ -57,7 +57,7 @@ fi
57
57
 
58
58
  # Gate check: existence, TTL, drift, threshold
59
59
  if ! check_risk_gate "$SESSION_ID" "commit"; then
60
- risk_gate_deny "Commit blocked: ${RISK_GATE_REASON} To proceed: (1) stage files with git add, (2) delegate to risk-scorer-pipeline (subagent_type: 'risk-scorer-pipeline') to assess cumulative pipeline risk. If the commit is risk-neutral or risk-reducing, the scorer will create a bypass marker."
60
+ risk_gate_deny "Commit blocked: ${RISK_GATE_REASON} To proceed: (1) stage files with git add, (2) delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to assess cumulative pipeline risk. If the commit is risk-neutral or risk-reducing, the scorer will create a bypass marker."
61
61
  exit 0
62
62
  fi
63
63
 
@@ -4,7 +4,7 @@
4
4
  # risk-scorer agents. This is the ONLY place score files are written —
5
5
  # agents output structured markers, this hook writes the files.
6
6
  #
7
- # Handles: risk-scorer-pipeline, risk-scorer-plan, risk-scorer-wip, risk-scorer-policy
7
+ # Handles: wr-risk-scorer:pipeline, wr-risk-scorer:plan, wr-risk-scorer:wip, wr-risk-scorer:policy
8
8
  # Replaces: risk-policy-mark-reviewed.sh (which had fragile P001 backup parsing)
9
9
 
10
10
  set -euo pipefail
@@ -34,7 +34,7 @@ RDIR=$(_risk_dir "$SESSION_ID")
34
34
  # ---------------------------------------------------------------------------
35
35
  # Pipeline scorer: write commit/push/release scores + bypass markers
36
36
  # ---------------------------------------------------------------------------
37
- if echo "$SUBAGENT" | grep -qE 'risk-scorer-pipeline'; then
37
+ if echo "$SUBAGENT" | grep -qE 'risk-scorer.pipeline'; then
38
38
  # Parse RISK_SCORES: commit=N push=N release=N
39
39
  SCORES_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^RISK_SCORES:' | tail -1) || true
40
40
  if [ -n "$SCORES_LINE" ]; then
@@ -79,7 +79,7 @@ fi
79
79
  # ---------------------------------------------------------------------------
80
80
  # Plan scorer: write plan-reviewed marker on PASS
81
81
  # ---------------------------------------------------------------------------
82
- if echo "$SUBAGENT" | grep -qE 'risk-scorer-plan'; then
82
+ if echo "$SUBAGENT" | grep -qE 'risk-scorer.plan'; then
83
83
  VERDICT_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^RISK_VERDICT:' | tail -1) || true
84
84
  VERDICT=$(echo "$VERDICT_LINE" | sed 's/^RISK_VERDICT:[[:space:]]*//' | tr -d '[:space:]')
85
85
  case "$VERDICT" in
@@ -98,7 +98,7 @@ fi
98
98
  # ---------------------------------------------------------------------------
99
99
  # WIP scorer: write wip-reviewed marker (unblocks next edit)
100
100
  # ---------------------------------------------------------------------------
101
- if echo "$SUBAGENT" | grep -qE 'risk-scorer-wip'; then
101
+ if echo "$SUBAGENT" | grep -qE 'risk-scorer.wip'; then
102
102
  # WIP assessment was done — unblock next edit regardless of CONTINUE/PAUSE
103
103
  # (PAUSE is advisory guidance to the user, not a hard gate)
104
104
  touch "${RDIR}/wip-reviewed"
@@ -107,7 +107,7 @@ fi
107
107
  # ---------------------------------------------------------------------------
108
108
  # Policy scorer: write policy-reviewed marker on PASS
109
109
  # ---------------------------------------------------------------------------
110
- if echo "$SUBAGENT" | grep -qE 'risk-scorer-policy'; then
110
+ if echo "$SUBAGENT" | grep -qE 'risk-scorer.policy'; then
111
111
  VERDICT_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^RISK_VERDICT:' | tail -1) || true
112
112
  VERDICT=$(echo "$VERDICT_LINE" | sed 's/^RISK_VERDICT:[[:space:]]*//' | tr -d '[:space:]')
113
113
  case "$VERDICT" in
@@ -24,7 +24,7 @@ cat <<'EOF'
24
24
  "hookSpecificOutput": {
25
25
  "hookEventName": "PreToolUse",
26
26
  "permissionDecision": "deny",
27
- "permissionDecisionReason": "BLOCKED: Risk-scorer must review the plan before exiting plan mode. Delegate to risk-scorer-plan (subagent_type: 'risk-scorer-plan') to review the plan file for risk, including projected release risk."
27
+ "permissionDecisionReason": "BLOCKED: Risk-scorer must review the plan before exiting plan mode. Delegate to wr-risk-scorer:plan (subagent_type: 'wr-risk-scorer:plan') to review the plan file for risk, including projected release risk."
28
28
  }
29
29
  }
30
30
  EOF
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bats
2
+ # Tests for git-push-gate.sh — gh pr merge block and release:watch guidance
3
+
4
+ setup() {
5
+ HOOKS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
6
+ HOOK="$HOOKS_DIR/git-push-gate.sh"
7
+
8
+ TEST_SESSION="bats-push-gate-$$-${BATS_TEST_NUMBER}"
9
+ # Ensure a clean risk dir
10
+ RDIR="${TMPDIR:-/tmp}/claude-risk-${TEST_SESSION}"
11
+ rm -rf "$RDIR"
12
+ mkdir -p "$RDIR"
13
+
14
+ # Create a temp project dir for package.json detection
15
+ TEST_PROJECT_DIR="$(mktemp -d)"
16
+ }
17
+
18
+ teardown() {
19
+ rm -rf "$RDIR"
20
+ rm -rf "$TEST_PROJECT_DIR"
21
+ }
22
+
23
+ # Helper: build a PreToolUse Bash input with a given command
24
+ build_input() {
25
+ local cmd="$1"
26
+ cat <<ENDJSON
27
+ {
28
+ "session_id": "$TEST_SESSION",
29
+ "tool_name": "Bash",
30
+ "tool_input": {
31
+ "command": "$cmd"
32
+ }
33
+ }
34
+ ENDJSON
35
+ }
36
+
37
+ @test "gh pr merge is blocked with release:watch guidance when script exists" {
38
+ # Create a package.json with release:watch
39
+ cat > "$TEST_PROJECT_DIR/package.json" <<'PKG'
40
+ { "scripts": { "release:watch": "bash scripts/release-watch.sh" } }
41
+ PKG
42
+
43
+ INPUT=$(build_input "gh pr merge 4 --merge")
44
+ run bash -c "cd '$TEST_PROJECT_DIR' && echo '$INPUT' | '$HOOK'"
45
+ [ "$status" -eq 0 ]
46
+ [[ "$output" == *"permissionDecision"* ]]
47
+ [[ "$output" == *"deny"* ]]
48
+ [[ "$output" == *"release:watch"* ]]
49
+ }
50
+
51
+ @test "gh pr merge tells agent to create release:watch when script missing" {
52
+ # Create a package.json WITHOUT release:watch
53
+ cat > "$TEST_PROJECT_DIR/package.json" <<'PKG'
54
+ { "scripts": { "test": "echo test" } }
55
+ PKG
56
+
57
+ INPUT=$(build_input "gh pr merge 4 --merge")
58
+ run bash -c "cd '$TEST_PROJECT_DIR' && echo '$INPUT' | '$HOOK'"
59
+ [ "$status" -eq 0 ]
60
+ [[ "$output" == *"permissionDecision"* ]]
61
+ [[ "$output" == *"deny"* ]]
62
+ # Should tell agent to create the script
63
+ [[ "$output" == *"no release:watch script"* ]]
64
+ [[ "$output" == *"gh pr merge"* ]]
65
+ [[ "$output" == *"gh run watch"* ]]
66
+ }
67
+
68
+ @test "gh pr merge tells agent to create release:watch when no package.json" {
69
+ local empty_dir="$(mktemp -d)"
70
+
71
+ INPUT=$(build_input "gh pr merge 4 --merge")
72
+ run bash -c "cd '$empty_dir' && echo '$INPUT' | '$HOOK'"
73
+ [ "$status" -eq 0 ]
74
+ [[ "$output" == *"permissionDecision"* ]]
75
+ [[ "$output" == *"deny"* ]]
76
+ # Should tell agent to create the script
77
+ [[ "$output" == *"no release:watch script"* ]]
78
+ [[ "$output" == *"gh pr merge"* ]]
79
+ [[ "$output" == *"gh run watch"* ]]
80
+
81
+ rm -rf "$empty_dir"
82
+ }
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # P004: risk-policy-enforce-edit.sh project-root check.
4
+
5
+ setup() {
6
+ SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
7
+ HOOK="$SCRIPT_DIR/risk-policy-enforce-edit.sh"
8
+ }
9
+
10
+ run_hook_with_file() {
11
+ local file_path="$1"
12
+ local json="{\"tool_input\":{\"file_path\":\"${file_path}\"},\"session_id\":\"test-$$\"}"
13
+ echo "$json" | bash "$HOOK"
14
+ }
15
+
16
+ @test "risk-policy project-root: RISK-POLICY.md outside project exits 0" {
17
+ run run_hook_with_file "/Users/other/project/RISK-POLICY.md"
18
+ [ "$status" -eq 0 ]
19
+ [[ "$output" != *"BLOCKED"* ]]
20
+ }
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Tests for risk-score-mark.sh — verifies the PostToolUse:Agent hook
4
+ # parses risk-scorer agent output and writes the right files into
5
+ # the session-scoped risk dir.
6
+ #
7
+ # Per ADR-005 (P011): behavioural assertions are functional — they
8
+ # pipe mock hook input to the script and assert on side-effects, not
9
+ # on what the source happens to contain. The four "echo X | grep X"
10
+ # tautologies that previously lived here have been removed (they
11
+ # always passed regardless of hook behaviour).
12
+
13
+ setup() {
14
+ SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
15
+ HOOK="$SCRIPT_DIR/risk-score-mark.sh"
16
+ ORIG_DIR="$PWD"
17
+ TEST_DIR=$(mktemp -d)
18
+ cd "$TEST_DIR"
19
+ TMPDIR="$TEST_DIR/tmp"
20
+ export TMPDIR
21
+ mkdir -p "$TMPDIR"
22
+ SESSION_ID="test-session-$$"
23
+ RDIR="$TMPDIR/claude-risk-${SESSION_ID}"
24
+ }
25
+
26
+ teardown() {
27
+ cd "$ORIG_DIR"
28
+ rm -rf "$TEST_DIR"
29
+ }
30
+
31
+ # Helper: build the PostToolUse:Agent JSON envelope and pipe it to the hook.
32
+ # AGENT_OUTPUT is wrapped in tool_response.content[0].text to match the
33
+ # real Claude Code PostToolUse hook payload shape.
34
+ run_hook() {
35
+ local subagent="$1"
36
+ local agent_output="$2"
37
+ python3 -c "
38
+ import json, sys
39
+ print(json.dumps({
40
+ 'tool_name': 'Agent',
41
+ 'session_id': '${SESSION_ID}',
42
+ 'tool_input': {'subagent_type': '${subagent}'},
43
+ 'tool_response': {'content': [{'type': 'text', 'text': sys.stdin.read()}]}
44
+ }))" <<<"$agent_output" | bash "$HOOK"
45
+ }
46
+
47
+ # --- Pipeline scorer: writes commit/push/release score files ---
48
+
49
+ @test "pipeline: writes commit/push/release scores from RISK_SCORES line" {
50
+ run_hook "wr-risk-scorer:pipeline" "Header text
51
+ RISK_SCORES: commit=2 push=3 release=1
52
+ Trailing text"
53
+ [ "$(cat "$RDIR/commit")" = "2" ]
54
+ [ "$(cat "$RDIR/push")" = "3" ]
55
+ [ "$(cat "$RDIR/release")" = "1" ]
56
+ }
57
+
58
+ @test "pipeline: writes reducing bypass markers when RISK_BYPASS: reducing" {
59
+ run_hook "wr-risk-scorer:pipeline" "RISK_SCORES: commit=2 push=2 release=0
60
+ RISK_BYPASS: reducing"
61
+ [ -f "$RDIR/reducing-commit" ]
62
+ [ -f "$RDIR/reducing-push" ]
63
+ [ -f "$RDIR/reducing-release" ]
64
+ }
65
+
66
+ @test "pipeline: writes incident bypass marker when RISK_BYPASS: incident" {
67
+ run_hook "wr-risk-scorer:pipeline" "RISK_SCORES: commit=10 push=10 release=10
68
+ RISK_BYPASS: incident"
69
+ [ -f "$RDIR/incident-release" ]
70
+ }
71
+
72
+ @test "pipeline: writes nothing when output has no RISK_SCORES line" {
73
+ run_hook "wr-risk-scorer:pipeline" "No score line in this output"
74
+ [ ! -f "$RDIR/commit" ]
75
+ [ ! -f "$RDIR/push" ]
76
+ [ ! -f "$RDIR/release" ]
77
+ }
78
+
79
+ # --- Plan scorer: writes plan-reviewed marker on PASS only ---
80
+
81
+ @test "plan: writes plan-reviewed marker on RISK_VERDICT: PASS" {
82
+ run_hook "wr-risk-scorer:plan" "RISK_VERDICT: PASS"
83
+ [ -f "$RDIR/plan-reviewed" ]
84
+ }
85
+
86
+ @test "plan: does NOT write plan-reviewed marker on RISK_VERDICT: FAIL" {
87
+ run_hook "wr-risk-scorer:plan" "RISK_VERDICT: FAIL"
88
+ [ ! -f "$RDIR/plan-reviewed" ]
89
+ }
90
+
91
+ # --- Subagent routing: case guard ignores non-risk-scorer agents ---
92
+
93
+ @test "case guard: skips unrelated agent without writing files" {
94
+ run_hook "wr-architect:agent" "RISK_SCORES: commit=99 push=99 release=99"
95
+ [ ! -f "$RDIR/commit" ]
96
+ }
97
+
98
+ @test "case guard: matches wr-risk-scorer:pipeline subagent" {
99
+ SUBAGENT="wr-risk-scorer:pipeline"
100
+ case "$SUBAGENT" in
101
+ *risk-scorer*) true ;;
102
+ *) false ;;
103
+ esac
104
+ }
105
+
106
+ @test "case guard: does NOT match wr-architect:agent" {
107
+ SUBAGENT="wr-architect:agent"
108
+ case "$SUBAGENT" in
109
+ *risk-scorer*) false ;;
110
+ *) true ;;
111
+ esac
112
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # P001 / ADR-009: Stop-hook marker reset removed.
4
+
5
+ setup() {
6
+ PLUGIN_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
7
+ }
8
+
9
+ @test "risk-scorer: hooks.json has no Stop hook entry (ADR-009)" {
10
+ ! grep -q '"Stop"' "$PLUGIN_DIR/hooks/hooks.json"
11
+ }
12
+
13
+ @test "risk-scorer: risk-score-reset.sh has been removed" {
14
+ [ ! -f "$PLUGIN_DIR/hooks/risk-score-reset.sh" ]
15
+ }
16
+
17
+ @test "risk-scorer: risk-policy-reset-marker.sh has been removed" {
18
+ [ ! -f "$PLUGIN_DIR/hooks/risk-policy-reset-marker.sh" ]
19
+ }
@@ -37,7 +37,7 @@ cat <<'EOF'
37
37
  "hookSpecificOutput": {
38
38
  "hookEventName": "PreToolUse",
39
39
  "permissionDecision": "deny",
40
- "permissionDecisionReason": "WIP risk assessment required. Delegate to risk-scorer-wip (subagent_type: 'risk-scorer-wip') to assess cumulative pipeline risk for changes so far."
40
+ "permissionDecisionReason": "WIP risk assessment required. Delegate to wr-risk-scorer:wip (subagent_type: 'wr-risk-scorer:wip') to assess cumulative pipeline risk for changes so far."
41
41
  }
42
42
  }
43
43
  EOF
@@ -17,16 +17,8 @@ SESSION_ID=$(_get_session_id)
17
17
 
18
18
  MARKER="$(_risk_dir "$SESSION_ID")/wip-reviewed"
19
19
 
20
- case "$TOOL_NAME" in
21
- Edit|Write)
22
- FILE_PATH=$(_get_file_path)
23
- [ -n "$FILE_PATH" ] || exit 0
24
-
25
- if ! _is_doc_file "$FILE_PATH"; then
26
- rm -f "$MARKER"
27
- fi
28
- ;;
29
- # Agent case handled by risk-score-mark.sh
30
- esac
20
+ # WIP marker persists after assessment — allows multiple edits.
21
+ # Marker lifecycle: TTL + drift detection (ADR-009).
22
+ # Agent case (marker creation) handled by risk-score-mark.sh.
31
23
 
32
24
  exit 0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Shared install utilities for @windyroad/* packages.
3
+ * Used by both per-plugin installers and the meta-installer.
4
+ */
5
+
6
+ import { execSync } from "node:child_process";
7
+
8
+ const MARKETPLACE_REPO = "windyroad/agent-plugins";
9
+ const MARKETPLACE_NAME = "windyroad";
10
+
11
+ let _dryRun = false;
12
+
13
+ export { MARKETPLACE_REPO, MARKETPLACE_NAME };
14
+
15
+ export function setDryRun(value) {
16
+ _dryRun = value;
17
+ }
18
+
19
+ export function isDryRun() {
20
+ return _dryRun;
21
+ }
22
+
23
+ export function run(cmd, label) {
24
+ console.log(` ${label}...`);
25
+ if (_dryRun) {
26
+ console.log(` [dry-run] ${cmd}`);
27
+ return true;
28
+ }
29
+ try {
30
+ execSync(cmd, { stdio: "inherit" });
31
+ return true;
32
+ } catch {
33
+ console.error(` FAILED: ${label}`);
34
+ return false;
35
+ }
36
+ }
37
+
38
+ export function checkPrerequisites() {
39
+ if (_dryRun) return;
40
+
41
+ try {
42
+ execSync("claude --version", { stdio: "pipe" });
43
+ } catch {
44
+ console.error(
45
+ "Error: 'claude' CLI not found. Install Claude Code first:\n https://docs.anthropic.com/en/docs/claude-code\n"
46
+ );
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ export function addMarketplace() {
52
+ return run(
53
+ `claude plugin marketplace add ${MARKETPLACE_REPO}`,
54
+ `Marketplace: ${MARKETPLACE_NAME}`
55
+ );
56
+ }
57
+
58
+ export function installPlugin(pluginName, { scope = "project" } = {}) {
59
+ return run(
60
+ `claude plugin install ${pluginName}@${MARKETPLACE_NAME} --scope ${scope}`,
61
+ pluginName
62
+ );
63
+ }
64
+
65
+ export function updatePlugin(pluginName) {
66
+ return run(`claude plugin update ${pluginName}`, pluginName);
67
+ }
68
+
69
+ export function uninstallPlugin(pluginName) {
70
+ return run(`claude plugin uninstall ${pluginName}`, `Removing ${pluginName}`);
71
+ }
72
+
73
+ /**
74
+ * Install a single package: marketplace add + plugin install.
75
+ */
76
+ export function installPackage(pluginName, { deps = [], scope = "project" } = {}) {
77
+ console.log(`\nInstalling @windyroad/${pluginName.replace("wr-", "")} (${scope} scope)...\n`);
78
+
79
+ addMarketplace();
80
+ installPlugin(pluginName, { scope });
81
+
82
+ if (deps.length > 0) {
83
+ console.log(`\nNote: This plugin works best with:`);
84
+ for (const dep of deps) {
85
+ console.log(` - @windyroad/${dep.replace("wr-", "")} (npx @windyroad/${dep.replace("wr-", "")})`);
86
+ }
87
+ }
88
+
89
+ console.log(
90
+ `\nDone! Restart Claude Code to activate.\n`
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Update a single package.
96
+ */
97
+ export function updatePackage(pluginName) {
98
+ console.log(`\nUpdating @windyroad/${pluginName.replace("wr-", "")}...\n`);
99
+
100
+ run(
101
+ `claude plugin marketplace update ${MARKETPLACE_NAME}`,
102
+ "Updating marketplace"
103
+ );
104
+ updatePlugin(pluginName);
105
+
106
+ console.log("\nDone! Restart Claude Code to apply updates.\n");
107
+ }
108
+
109
+ /**
110
+ * Uninstall a single package.
111
+ */
112
+ export function uninstallPackage(pluginName) {
113
+ console.log(`\nUninstalling @windyroad/${pluginName.replace("wr-", "")}...\n`);
114
+
115
+ uninstallPlugin(pluginName);
116
+
117
+ console.log("\nDone. Restart Claude Code to apply changes.\n");
118
+ }
119
+
120
+ /**
121
+ * Parse standard flags used by all per-plugin installers.
122
+ */
123
+ export function parseStandardArgs(argv) {
124
+ const args = argv.slice(2);
125
+ const flags = {
126
+ help: args.includes("--help") || args.includes("-h"),
127
+ uninstall: args.includes("--uninstall"),
128
+ update: args.includes("--update"),
129
+ dryRun: args.includes("--dry-run"),
130
+ scope: "project",
131
+ };
132
+ const scopeIdx = args.indexOf("--scope");
133
+ if (scopeIdx !== -1 && args[scopeIdx + 1]) {
134
+ const val = args[scopeIdx + 1];
135
+ if (["project", "user", "local"].includes(val)) {
136
+ flags.scope = val;
137
+ } else {
138
+ console.error("--scope requires: project, user, or local");
139
+ process.exit(1);
140
+ }
141
+ }
142
+ return flags;
143
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/risk-scorer",
3
- "version": "0.2.0",
3
+ "version": "0.2.1-preview.70",
4
4
  "description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
5
5
  "bin": {
6
6
  "windyroad-risk-scorer": "./bin/install.mjs"
@@ -23,6 +23,7 @@
23
23
  "agents/",
24
24
  "hooks/",
25
25
  "skills/",
26
- ".claude-plugin/"
26
+ ".claude-plugin/",
27
+ "lib/"
27
28
  ]
28
29
  }
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: wr:risk-policy
2
+ name: wr-risk-scorer:update-policy
3
3
  description: Create or update the project's RISK-POLICY.md per ISO 31000 and the risk-scorer agent. Examines the project to derive business-specific impact levels.
4
4
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Agent
5
5
  ---
@@ -1,17 +0,0 @@
1
- #!/bin/bash
2
- # Stop hook: Clears risk-policy session marker.
3
- # Mirrors: architect-reset-marker.sh
4
-
5
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
6
- source "$SCRIPT_DIR/lib/gate-helpers.sh"
7
-
8
- _parse_input
9
-
10
- SESSION_ID=$(_get_session_id)
11
-
12
- if [ -n "$SESSION_ID" ]; then
13
- RDIR=$(_risk_dir "$SESSION_ID")
14
- rm -f "${RDIR}/policy-reviewed" "${RDIR}/plan-reviewed"
15
- fi
16
-
17
- exit 0
@@ -1,17 +0,0 @@
1
- #!/bin/bash
2
- # Stop hook: Clears risk score temp files on session end.
3
-
4
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
5
- source "$SCRIPT_DIR/lib/gate-helpers.sh"
6
-
7
- _parse_input
8
-
9
- SESSION_ID=$(_get_session_id)
10
-
11
- if [ -n "$SESSION_ID" ]; then
12
- # Remove the entire session-scoped directory
13
- RDIR="${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}"
14
- rm -rf "$RDIR"
15
- fi
16
-
17
- exit 0