@windyroad/retrospective 0.17.0 → 0.18.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/hooks/hooks.json
CHANGED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# P159: PreToolUse:Bash hook — denies `git commit` invocations whose
|
|
3
|
+
# post-commit working tree exhibits JTBD-currency drift in any
|
|
4
|
+
# packages/<plugin>/README.md (no JTBD-NNN anchor, stale or
|
|
5
|
+
# deprecated-only citation, or skill directory missing from README).
|
|
6
|
+
#
|
|
7
|
+
# Hook-level enforcement at commit time replaces ADR-051 Phase 1's
|
|
8
|
+
# retro-time advisory surface (shipped under P158, df47ad1). The user
|
|
9
|
+
# correction (P159) and the architect verdict identified the retro
|
|
10
|
+
# surface as too late: the most-common drift class (contributor adds
|
|
11
|
+
# skill/hook/agent and forgets the README) ships in a commit that
|
|
12
|
+
# does not touch README.md, so a retro-time consumer sees the drift
|
|
13
|
+
# only after the contributor has already committed.
|
|
14
|
+
#
|
|
15
|
+
# Detection delegates to the existing detector script
|
|
16
|
+
# (`packages/retrospective/scripts/check-readme-jtbd-currency.sh`),
|
|
17
|
+
# invoked against the project's working tree (`./packages/` +
|
|
18
|
+
# `./docs/jtbd/`). The hook reads the detector's
|
|
19
|
+
# `TOTAL packages=<N> with_jtbd=<M> drift_instances=<K>` summary and
|
|
20
|
+
# denies when `drift_instances > 0`.
|
|
21
|
+
#
|
|
22
|
+
# Allow paths (exit 0 silently per ADR-045 Pattern 1):
|
|
23
|
+
# - tool_name != "Bash" (only Bash invocations are gated)
|
|
24
|
+
# - command does not contain `git commit` substring (non-commit
|
|
25
|
+
# Bash bypasses entirely — `git
|
|
26
|
+
# status`, `git log`, etc.)
|
|
27
|
+
# - BYPASS_JTBD_CURRENCY=1 (single-most-common legitimate
|
|
28
|
+
# escape — bypass-traceable via
|
|
29
|
+
# shell history)
|
|
30
|
+
# - outside a git work tree (adopter sessions outside the
|
|
31
|
+
# plugin monorepo)
|
|
32
|
+
# - no `./packages/` directory (project does not have ADR-051's
|
|
33
|
+
# structural anchor — adopter
|
|
34
|
+
# project shape; gate is a no-op)
|
|
35
|
+
# - no `./docs/jtbd/` directory (project has not run
|
|
36
|
+
# /wr-jtbd:update-guide; gate is a
|
|
37
|
+
# no-op)
|
|
38
|
+
# - detector exits non-zero (parse error / hostile env;
|
|
39
|
+
# fail-open per ADR-013 Rule 6)
|
|
40
|
+
# - detector emits no TOTAL line (no packages found; nothing to
|
|
41
|
+
# gate)
|
|
42
|
+
# - drift_instances == 0 (clean tree)
|
|
43
|
+
#
|
|
44
|
+
# Deny shape (per ADR-013 Rule 1 — deny redirects with mechanical
|
|
45
|
+
# recovery; ADR-045 deny-band ≤300 bytes):
|
|
46
|
+
# - Names the first offending plugin slug + drift hint vocabulary.
|
|
47
|
+
# - Names the wr-jtbd:agent recovery path AND the hand-edit fallback
|
|
48
|
+
# (graceful degradation when @windyroad/jtbd is not installed).
|
|
49
|
+
# - Names BYPASS_JTBD_CURRENCY=1 as the env-var escape.
|
|
50
|
+
# - Cites P159 for traceability.
|
|
51
|
+
# - Truncates the drift_hints CSV to the first hint to keep the
|
|
52
|
+
# deny-band ≤300 bytes for worst-case slug + hint combinations.
|
|
53
|
+
#
|
|
54
|
+
# Cost: one invocation of `check-readme-jtbd-currency.sh` per `git
|
|
55
|
+
# commit` (~80–150ms in the worst case across 12 plugin READMEs +
|
|
56
|
+
# ~30 JTBD job files; per the architect's ADR-023 perf review at
|
|
57
|
+
# Phase 1 design time). Per-invocation deterministic; no marker
|
|
58
|
+
# (mirrors P125 `staging-detect.sh` and P141
|
|
59
|
+
# `itil-changeset-discipline.sh` precedent — architect-approved
|
|
60
|
+
# no-marker design when detection cost stays under ~150ms).
|
|
61
|
+
#
|
|
62
|
+
# References:
|
|
63
|
+
# ADR-005 — plugin testing strategy (hook bats live under
|
|
64
|
+
# `packages/<plugin>/hooks/test/`).
|
|
65
|
+
# ADR-013 Rule 1 — deny redirects with mechanical recovery (the
|
|
66
|
+
# deny names the wr-jtbd:agent recovery, the hand-edit
|
|
67
|
+
# fallback, and the BYPASS env override).
|
|
68
|
+
# ADR-013 Rule 6 — non-interactive fail-safe (fail-open outside a
|
|
69
|
+
# git work tree, on parse error, in projects lacking
|
|
70
|
+
# ADR-051 anchors, and on detector failure).
|
|
71
|
+
# ADR-014 — governance skills commit their own work (this hook
|
|
72
|
+
# keeps iter commits self-contained).
|
|
73
|
+
# ADR-018 — inter-iteration release cadence (the hook strengthens
|
|
74
|
+
# release-cadence integrity by ensuring every publishable
|
|
75
|
+
# iter has a current README before commit).
|
|
76
|
+
# ADR-038 — progressive disclosure / deny-message terseness budget.
|
|
77
|
+
# ADR-045 — hook injection budget (Pattern 1 silent-on-pass; deny
|
|
78
|
+
# band ≤300 bytes for this hook).
|
|
79
|
+
# ADR-051 — JTBD-anchored README rule (this hook is the load-
|
|
80
|
+
# bearing-from-the-start commit-gate surface; supersedes
|
|
81
|
+
# retro-time advisory consumption as primary).
|
|
82
|
+
# ADR-052 — behavioural-tests default (bats fixture asserts on
|
|
83
|
+
# emitted JSON, not source content).
|
|
84
|
+
# P081 — behavioural tests preferred over structural greps.
|
|
85
|
+
# P125 — sibling staging-trap helper (per-invocation no-marker).
|
|
86
|
+
# P141 — sibling changeset-discipline gate on `git commit` (same
|
|
87
|
+
# hook shape).
|
|
88
|
+
# P158 — retro Step 2b wiring (backup advisory; survives this
|
|
89
|
+
# hook's primary-surface migration).
|
|
90
|
+
# P159 — this hook.
|
|
91
|
+
|
|
92
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
93
|
+
DETECTOR="$SCRIPT_DIR/../scripts/check-readme-jtbd-currency.sh"
|
|
94
|
+
|
|
95
|
+
INPUT=$(cat)
|
|
96
|
+
|
|
97
|
+
TOOL_NAME=$(echo "$INPUT" | python3 -c "
|
|
98
|
+
import sys, json
|
|
99
|
+
try:
|
|
100
|
+
data = json.load(sys.stdin)
|
|
101
|
+
print(data.get('tool_name', ''))
|
|
102
|
+
except:
|
|
103
|
+
print('')
|
|
104
|
+
" 2>/dev/null || echo "")
|
|
105
|
+
|
|
106
|
+
# Only gate Bash. Non-Bash tools bypass entirely.
|
|
107
|
+
if [ "$TOOL_NAME" != "Bash" ]; then
|
|
108
|
+
exit 0
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
COMMAND=$(echo "$INPUT" | python3 -c "
|
|
112
|
+
import sys, json
|
|
113
|
+
try:
|
|
114
|
+
data = json.load(sys.stdin)
|
|
115
|
+
print(data.get('tool_input', {}).get('command', ''))
|
|
116
|
+
except:
|
|
117
|
+
print('')
|
|
118
|
+
" 2>/dev/null || echo "")
|
|
119
|
+
|
|
120
|
+
# Only fire on `git commit` invocations. Substring match catches common
|
|
121
|
+
# shapes (`git commit -m`, `git commit --amend`, leading `cd && git
|
|
122
|
+
# commit`, `chore: version packages` release commits routed via
|
|
123
|
+
# `git commit -m 'chore: version packages'`, etc.) without
|
|
124
|
+
# over-matching unrelated bash.
|
|
125
|
+
case "$COMMAND" in
|
|
126
|
+
*"git commit"*) ;;
|
|
127
|
+
*) exit 0 ;;
|
|
128
|
+
esac
|
|
129
|
+
|
|
130
|
+
# Bypass via env var — single most-common legitimate escape.
|
|
131
|
+
if [ "${BYPASS_JTBD_CURRENCY:-}" = "1" ]; then
|
|
132
|
+
exit 0
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
# Fail-open if not inside a git working tree.
|
|
136
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
137
|
+
|
|
138
|
+
# Fail-open if the project lacks ADR-051's structural anchors.
|
|
139
|
+
# Adopter projects without `./packages/` or `./docs/jtbd/` are not
|
|
140
|
+
# subject to the rule; the hook is a no-op for them.
|
|
141
|
+
[ -d "./packages" ] || exit 0
|
|
142
|
+
[ -d "./docs/jtbd" ] || exit 0
|
|
143
|
+
|
|
144
|
+
# Fail-open if the detector script itself is missing (defensive —
|
|
145
|
+
# hook + detector ship together, but install-time corruption or
|
|
146
|
+
# adopter-side patching should not block legitimate commits).
|
|
147
|
+
[ -x "$DETECTOR" ] || exit 0
|
|
148
|
+
|
|
149
|
+
# Run the detector. Capture exit code + output. Fail-open on detector
|
|
150
|
+
# error (exit != 0).
|
|
151
|
+
DETECTOR_OUTPUT=$(bash "$DETECTOR" "./packages" "./docs/jtbd" 2>/dev/null) || exit 0
|
|
152
|
+
|
|
153
|
+
# Parse the TOTAL summary line. If absent, no packages were
|
|
154
|
+
# enumerated — fail-open (no drift to report).
|
|
155
|
+
TOTAL_LINE=$(echo "$DETECTOR_OUTPUT" | grep -E '^TOTAL packages=' | tail -n1)
|
|
156
|
+
[ -n "$TOTAL_LINE" ] || exit 0
|
|
157
|
+
|
|
158
|
+
# Extract drift_instances=<K>.
|
|
159
|
+
DRIFT_INSTANCES=$(echo "$TOTAL_LINE" | grep -oE 'drift_instances=[0-9]+' | head -n1 | cut -d'=' -f2)
|
|
160
|
+
[ -n "$DRIFT_INSTANCES" ] || exit 0
|
|
161
|
+
|
|
162
|
+
# Allow path: clean tree.
|
|
163
|
+
if [ "$DRIFT_INSTANCES" -eq 0 ]; then
|
|
164
|
+
exit 0
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
# Drift detected — extract first offending package + its drift hints
|
|
168
|
+
# for the deny message. The detector emits one "README package=<name>
|
|
169
|
+
# ... drift_hints=<csv>" line per package; we name the first one with
|
|
170
|
+
# a non-empty drift_hints.
|
|
171
|
+
OFFENDING_LINE=$(echo "$DETECTOR_OUTPUT" | grep -E '^README package=' | grep -vE 'drift_hints=$' | head -n1)
|
|
172
|
+
OFFENDING_SLUG=$(echo "$OFFENDING_LINE" | grep -oE 'package=[A-Za-z0-9_-]+' | head -n1 | cut -d'=' -f2)
|
|
173
|
+
OFFENDING_HINTS=$(echo "$OFFENDING_LINE" | grep -oE 'drift_hints=[A-Za-z0-9,_-]+' | head -n1 | cut -d'=' -f2)
|
|
174
|
+
|
|
175
|
+
# Fall back to a generic name if parsing failed (shouldn't happen but
|
|
176
|
+
# defensive).
|
|
177
|
+
[ -n "$OFFENDING_SLUG" ] || OFFENDING_SLUG="(unknown)"
|
|
178
|
+
[ -n "$OFFENDING_HINTS" ] || OFFENDING_HINTS="drift"
|
|
179
|
+
|
|
180
|
+
# Truncate the hints CSV to the first hint. Multi-hint cases (e.g.
|
|
181
|
+
# both `missing-jtbd-section` and `skill-inventory-drift` on one
|
|
182
|
+
# package) are bounded so the deny-band stays under 300 bytes for
|
|
183
|
+
# worst-case slug + hint combinations.
|
|
184
|
+
PRIMARY_HINT="${OFFENDING_HINTS%%,*}"
|
|
185
|
+
|
|
186
|
+
# Deny — voice/tone budget per ADR-045 deny-band ≤300 bytes total
|
|
187
|
+
# (envelope ~137 bytes; REASON ~163 bytes for worst-case slug +
|
|
188
|
+
# hint). Names the offending plugin slug, the primary drift hint,
|
|
189
|
+
# the wr-jtbd:agent recovery path with hand-edit fallback (graceful
|
|
190
|
+
# degradation per architect F advisory), the BYPASS env, and the
|
|
191
|
+
# P159 cite.
|
|
192
|
+
REASON="BLOCKED: P159 JTBD drift in ${OFFENDING_SLUG} (${PRIMARY_HINT}). Recovery: wr-jtbd:agent OR cite a JTBD-NNN in README. Bypass: BYPASS_JTBD_CURRENCY=1."
|
|
193
|
+
|
|
194
|
+
cat <<EOF
|
|
195
|
+
{
|
|
196
|
+
"hookSpecificOutput": {
|
|
197
|
+
"hookEventName": "PreToolUse",
|
|
198
|
+
"permissionDecision": "deny",
|
|
199
|
+
"permissionDecisionReason": "${REASON}"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
EOF
|
|
203
|
+
exit 0
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# P159: retrospective-readme-jtbd-currency.sh PreToolUse:Bash hook must
|
|
4
|
+
# deny `git commit` invocations whose post-commit working tree exhibits
|
|
5
|
+
# JTBD-currency drift (no JTBD-NNN anchor in a plugin README, stale or
|
|
6
|
+
# deprecated-only citations, or skills/<dir>/ missing from the README).
|
|
7
|
+
# Hook-level enforcement at commit time replaces ADR-051 Phase 1's
|
|
8
|
+
# retro-time advisory surface, which the user correction (P159) and the
|
|
9
|
+
# architect verdict identify as too late: the most-common drift class
|
|
10
|
+
# (contributor adds skill/hook/agent and forgets the README) ships in a
|
|
11
|
+
# commit that doesn't touch README.md, so a retro-time consumer sees the
|
|
12
|
+
# drift only after the contributor has already committed.
|
|
13
|
+
#
|
|
14
|
+
# Detection delegates to the existing
|
|
15
|
+
# `packages/retrospective/scripts/check-readme-jtbd-currency.sh`
|
|
16
|
+
# detector, which the hook invokes against the project's working tree
|
|
17
|
+
# (`./packages/` + `./docs/jtbd/`). The hook reads the detector's
|
|
18
|
+
# `TOTAL packages=<N> with_jtbd=<M> drift_instances=<K>` summary line
|
|
19
|
+
# and denies when `drift_instances > 0`.
|
|
20
|
+
#
|
|
21
|
+
# Per ADR-005 (plugin testing strategy) — hook bats live under
|
|
22
|
+
# packages/<plugin>/hooks/test/ and assert on emitted JSON, not source
|
|
23
|
+
# content. Per ADR-052 / P081 — behavioural; no source greps. Per
|
|
24
|
+
# ADR-045 Pattern 1 — allow paths emit 0 bytes; deny-band ≤300 bytes.
|
|
25
|
+
# Per ADR-013 Rule 1 — deny redirects to mechanical recovery (here: the
|
|
26
|
+
# wr-jtbd:agent for prose-weaving guidance, with hand-edit fallback).
|
|
27
|
+
# Per ADR-013 Rule 6 — fail-open outside a git work tree, on parse
|
|
28
|
+
# errors, or in projects without ADR-051's structural anchors
|
|
29
|
+
# (./packages/ or ./docs/jtbd/) so adopter projects are not blocked.
|
|
30
|
+
|
|
31
|
+
setup() {
|
|
32
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
33
|
+
HOOK="$SCRIPT_DIR/retrospective-readme-jtbd-currency.sh"
|
|
34
|
+
ORIG_DIR="$PWD"
|
|
35
|
+
TEST_DIR=$(mktemp -d)
|
|
36
|
+
cd "$TEST_DIR"
|
|
37
|
+
git init --quiet -b main
|
|
38
|
+
git config user.email "test@example.com"
|
|
39
|
+
git config user.name "Test"
|
|
40
|
+
echo "seed" > seed.txt
|
|
41
|
+
git add seed.txt
|
|
42
|
+
git -c commit.gpgsign=false commit --quiet -m "initial"
|
|
43
|
+
unset BYPASS_JTBD_CURRENCY
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
teardown() {
|
|
47
|
+
cd "$ORIG_DIR"
|
|
48
|
+
rm -rf "$TEST_DIR"
|
|
49
|
+
unset BYPASS_JTBD_CURRENCY
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
run_bash_hook() {
|
|
53
|
+
local cmd="$1"
|
|
54
|
+
local json
|
|
55
|
+
json=$(printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$cmd")
|
|
56
|
+
echo "$json" | bash "$HOOK"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Helper: build a stub project layout with a drifted plugin README.
|
|
60
|
+
# README has no JTBD-NNN citation; jtbd dir has one resolving job.
|
|
61
|
+
make_drifted_project() {
|
|
62
|
+
mkdir -p packages/stub docs/jtbd/plugin-user
|
|
63
|
+
printf '%s\n' "# @windyroad/stub" "no jtbd anchor here" > packages/stub/README.md
|
|
64
|
+
cat > docs/jtbd/plugin-user/JTBD-302-trust-readme.proposed.md <<'EOF'
|
|
65
|
+
---
|
|
66
|
+
status: proposed
|
|
67
|
+
job-id: trust-readme
|
|
68
|
+
persona: plugin-user
|
|
69
|
+
date-created: 2026-05-04
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
# JTBD-302
|
|
73
|
+
EOF
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Helper: build a stub project layout with a clean plugin README.
|
|
77
|
+
# README cites a resolving JTBD-NNN; no skill drift.
|
|
78
|
+
make_clean_project() {
|
|
79
|
+
mkdir -p packages/stub docs/jtbd/plugin-user
|
|
80
|
+
printf '%s\n' "# @windyroad/stub" "Serves JTBD-302." > packages/stub/README.md
|
|
81
|
+
cat > docs/jtbd/plugin-user/JTBD-302-trust-readme.proposed.md <<'EOF'
|
|
82
|
+
---
|
|
83
|
+
status: proposed
|
|
84
|
+
job-id: trust-readme
|
|
85
|
+
persona: plugin-user
|
|
86
|
+
date-created: 2026-05-04
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
# JTBD-302
|
|
90
|
+
EOF
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Helper: build a stub project with skill-inventory-drift —
|
|
94
|
+
# packages/stub/skills/orphan/ exists but README doesn't name "orphan".
|
|
95
|
+
make_skill_drift_project() {
|
|
96
|
+
mkdir -p packages/stub/skills/orphan docs/jtbd/plugin-user
|
|
97
|
+
printf '%s\n' "# @windyroad/stub" "Serves JTBD-302." > packages/stub/README.md
|
|
98
|
+
cat > docs/jtbd/plugin-user/JTBD-302-trust-readme.proposed.md <<'EOF'
|
|
99
|
+
---
|
|
100
|
+
status: proposed
|
|
101
|
+
job-id: trust-readme
|
|
102
|
+
persona: plugin-user
|
|
103
|
+
date-created: 2026-05-04
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
# JTBD-302
|
|
107
|
+
EOF
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# ── Trap detection: deny when drift detected ───────────────────────────────
|
|
111
|
+
|
|
112
|
+
@test "deny: drifted README (no JTBD-NNN cite) on git commit triggers deny" {
|
|
113
|
+
make_drifted_project
|
|
114
|
+
run run_bash_hook "git commit -m 'feat'"
|
|
115
|
+
[ "$status" -eq 0 ]
|
|
116
|
+
[[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
|
|
117
|
+
[[ "$output" == *"P159"* ]]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@test "deny: skill-inventory-drift on git commit triggers deny" {
|
|
121
|
+
make_skill_drift_project
|
|
122
|
+
run run_bash_hook "git commit -m 'feat'"
|
|
123
|
+
[ "$status" -eq 0 ]
|
|
124
|
+
[[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@test "deny message names the offending plugin slug" {
|
|
128
|
+
make_drifted_project
|
|
129
|
+
run run_bash_hook "git commit -m 'feat'"
|
|
130
|
+
[ "$status" -eq 0 ]
|
|
131
|
+
[[ "$output" == *"stub"* ]]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@test "deny message names the wr-jtbd:agent recovery path" {
|
|
135
|
+
make_drifted_project
|
|
136
|
+
run run_bash_hook "git commit -m 'feat'"
|
|
137
|
+
[ "$status" -eq 0 ]
|
|
138
|
+
[[ "$output" == *"wr-jtbd"* ]]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@test "deny message stays under ADR-045 deny-band (<300 bytes)" {
|
|
142
|
+
make_drifted_project
|
|
143
|
+
run run_bash_hook "git commit -m 'feat'"
|
|
144
|
+
[ "$status" -eq 0 ]
|
|
145
|
+
[ "${#output}" -lt 300 ]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@test "deny: chore release commit (chore: version packages) is subject to the gate" {
|
|
149
|
+
make_drifted_project
|
|
150
|
+
run run_bash_hook "chore: version packages"
|
|
151
|
+
# Not a `git commit` invocation — should NOT trigger deny because the
|
|
152
|
+
# command field is the message, not the invocation. The actual release
|
|
153
|
+
# path runs `git commit` which IS gated; verify that shape:
|
|
154
|
+
[ "$status" -eq 0 ]
|
|
155
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
156
|
+
|
|
157
|
+
# Now the canonical release commit shape:
|
|
158
|
+
run run_bash_hook "git commit -m 'chore: version packages'"
|
|
159
|
+
[ "$status" -eq 0 ]
|
|
160
|
+
[[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@test "deny: git commit --amend on drifted tree also triggers deny" {
|
|
164
|
+
make_drifted_project
|
|
165
|
+
run run_bash_hook "git commit --amend --no-edit"
|
|
166
|
+
[ "$status" -eq 0 ]
|
|
167
|
+
[[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# ── Allow paths: each non-trap shape must NOT deny ─────────────────────────
|
|
171
|
+
|
|
172
|
+
@test "allow: clean README (cites resolving JTBD-NNN) on git commit allows the commit" {
|
|
173
|
+
make_clean_project
|
|
174
|
+
run run_bash_hook "git commit -m 'feat'"
|
|
175
|
+
[ "$status" -eq 0 ]
|
|
176
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@test "allow: BYPASS_JTBD_CURRENCY=1 env var allows drifted commit" {
|
|
180
|
+
make_drifted_project
|
|
181
|
+
BYPASS_JTBD_CURRENCY=1 run run_bash_hook "git commit -m 'feat'"
|
|
182
|
+
[ "$status" -eq 0 ]
|
|
183
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@test "allow: non-Bash tool exits 0 without deny" {
|
|
187
|
+
make_drifted_project
|
|
188
|
+
run bash -c "echo '{\"tool_name\":\"Edit\",\"tool_input\":{\"file_path\":\"foo.md\"}}' | bash $HOOK"
|
|
189
|
+
[ "$status" -eq 0 ]
|
|
190
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@test "allow: Bash command that is NOT git commit (e.g., git status) bypasses detection" {
|
|
194
|
+
make_drifted_project
|
|
195
|
+
run run_bash_hook "git status"
|
|
196
|
+
[ "$status" -eq 0 ]
|
|
197
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# ── Fail-open contracts (ADR-013 Rule 6) ───────────────────────────────────
|
|
201
|
+
|
|
202
|
+
@test "allow: outside a git work tree exits 0 without deny (fail-open)" {
|
|
203
|
+
cd "$ORIG_DIR"
|
|
204
|
+
TEMP_NONGIT=$(mktemp -d)
|
|
205
|
+
cd "$TEMP_NONGIT"
|
|
206
|
+
run run_bash_hook "git commit -m 'feat'"
|
|
207
|
+
cd "$TEST_DIR"
|
|
208
|
+
rm -rf "$TEMP_NONGIT"
|
|
209
|
+
[ "$status" -eq 0 ]
|
|
210
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@test "allow: project without packages/ dir exits 0 without deny (fail-open)" {
|
|
214
|
+
# No packages/, no docs/jtbd/ — adopter project shape.
|
|
215
|
+
run run_bash_hook "git commit -m 'feat'"
|
|
216
|
+
[ "$status" -eq 0 ]
|
|
217
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
@test "allow: project with packages/ but no docs/jtbd/ exits 0 without deny (fail-open)" {
|
|
221
|
+
mkdir -p packages/stub
|
|
222
|
+
echo "# stub" > packages/stub/README.md
|
|
223
|
+
run run_bash_hook "git commit -m 'feat'"
|
|
224
|
+
[ "$status" -eq 0 ]
|
|
225
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@test "allow: empty JSON exits 0 without deny (fail-open on parse-incomplete)" {
|
|
229
|
+
make_drifted_project
|
|
230
|
+
run bash -c "echo '{}' | bash $HOOK"
|
|
231
|
+
[ "$status" -eq 0 ]
|
|
232
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@test "allow: malformed JSON exits 0 without deny (fail-open on parse error)" {
|
|
236
|
+
make_drifted_project
|
|
237
|
+
run bash -c "echo 'not-json' | bash $HOOK"
|
|
238
|
+
[ "$status" -eq 0 ]
|
|
239
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# ── Allow path silence (ADR-045 Pattern 1) ─────────────────────────────────
|
|
243
|
+
|
|
244
|
+
@test "allow path on clean tree emits 0 bytes (ADR-045 Pattern 1 silent-on-pass)" {
|
|
245
|
+
make_clean_project
|
|
246
|
+
run run_bash_hook "git commit -m 'feat'"
|
|
247
|
+
[ "$status" -eq 0 ]
|
|
248
|
+
[ "${#output}" -eq 0 ]
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
@test "allow path on non-Bash tool emits 0 bytes (silent-on-pass)" {
|
|
252
|
+
make_drifted_project
|
|
253
|
+
run bash -c "echo '{\"tool_name\":\"Edit\",\"tool_input\":{\"file_path\":\"foo.md\"}}' | bash $HOOK"
|
|
254
|
+
[ "$status" -eq 0 ]
|
|
255
|
+
[ "${#output}" -eq 0 ]
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
@test "allow path on non-commit Bash emits 0 bytes (silent-on-pass)" {
|
|
259
|
+
make_drifted_project
|
|
260
|
+
run run_bash_hook "git status"
|
|
261
|
+
[ "$status" -eq 0 ]
|
|
262
|
+
[ "${#output}" -eq 0 ]
|
|
263
|
+
}
|