@windyroad/risk-scorer 0.3.5 → 0.3.6-preview.192

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "wr-risk-scorer",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Pipeline risk scoring, commit/push/release gates for Claude Code"
5
5
  }
@@ -149,9 +149,9 @@ exceeds appetite. The only sanctioned above-appetite output is the Risk Report
149
149
  structure, `RISK_SCORES: ...`, and the structured `RISK_REMEDIATIONS:` block
150
150
  defined below.
151
151
 
152
- Emit a structured `RISK_REMEDIATIONS:` block after the `RISK_SCORES:` line. This gives the calling skill machine-readable input for structured decision prompts.
152
+ Emit a structured `RISK_REMEDIATIONS:` block after the `RISK_SCORES:` line. This gives the calling skill machine-readable input.
153
153
 
154
- Format (5 columns — machine-readable for structured AskUserQuestion prompts in calling skills):
154
+ Format (5 columns):
155
155
  ```
156
156
  RISK_REMEDIATIONS:
157
157
  - R1 | <description of remediation> | <effort S/M/L> | <risk_delta -N> | <files affected>
@@ -161,6 +161,7 @@ RISK_REMEDIATIONS:
161
161
  Column definitions:
162
162
  - **effort**: estimated size of the remediation — S (< 1h, single file), M (1-4h, few files), L (> 4h, multiple files)
163
163
  - **risk_delta**: estimated reduction in residual risk if this remediation is applied (e.g., `-3` means risk drops by 3 points)
164
+ - **description**: free-form prose. The agent reads this and decides what to do. No structured action_class column.
164
165
 
165
166
  Include downstream back-pressure in the remediation list:
166
167
  - **Commit**: If adding this commit would push the push queue risk >= 5, include a remediation to split the commit.
package/agents/plan.md CHANGED
@@ -55,7 +55,7 @@ not policy-authorised — the only sanctioned FAIL output is the Plan Risk Repor
55
55
  the `RISK_VERDICT: FAIL` marker, and the structured `RISK_REMEDIATIONS:` block
56
56
  defined below.
57
57
 
58
- Emit a structured `RISK_REMEDIATIONS:` block after the verdict (5 columns — machine-readable for structured AskUserQuestion prompts in calling skills):
58
+ Emit a structured `RISK_REMEDIATIONS:` block after the verdict (5 columns):
59
59
  ```
60
60
  RISK_REMEDIATIONS:
61
61
  - R1 | <description of what the plan must add/change> | <effort S/M/L> | <risk_delta -N> | <affected area>
@@ -64,8 +64,9 @@ RISK_REMEDIATIONS:
64
64
  Column definitions:
65
65
  - **effort**: estimated size of the remediation — S (< 1h, single file), M (1-4h, few files), L (> 4h, multiple files)
66
66
  - **risk_delta**: estimated reduction in residual risk if this remediation is applied
67
+ - **description**: free-form prose. The agent reads this and decides what to do. No structured action_class column.
67
68
 
68
- Do NOT emit free-text "consider" or "you should" prose. The structured block is the only output for above-appetite guidance.
69
+ Do NOT emit free-text "consider" or "you should" prose outside the structured block. The `RISK_REMEDIATIONS:` block is the only output for above-appetite guidance.
69
70
 
70
71
  ## Control Discovery
71
72
 
@@ -98,3 +98,26 @@ setup() {
98
98
  run grep -q "risk_delta" "$PLAN"
99
99
  [ "$status" -eq 0 ]
100
100
  }
101
+
102
+ # ──────────────────────────────────────────────────────────────────────────────
103
+ # P108: scorer writes prose descriptions; agent decides (ADR-042 Rule 2a)
104
+ # ──────────────────────────────────────────────────────────────────────────────
105
+
106
+ @test "pipeline.md RISK_REMEDIATIONS format has no action_class column" {
107
+ # ADR-042 Rule 2a: no structured action_class column. The agent reads
108
+ # the description and decides. Match only markdown-table column-header
109
+ # rows so prose mentions of "action_class" (e.g. "No structured
110
+ # action_class column.") do not trip the assertion (P114).
111
+ run grep -qE '^\| *action_class\b' "$PIPELINE"
112
+ [ "$status" -ne 0 ]
113
+ }
114
+
115
+ @test "wip.md RISK_REMEDIATIONS format has no action_class column" {
116
+ run grep -qE '^\| *action_class\b' "$WIP"
117
+ [ "$status" -ne 0 ]
118
+ }
119
+
120
+ @test "plan.md RISK_REMEDIATIONS format has no action_class column" {
121
+ run grep -qE '^\| *action_class\b' "$PLAN"
122
+ [ "$status" -ne 0 ]
123
+ }
package/agents/wip.md CHANGED
@@ -61,7 +61,7 @@ structured `RISK_REMEDIATIONS:` block defined below.
61
61
 
62
62
  Provide the assessment table, then emit a structured `RISK_REMEDIATIONS:` block with specific risk-reducing actions:
63
63
 
64
- Format (5 columns — machine-readable for structured AskUserQuestion prompts in calling skills):
64
+ Format (5 columns):
65
65
  ```
66
66
  RISK_REMEDIATIONS:
67
67
  - R1 | Commit current changes to move WIP forward | S | -2 | <uncommitted files>
@@ -73,8 +73,9 @@ RISK_REMEDIATIONS:
73
73
  Column definitions:
74
74
  - **effort**: estimated size of the remediation — S (< 1h, single file), M (1-4h, few files), L (> 4h, multiple files)
75
75
  - **risk_delta**: estimated reduction in residual risk if this remediation is applied (e.g., `-3` means risk drops by 3 points)
76
+ - **description**: free-form prose. The agent reads this and decides what to do. No structured action_class column.
76
77
 
77
- Do NOT emit free-text suggestions as prose. The structured block is the only output for above-appetite guidance.
78
+ Do NOT emit free-text suggestions outside the structured block. The `RISK_REMEDIATIONS:` block is the only output for above-appetite guidance.
78
79
 
79
80
  The verdict is `RISK_VERDICT: PAUSE`. This blocks the next edit until the risk is addressed.
80
81
 
@@ -32,7 +32,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*git push(\s|$)'; then
32
32
  exit 0
33
33
  fi
34
34
 
35
- # Gate push:watch on push risk score
35
+ # Gate push:watch on push risk score (inherits three-band TTL via check_risk_gate — P090)
36
36
  if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm run push:watch(\s|$)'; then
37
37
  if [ -n "$SESSION_ID" ]; then
38
38
  RDIR=$(_risk_dir "$SESSION_ID")
@@ -45,27 +45,12 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm run push:watch(\s|$)'; then
45
45
  if [ -f "${RDIR}/clean" ]; then
46
46
  exit 0
47
47
  fi
48
- PUSH_SCORE_FILE="${RDIR}/push"
49
- if [ ! -f "$PUSH_SCORE_FILE" ]; then
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
- exit 0
52
- fi
53
- PUSH_NOW=$(date +%s)
54
- PUSH_SCORE_TIME=$(_mtime "$PUSH_SCORE_FILE")
55
- PUSH_AGE=$(( PUSH_NOW - PUSH_SCORE_TIME ))
56
- PUSH_TTL="${RISK_TTL:-1800}"
57
- if [ "$PUSH_AGE" -ge "$PUSH_TTL" ]; then
58
- risk_gate_deny "Push blocked: Push risk score expired (${PUSH_AGE}s old, TTL ${PUSH_TTL}s). Delegate to risk-scorer to rescore."
59
- exit 0
60
- fi
61
- PUSH_SCORE=$(cat "$PUSH_SCORE_FILE" 2>/dev/null || echo "")
62
- if ! echo "$PUSH_SCORE" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
63
- risk_gate_deny "Push blocked: Push risk score is not yet available (scoring in progress). Wait a moment and retry."
64
- exit 0
65
- fi
66
- PUSH_DENIED=$(python3 -c "print('yes' if float('${PUSH_SCORE}') >= 5 else 'no')" 2>/dev/null || echo "no")
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 wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') — it will create a bypass marker."
48
+ if ! check_risk_gate "$SESSION_ID" "push"; then
49
+ if [ "$RISK_GATE_CATEGORY" = "threshold" ]; then
50
+ risk_gate_deny "Push blocked: Push risk score ${RISK_GATE_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."
51
+ else
52
+ risk_gate_deny "Push blocked: ${RISK_GATE_REASON}"
53
+ fi
69
54
  exit 0
70
55
  fi
71
56
  fi
@@ -9,6 +9,18 @@ source "$_RISK_GATE_DIR/gate-helpers.sh"
9
9
 
10
10
  # Check risk gate for a given action. Returns 0 if allowed, 1 if denied.
11
11
  # Sets RISK_GATE_REASON on failure with human-readable message.
12
+ # Also sets RISK_GATE_CATEGORY ∈ {missing, expired, drift, invalid, threshold}
13
+ # and RISK_GATE_SCORE (on threshold) for callers that customise deny messages.
14
+ #
15
+ # Implements the three-band TTL policy (P090, ADR-009 footnote):
16
+ # Band A: age < TTL/2 → pass silently (no slide).
17
+ # Band B: TTL/2 ≤ age < TTL → if state-hash is invariant since the
18
+ # scorer ran, pass AND slide the marker
19
+ # forward (touch score file); bounded by
20
+ # a 2×TTL hard-cap from the scorer-run
21
+ # birth time stored in <action>-born.
22
+ # If the hash drifted, halt as before.
23
+ # Band C: age ≥ TTL → halt with the existing expired message.
12
24
  # Usage: check_risk_gate "$SESSION_ID" "commit"
13
25
  check_risk_gate() {
14
26
  local SESSION_ID="$1"
@@ -16,24 +28,37 @@ check_risk_gate() {
16
28
  local RDIR
17
29
  RDIR=$(_risk_dir "$SESSION_ID")
18
30
  local SCORE_FILE="${RDIR}/${ACTION}"
31
+ local BORN_FILE="${RDIR}/${ACTION}-born"
19
32
  local HASH_FILE="${RDIR}/state-hash"
20
- local TTL_SECONDS="${RISK_TTL:-1800}"
33
+ local TTL_SECONDS="${RISK_TTL:-3600}"
34
+
35
+ RISK_GATE_CATEGORY=""
36
+ RISK_GATE_SCORE=""
21
37
 
22
38
  # 1. Score file must exist (fail-closed)
23
39
  if [ ! -f "$SCORE_FILE" ]; then
40
+ RISK_GATE_CATEGORY="missing"
24
41
  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
42
  return 1
26
43
  fi
27
44
 
28
- # 2. TTL check score file mtime must be within TTL
45
+ # 2. TTL — Band C hard expiry first
29
46
  local NOW=$(date +%s)
30
47
  local SCORE_TIME=$(_mtime "$SCORE_FILE")
31
48
  local AGE=$(( NOW - SCORE_TIME ))
32
49
  if [ "$AGE" -ge "$TTL_SECONDS" ]; then
50
+ RISK_GATE_CATEGORY="expired"
33
51
  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
52
  return 1
35
53
  fi
36
54
 
55
+ # Detect Band B candidacy (age in [TTL/2, TTL))
56
+ local HALF_TTL=$(( TTL_SECONDS / 2 ))
57
+ local BAND_B=0
58
+ if [ "$AGE" -ge "$HALF_TTL" ]; then
59
+ BAND_B=1
60
+ fi
61
+
37
62
  # 3. Drift detection — pipeline state hash must match
38
63
  # The hash is computed from git diff HEAD --stat at prompt submit time.
39
64
  # If you staged files AFTER the prompt, the hash will differ.
@@ -43,15 +68,34 @@ check_risk_gate() {
43
68
  local CURRENT_HASH
44
69
  CURRENT_HASH=$("$_RISK_GATE_DIR/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
45
70
  if [ "$STORED_HASH" != "$CURRENT_HASH" ]; then
71
+ RISK_GATE_CATEGORY="drift"
46
72
  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
73
  return 1
48
74
  fi
75
+
76
+ # Band B + hash invariant: slide the marker forward, bounded by 2×TTL
77
+ # from the scorer-run birth time. The hard cap prevents an unchanged-but-
78
+ # perpetually-idle tree from riding a single marker indefinitely.
79
+ if [ "$BAND_B" = "1" ]; then
80
+ if [ -f "$BORN_FILE" ]; then
81
+ local BORN_TIME=$(_mtime "$BORN_FILE")
82
+ local BORN_AGE=$(( NOW - BORN_TIME ))
83
+ local HARD_CAP=$(( TTL_SECONDS * 2 ))
84
+ if [ "$BORN_AGE" -ge "$HARD_CAP" ]; then
85
+ RISK_GATE_CATEGORY="expired"
86
+ RISK_GATE_REASON="Risk score expired (${BORN_AGE}s total since scoring, hard cap ${HARD_CAP}s). Delegate to wr-risk-scorer:pipeline (subagent_type: 'wr-risk-scorer:pipeline') to rescore."
87
+ return 1
88
+ fi
89
+ fi
90
+ touch "$SCORE_FILE"
91
+ fi
49
92
  fi
50
- # No hash file = backward compat, skip drift check
93
+ # No hash file = backward compat, skip drift check and Band B slide
51
94
 
52
95
  # 4. Read and validate score
53
96
  local SCORE=$(cat "$SCORE_FILE" 2>/dev/null || echo "")
54
97
  if ! echo "$SCORE" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
98
+ RISK_GATE_CATEGORY="invalid"
55
99
  RISK_GATE_REASON="Risk score file contains an invalid value. Re-run the risk-scorer agent."
56
100
  return 1
57
101
  fi
@@ -63,6 +107,8 @@ print('yes' if score >= 5 else 'no')
63
107
  " 2>/dev/null || echo "no")
64
108
 
65
109
  if [ "$DENIED" = "yes" ]; then
110
+ RISK_GATE_CATEGORY="threshold"
111
+ RISK_GATE_SCORE="$SCORE"
66
112
  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
113
  return 1
68
114
  fi
@@ -42,9 +42,13 @@ if echo "$SUBAGENT" | grep -qE 'risk-scorer.pipeline'; then
42
42
  PUSH=$(echo "$SCORES_LINE" | grep -oE 'push=[0-9]+' | cut -d= -f2) || true
43
43
  RELEASE=$(echo "$SCORES_LINE" | grep -oE 'release=[0-9]+' | cut -d= -f2) || true
44
44
 
45
- [ -n "$COMMIT" ] && printf '%s' "$COMMIT" > "${RDIR}/commit"
46
- [ -n "$PUSH" ] && printf '%s' "$PUSH" > "${RDIR}/push"
47
- [ -n "$RELEASE" ] && printf '%s' "$RELEASE" > "${RDIR}/release"
45
+ # Birth markers (<action>-born) capture the scorer-run timestamp. Band B
46
+ # of the three-band TTL policy (P090) uses them to enforce a 2×TTL
47
+ # hard-cap on sliding-window extension, so an unchanged-but-idle tree
48
+ # cannot ride a single score indefinitely.
49
+ [ -n "$COMMIT" ] && { printf '%s' "$COMMIT" > "${RDIR}/commit"; touch "${RDIR}/commit-born"; }
50
+ [ -n "$PUSH" ] && { printf '%s' "$PUSH" > "${RDIR}/push"; touch "${RDIR}/push-born"; }
51
+ [ -n "$RELEASE" ] && { printf '%s' "$RELEASE" > "${RDIR}/release"; touch "${RDIR}/release-born"; }
48
52
  fi
49
53
 
50
54
  # Parse RISK_BYPASS: reducing|incident
@@ -105,3 +105,117 @@ assert_gate_allows() {
105
105
  [[ "$output" == *"deny"* ]]
106
106
  [[ "$output" == *"Test reason"* ]]
107
107
  }
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Three-band TTL policy (P090)
111
+ # Band A: age < TTL/2 → pass silently, no slide
112
+ # Band B: TTL/2 <= age < TTL → consult state-hash; if invariant, pass + slide
113
+ # the marker forward (touch score file) bounded by 2*TTL hard cap
114
+ # from the scorer-run birth time (<action>-born); if drifted, halt
115
+ # Band C: age >= TTL → halt with existing "expired" message
116
+ # ---------------------------------------------------------------------------
117
+
118
+ # Helper: backdate file mtime by N seconds (portable between macOS and Linux)
119
+ _backdate() {
120
+ local file="$1" seconds="$2"
121
+ local stamp
122
+ stamp=$(date -v-${seconds}S +%Y%m%d%H%M.%S 2>/dev/null \
123
+ || date -d "${seconds} seconds ago" +%Y%m%d%H%M.%S 2>/dev/null)
124
+ touch -t "$stamp" "$file"
125
+ }
126
+
127
+ # Helper: write a matching state-hash for the current working tree
128
+ _write_matching_hash() {
129
+ local target="$1"
130
+ local hash
131
+ hash=$("$HOOKS_DIR/lib/pipeline-state.sh" --hash-inputs 2>/dev/null | _hashcmd | cut -d' ' -f1)
132
+ echo "$hash" > "$target"
133
+ }
134
+
135
+ @test "Band A (age < TTL/2): passes, does NOT slide the marker" {
136
+ printf '3' > "$SCORE_FILE"
137
+ touch "$SCORE_FILE"
138
+ rm -f "$HASH_FILE"
139
+ BEFORE_MTIME=$(_mtime "$SCORE_FILE")
140
+ sleep 1
141
+ assert_gate_allows "$TEST_SESSION" "commit"
142
+ AFTER_MTIME=$(_mtime "$SCORE_FILE")
143
+ [ "$BEFORE_MTIME" = "$AFTER_MTIME" ]
144
+ }
145
+
146
+ @test "Band B (TTL/2 <= age < TTL) with hash invariant: passes AND slides marker forward" {
147
+ printf '3' > "$SCORE_FILE"
148
+ _backdate "$SCORE_FILE" 3
149
+ _write_matching_hash "$HASH_FILE"
150
+ touch "${SCORE_FILE}-born"
151
+ BEFORE_MTIME=$(_mtime "$SCORE_FILE")
152
+ assert_gate_allows "$TEST_SESSION" "commit"
153
+ AFTER_MTIME=$(_mtime "$SCORE_FILE")
154
+ [ "$AFTER_MTIME" -gt "$BEFORE_MTIME" ]
155
+ }
156
+
157
+ @test "Band B with no hash file: passes but does NOT slide (no invariance proof)" {
158
+ printf '3' > "$SCORE_FILE"
159
+ _backdate "$SCORE_FILE" 3
160
+ rm -f "$HASH_FILE"
161
+ BEFORE_MTIME=$(_mtime "$SCORE_FILE")
162
+ sleep 1
163
+ assert_gate_allows "$TEST_SESSION" "commit"
164
+ AFTER_MTIME=$(_mtime "$SCORE_FILE")
165
+ [ "$BEFORE_MTIME" = "$AFTER_MTIME" ]
166
+ }
167
+
168
+ @test "Band B with hash mismatch: denies with drift (no slide)" {
169
+ printf '3' > "$SCORE_FILE"
170
+ _backdate "$SCORE_FILE" 3
171
+ echo "staleold" > "$HASH_FILE"
172
+ touch "${SCORE_FILE}-born"
173
+ BEFORE_MTIME=$(_mtime "$SCORE_FILE")
174
+ assert_gate_denies "$TEST_SESSION" "commit" "drift"
175
+ AFTER_MTIME=$(_mtime "$SCORE_FILE")
176
+ [ "$BEFORE_MTIME" = "$AFTER_MTIME" ]
177
+ }
178
+
179
+ @test "Band B with hard-cap exceeded (born-age >= 2*TTL): denies even if hash invariant" {
180
+ printf '3' > "$SCORE_FILE"
181
+ _backdate "$SCORE_FILE" 3
182
+ _write_matching_hash "$HASH_FILE"
183
+ touch "${SCORE_FILE}-born"
184
+ _backdate "${SCORE_FILE}-born" 12
185
+ assert_gate_denies "$TEST_SESSION" "commit" "expired"
186
+ }
187
+
188
+ @test "Band C (age >= TTL): denies regardless of hash invariance" {
189
+ printf '3' > "$SCORE_FILE"
190
+ _backdate "$SCORE_FILE" 10
191
+ _write_matching_hash "$HASH_FILE"
192
+ touch "${SCORE_FILE}-born"
193
+ assert_gate_denies "$TEST_SESSION" "commit" "expired"
194
+ }
195
+
196
+ @test "Band B denial exports RISK_GATE_CATEGORY=drift on hash mismatch" {
197
+ printf '3' > "$SCORE_FILE"
198
+ _backdate "$SCORE_FILE" 3
199
+ echo "staleold" > "$HASH_FILE"
200
+ RISK_GATE_CATEGORY=""
201
+ ! check_risk_gate "$TEST_SESSION" "commit"
202
+ [ "$RISK_GATE_CATEGORY" = "drift" ]
203
+ }
204
+
205
+ @test "Band C denial exports RISK_GATE_CATEGORY=expired" {
206
+ printf '3' > "$SCORE_FILE"
207
+ _backdate "$SCORE_FILE" 10
208
+ RISK_GATE_CATEGORY=""
209
+ ! check_risk_gate "$TEST_SESSION" "commit"
210
+ [ "$RISK_GATE_CATEGORY" = "expired" ]
211
+ }
212
+
213
+ @test "Threshold denial exports RISK_GATE_CATEGORY=threshold and RISK_GATE_SCORE" {
214
+ printf '7' > "$SCORE_FILE"
215
+ touch "$SCORE_FILE"
216
+ RISK_GATE_CATEGORY=""
217
+ RISK_GATE_SCORE=""
218
+ ! check_risk_gate "$TEST_SESSION" "commit"
219
+ [ "$RISK_GATE_CATEGORY" = "threshold" ]
220
+ [ "$RISK_GATE_SCORE" = "7" ]
221
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/risk-scorer",
3
- "version": "0.3.5",
3
+ "version": "0.3.6-preview.192",
4
4
  "description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
5
5
  "bin": {
6
6
  "windyroad-risk-scorer": "./bin/install.mjs"
@@ -0,0 +1,172 @@
1
+ ---
2
+ name: wr-risk-scorer:create-risk
3
+ description: Create a new standing-risk entry in docs/risks/. Examines existing risks, gathers impact/likelihood/controls from the user, writes a file matching docs/risks/TEMPLATE.md, and updates the register index.
4
+ allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
5
+ ---
6
+
7
+ # Risk Register Entry Generator
8
+
9
+ Create a new standing-risk file in `docs/risks/` following the format defined by `docs/risks/TEMPLATE.md`. The register captures persistent risks (distinct from the ephemeral per-change reports in `.risk-reports/`), and its criteria come from `RISK-POLICY.md`.
10
+
11
+ This skill is the invocation surface for populating the register (scaffolded by P033; populated per P102). Per ADR-015, it is a plugin-namespaced on-demand skill. Per ADR-014, the skill commits its own work.
12
+
13
+ ## Steps
14
+
15
+ ### 1. Discover existing risks
16
+
17
+ Scan for existing risk files:
18
+ - Glob `docs/risks/R*.md` (skip `README.md`, `TEMPLATE.md`)
19
+ - Note the highest numbered risk to determine the next sequence number
20
+ - Read any risks related to the topic being discussed (if the user has mentioned a topic)
21
+ - If `docs/risks/` does not exist, explain that `/wr-risk-scorer:update-policy` must be run first (it ships the scaffolding) and stop
22
+
23
+ ### 2. Gather context from the user
24
+
25
+ You MUST use the AskUserQuestion tool to collect context that cannot be derived. Do not proceed to step 3 until you have answers. Apply ADR-013 Rule 6 non-interactive defaults if the tool is unavailable (AFK mode): choose the most conservative option for each question and note auto-selection in the output.
26
+
27
+ Auto-derive where possible (do not ask):
28
+ - **ID number** — next free slot per step 3 (do not ask per `feedback_dont_ask_trivial_id_choices.md`).
29
+ - **Today's date** — use the current date for `Identified` and `Last reviewed`.
30
+ - **Category** — infer from description keywords where unambiguous: "token", "secret", "leak" → `infosec`; "install", "hook", "pipeline" → `operational`. Confirm only if ambiguous.
31
+ - **Next review** — default to 6 months from today.
32
+
33
+ Ask the user (one AskUserQuestion call with grouped questions):
34
+
35
+ 1. **What is the risk?** A short title and 1-2 paragraph description — what could go wrong, for whom, and why it matters. This is the condition, not the control.
36
+ 2. **Impact level (from `RISK-POLICY.md`)?** 1 Negligible · 2 Minor · 3 Moderate · 4 Significant · 5 Severe. Read the policy's Impact table to the user if they need the descriptions.
37
+ 3. **Likelihood level?** 1 Rare · 2 Unlikely · 3 Possible · 4 Likely · 5 Almost certain.
38
+ 4. **Existing controls?** Each control names what it does and where it is implemented (file path or `ADR-NNN`). If none, leave empty.
39
+ 5. **Residual impact and likelihood** (after controls). If controls are minimal, residual = inherent — do not fabricate reductions. Per ADR-026, quantitative reduction claims must cite evidence (test, hook gate, pipeline report). If no evidence, state "Residual same as inherent pending control evidence" in the Treatment section and set residual = inherent.
40
+ 6. **Treatment choice?** Accept · Mitigate · Transfer · Avoid. Include brief justification.
41
+ 7. **Owner?** Persona or role (e.g. `solo-developer`, `plugin-maintainer`, `tech-lead`).
42
+
43
+ If the user has already provided this context in the conversation (e.g. as arguments, or as part of a pipeline-finding hand-off), use what they have given and only ask about what is missing.
44
+
45
+ ### 3. Determine sequence number and filename
46
+
47
+ - Next number = **max of the local and origin highest risk numbers**, plus 1 (or 001 if none exist).
48
+ - Filename: `R<NNN>-<kebab-case-title>.active.md`
49
+ - Pad the number to 3 digits (001, 002, ... 010, 011, etc.)
50
+
51
+ **Why compare against origin?** Per ADR-019 confirmation criterion 2, ticket-creator skills MUST re-check next-number assignment against `git ls-tree origin/<base>` before assigning. Without it, parallel sessions can mint the same ID for different risks, causing a destructive surgical rebase on push.
52
+
53
+ ```bash
54
+ # Local-max number
55
+ local_max=$(ls docs/risks/R*.md 2>/dev/null | sed 's/.*\///' | grep -oE '^R[0-9]+' | sed 's/^R//' | sort -n | tail -1)
56
+
57
+ # Origin-max number — reads remote-tracking ref. `--name-only` required per P056
58
+ # to avoid false-matches on blob SHAs.
59
+ origin_max=$(git ls-tree --name-only origin/main docs/risks/ 2>/dev/null | sed 's|^docs/risks/||' | grep -oE '^R[0-9]+' | sed 's/^R//' | sort -n | tail -1)
60
+
61
+ # Take the max of the two and increment.
62
+ next=$(printf '%03d' $(( $(echo -e "${local_max:-0}\n${origin_max:-0}" | sort -n | tail -1) + 1 )))
63
+ ```
64
+
65
+ If the local choice would have collided with an origin risk file created since the last fetch, the `git ls-tree` lookup catches it here and the renumber is automatic. Log the renumber in the user-facing report (e.g. "Bumped next risk number from R012 → R013 to avoid collision with origin").
66
+
67
+ ### 4. Compute scores and bands
68
+
69
+ Use the Risk Matrix from `RISK-POLICY.md`:
70
+
71
+ - **Inherent Score** = Impact × Likelihood
72
+ - **Residual Score** = Impact × Likelihood (after controls)
73
+ - **Band** (for each) per the Label Bands table: 1-2 Very Low · 3-4 Low · 5-9 Medium · 10-16 High · 17-25 Very High
74
+ - **Within appetite?** = residual score ≤ `RISK-POLICY.md`'s appetite threshold (read the threshold at runtime; do not hardcode)
75
+
76
+ ### 5. Write the risk file
77
+
78
+ Write the file to `docs/risks/` using the structure from `TEMPLATE.md`:
79
+
80
+ ```markdown
81
+ # Risk R<NNN>: <Title>
82
+
83
+ **Status**: Active
84
+ **Category**: <infosec | operational | brand | delivery>
85
+ **Identified**: <YYYY-MM-DD>
86
+ **Owner**: <persona or role>
87
+ **Last reviewed**: <YYYY-MM-DD>
88
+ **Next review**: <YYYY-MM-DD + 6 months>
89
+
90
+ ## Description
91
+
92
+ <1-2 paragraph description from step 2.>
93
+
94
+ ## Inherent Risk
95
+
96
+ Impact × Likelihood *before* controls.
97
+
98
+ - **Impact**: <level> (<label>)
99
+ - **Likelihood**: <level> (<label>)
100
+ - **Inherent Score**: <product>
101
+ - **Inherent Band**: <band>
102
+
103
+ ## Controls
104
+
105
+ - **<control-name>** — <what it does>. Implemented in <file path or ADR-NNN>.
106
+
107
+ ## Residual Risk
108
+
109
+ Impact × Likelihood *after* controls.
110
+
111
+ - **Impact**: <level> (<label>)
112
+ - **Likelihood**: <level> (<label>)
113
+ - **Residual Score**: <product>
114
+ - **Residual Band**: <band>
115
+ - **Within appetite?**: <Yes | No>
116
+
117
+ ## Treatment
118
+
119
+ <Accept | Mitigate | Transfer | Avoid>. <Justification.>
120
+
121
+ ## Monitoring
122
+
123
+ - **Trigger to re-assess**: <event or threshold>
124
+ - **Metrics**: <if any>
125
+
126
+ ## Related
127
+
128
+ - Criteria: `RISK-POLICY.md`
129
+ - Realised-as: <links to `docs/problems/P<NNN>` if any>
130
+ - Treatment ADRs: <links if any>
131
+ - Personas affected: <links to `docs/jtbd/<persona>/persona.md`>
132
+
133
+ ## Change Log
134
+
135
+ - <YYYY-MM-DD>: Initial identification.
136
+ ```
137
+
138
+ ### 6. Update the register index
139
+
140
+ `docs/risks/README.md` has a **Register** table that MUST reflect the new risk. Append a row with the following columns:
141
+
142
+ ```
143
+ | R<NNN> | <Title> | <Category> | <Inherent Score> | <Residual Score> | <Treatment verb> | <Owner> | <Next review date> |
144
+ ```
145
+
146
+ This step is not optional: the README drifts from the register without it, and the ISO 27001 audit signal depends on the index being accurate.
147
+
148
+ ### 7. Confirm with the user
149
+
150
+ Present the written file path, inherent/residual bands, and any `Within appetite?: No` flag. Ask via AskUserQuestion:
151
+
152
+ 1. Does the description accurately capture the risk?
153
+ 2. Are the inherent and residual scores defensible?
154
+ 3. Is the treatment choice appropriate for the residual band?
155
+ 4. Should the owner or next review date be adjusted?
156
+
157
+ Apply any feedback by editing the file and re-updating the README row if scores/treatment change.
158
+
159
+ ### 8. Commit the risk (ADR-014)
160
+
161
+ Per ADR-014, this skill commits its own work. Stage both files and commit:
162
+
163
+ ```bash
164
+ git add docs/risks/R<NNN>-<title>.active.md docs/risks/README.md
165
+ git commit -m "docs(risks): open R<NNN> <title>"
166
+ ```
167
+
168
+ The commit message convention `docs(risks): open R<NNN> <title>` matches `docs/risks/README.md` step 6 and mirrors `docs(problems): open P<NNN>` used by `/wr-itil:manage-problem`.
169
+
170
+ If the commit-gate pattern-matches `git commit` text and blocks, run `/wr-risk-scorer:assess-release` first to produce a fresh pipeline marker, then retry the commit.
171
+
172
+ $ARGUMENTS