@windyroad/jtbd 0.12.1 → 0.12.2-preview.578
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.
|
@@ -13,6 +13,111 @@ _mtime() { stat -c%Y "$1" 2>/dev/null || /usr/bin/stat -f%m "$1" 2>/dev/null ||
|
|
|
13
13
|
# Portable hash: tries md5sum, falls back to md5 -r, then shasum
|
|
14
14
|
_hashcmd() { md5sum 2>/dev/null || md5 -r 2>/dev/null || shasum 2>/dev/null; }
|
|
15
15
|
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Substance-aware drift hash + atomic verdict-write (ADR-009 amendment
|
|
18
|
+
# 2026-06-06, P353 + P303 close).
|
|
19
|
+
#
|
|
20
|
+
# `_substance_hash_path` normalises trivial/no-op edits BEFORE hashing so a
|
|
21
|
+
# PASS marker survives whitespace / CRLF / trailing-newline edits while still
|
|
22
|
+
# detecting substantive policy changes. Conservative boundary: when in doubt
|
|
23
|
+
# whether an edit is trivial vs substantive, this helper treats it as
|
|
24
|
+
# substantive (re-review fires). Only whitespace + line-ending + trailing-
|
|
25
|
+
# newline are normalised in this iteration — single-numeral edits and
|
|
26
|
+
# frontmatter-key changes are intentionally NOT normalised. See ADR-009
|
|
27
|
+
# 2026-06-06 amendment for the ratified contract.
|
|
28
|
+
#
|
|
29
|
+
# `_atomic_mark_with_hash` writes the marker + hash file as an atomic pair
|
|
30
|
+
# (mktemp + mv) so a PASS NEVER silently fails to persist (the empirically-
|
|
31
|
+
# measured P353 failure mode that forced BYPASS_RISK_GATE=1 on every
|
|
32
|
+
# external-comms gate clearance). Either both files land, or neither does.
|
|
33
|
+
# Non-zero exit on failure so callers can emit a diagnostic.
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
# Substance-aware hash of a file or directory path.
|
|
37
|
+
# For directories: hashes the concatenated content of all *.md files
|
|
38
|
+
# (excluding README.md) in sorted order.
|
|
39
|
+
# For files: hashes the file content.
|
|
40
|
+
# Normalisation BEFORE hashing: CRLF → LF, strip trailing whitespace per
|
|
41
|
+
# line, normalise trailing whitespace to a single \n.
|
|
42
|
+
# Echoes "missing" for paths that do not exist (drop-in equivalence with the
|
|
43
|
+
# pre-amendment `cat | _hashcmd | cut -d' ' -f1` site behaviour).
|
|
44
|
+
# Echoes a hex sha256 of the normalised content on success.
|
|
45
|
+
_substance_hash_path() {
|
|
46
|
+
local path="$1"
|
|
47
|
+
if [ -z "$path" ]; then
|
|
48
|
+
echo "missing"
|
|
49
|
+
return 0
|
|
50
|
+
fi
|
|
51
|
+
if [ -f "$path" ]; then
|
|
52
|
+
cat "$path" 2>/dev/null | _substance_normalize_then_hash
|
|
53
|
+
elif [ -d "$path" ]; then
|
|
54
|
+
find "$path" -name '*.md' -not -name 'README.md' -print0 \
|
|
55
|
+
| sort -z \
|
|
56
|
+
| xargs -0 cat 2>/dev/null \
|
|
57
|
+
| _substance_normalize_then_hash
|
|
58
|
+
else
|
|
59
|
+
echo "missing"
|
|
60
|
+
fi
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Internal: reads from stdin, normalises whitespace + line endings, emits a
|
|
64
|
+
# hex sha256 of the normalised content. Conservative boundary documented in
|
|
65
|
+
# ADR-009 2026-06-06 amendment: ambiguous edits stay substantive.
|
|
66
|
+
_substance_normalize_then_hash() {
|
|
67
|
+
python3 -c "
|
|
68
|
+
import sys, hashlib
|
|
69
|
+
data = sys.stdin.buffer.read().decode('utf-8', errors='replace')
|
|
70
|
+
# CRLF / CR -> LF
|
|
71
|
+
data = data.replace('\r\n', '\n').replace('\r', '\n')
|
|
72
|
+
# Strip trailing whitespace per line.
|
|
73
|
+
lines = [line.rstrip() for line in data.split('\n')]
|
|
74
|
+
# Re-join and normalise trailing whitespace to a single \n.
|
|
75
|
+
normalised = '\n'.join(lines).rstrip() + '\n'
|
|
76
|
+
print(hashlib.sha256(normalised.encode('utf-8')).hexdigest())
|
|
77
|
+
" 2>/dev/null || echo "missing"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Atomically write a presence marker + its paired hash file. Either both
|
|
81
|
+
# files land or neither does. Returns 0 on success, 1 on failure. On failure
|
|
82
|
+
# any partial state is rolled back.
|
|
83
|
+
# Usage: _atomic_mark_with_hash "/tmp/architect-reviewed-${SID}" "$HASH"
|
|
84
|
+
_atomic_mark_with_hash() {
|
|
85
|
+
local marker="$1"
|
|
86
|
+
local hash="$2"
|
|
87
|
+
local hash_file="${marker}.hash"
|
|
88
|
+
|
|
89
|
+
if [ -z "$marker" ]; then
|
|
90
|
+
return 1
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
local htmp="${hash_file}.tmp.$$.${RANDOM:-0}"
|
|
94
|
+
local mtmp="${marker}.tmp.$$.${RANDOM:-0}"
|
|
95
|
+
|
|
96
|
+
# Write hash to tempfile.
|
|
97
|
+
if ! printf '%s\n' "$hash" > "$htmp" 2>/dev/null; then
|
|
98
|
+
rm -f "$htmp"
|
|
99
|
+
return 1
|
|
100
|
+
fi
|
|
101
|
+
# Write empty marker to tempfile.
|
|
102
|
+
if ! : > "$mtmp" 2>/dev/null; then
|
|
103
|
+
rm -f "$htmp" "$mtmp"
|
|
104
|
+
return 1
|
|
105
|
+
fi
|
|
106
|
+
# Atomic rename: hash file first.
|
|
107
|
+
if ! mv -f "$htmp" "$hash_file" 2>/dev/null; then
|
|
108
|
+
rm -f "$htmp" "$mtmp"
|
|
109
|
+
return 1
|
|
110
|
+
fi
|
|
111
|
+
# Atomic rename: marker second. If this fails, roll back the hash file
|
|
112
|
+
# so we never observe a hash-without-marker half-state.
|
|
113
|
+
if ! mv -f "$mtmp" "$marker" 2>/dev/null; then
|
|
114
|
+
rm -f "$mtmp"
|
|
115
|
+
rm -f "$hash_file"
|
|
116
|
+
return 1
|
|
117
|
+
fi
|
|
118
|
+
return 0
|
|
119
|
+
}
|
|
120
|
+
|
|
16
121
|
# Paths excluded from pipeline state hashing and docs-only detection.
|
|
17
122
|
_doc_exclusions() {
|
|
18
123
|
echo ':!docs/' ':!.risk-reports/' ':!.changeset/' ':!governance/' ':!.claude/plans/' ':!CLAUDE.md' ':!AGENTS.md' ':!PRINCIPLES.md' ':!DECISION-MANAGEMENT.md' ':!AGENTIC_RISK_REGISTER.md' ':!PROBLEM-MANAGEMENT.md'
|
package/hooks/lib/review-gate.sh
CHANGED
|
@@ -34,18 +34,15 @@ check_review_gate() {
|
|
|
34
34
|
return 1
|
|
35
35
|
fi
|
|
36
36
|
|
|
37
|
-
# 3. Drift detection — policy
|
|
37
|
+
# 3. Drift detection — substance-aware policy hash must match
|
|
38
|
+
# (ADR-009 amendment 2026-06-06: trivial whitespace / line-ending /
|
|
39
|
+
# trailing-newline edits do NOT trigger drift; substantive policy
|
|
40
|
+
# changes DO. Conservative boundary — ambiguous edits stay substantive.
|
|
41
|
+
# See gate-helpers.sh::_substance_hash_path.)
|
|
38
42
|
if [ -f "$HASH_FILE" ] && [ -n "$POLICY_FILE" ]; then
|
|
39
43
|
local STORED_HASH=$(cat "$HASH_FILE")
|
|
40
|
-
local CURRENT_HASH
|
|
41
|
-
|
|
42
|
-
CURRENT_HASH=$(cat "$POLICY_FILE" | _hashcmd | cut -d' ' -f1)
|
|
43
|
-
elif [ -d "$POLICY_FILE" ]; then
|
|
44
|
-
# Directory (e.g., docs/decisions/) — hash all .md files
|
|
45
|
-
CURRENT_HASH=$(find "$POLICY_FILE" -name '*.md' -not -name 'README.md' -print0 | sort -z | xargs -0 cat 2>/dev/null | _hashcmd | cut -d' ' -f1)
|
|
46
|
-
else
|
|
47
|
-
CURRENT_HASH="missing"
|
|
48
|
-
fi
|
|
44
|
+
local CURRENT_HASH
|
|
45
|
+
CURRENT_HASH=$(_substance_hash_path "$POLICY_FILE")
|
|
49
46
|
if [ "$STORED_HASH" != "$CURRENT_HASH" ]; then
|
|
50
47
|
rm -f "$MARKER" "$HASH_FILE"
|
|
51
48
|
REVIEW_GATE_REASON="${SYSTEM} policy file changed since last review. Re-run the ${SYSTEM} agent."
|
|
@@ -59,24 +56,29 @@ check_review_gate() {
|
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
# Store policy file hash after a successful review.
|
|
59
|
+
# Routes the marker + hash write through `_atomic_mark_with_hash` so a PASS
|
|
60
|
+
# never silently fails to persist (ADR-009 amendment 2026-06-06: closes the
|
|
61
|
+
# "marker doesn't land after PASS" failure mode P353 measured as ~12
|
|
62
|
+
# subagent invocations + 3 BYPASS_RISK_GATE=1 uses per 3-filing session).
|
|
62
63
|
# Usage: store_review_hash "$SESSION_ID" "style-guide" "docs/STYLE-GUIDE.md"
|
|
63
64
|
store_review_hash() {
|
|
64
65
|
local SESSION_ID="$1"
|
|
65
66
|
local SYSTEM="$2"
|
|
66
67
|
local POLICY_FILE="$3"
|
|
67
|
-
local
|
|
68
|
+
local MARKER="/tmp/${SYSTEM}-reviewed-${SESSION_ID}"
|
|
68
69
|
|
|
69
70
|
if [ -n "$POLICY_FILE" ]; then
|
|
70
|
-
local HASH
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
local HASH
|
|
72
|
+
HASH=$(_substance_hash_path "$POLICY_FILE")
|
|
73
|
+
# Atomic: marker + hash either both land or neither does.
|
|
74
|
+
if ! _atomic_mark_with_hash "$MARKER" "$HASH"; then
|
|
75
|
+
# Diagnostic on failure — surface the silent-fail mode the
|
|
76
|
+
# pre-amendment `touch + echo > .hash` pair hid.
|
|
77
|
+
echo "WARN: ${SYSTEM}-mark-reviewed atomic marker-write failed for ${MARKER}" >&2
|
|
78
|
+
return 1
|
|
77
79
|
fi
|
|
78
|
-
echo "$HASH" > "$HASH_FILE"
|
|
79
80
|
fi
|
|
81
|
+
return 0
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
# Emit fail-closed deny JSON for PreToolUse hooks.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Behavioural tests for ADR-009 amendment 2026-06-06: substance-aware drift +
|
|
4
|
+
# atomic verdict-write — JTBD gate. Closes P353 (hash-marker brittleness
|
|
5
|
+
# umbrella) for the review-gate.sh code path shared by JTBD / voice-tone /
|
|
6
|
+
# style-guide.
|
|
7
|
+
#
|
|
8
|
+
# Cases ratified 2026-06-06: see substance-aware-drift.bats in the architect
|
|
9
|
+
# package for the full contract. JTBD's tests cover the same four cases
|
|
10
|
+
# through `check_review_gate` + `store_review_hash`.
|
|
11
|
+
|
|
12
|
+
setup() {
|
|
13
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
14
|
+
source "$SCRIPT_DIR/lib/review-gate.sh"
|
|
15
|
+
|
|
16
|
+
TEST_SESSION="bats-jtbd-substance-$$-${BATS_TEST_NUMBER}"
|
|
17
|
+
SYSTEM="jtbd"
|
|
18
|
+
TEST_DIR=$(mktemp -d -t substance-aware-drift-jtbd.XXXXXX)
|
|
19
|
+
mkdir -p "$TEST_DIR/docs/jtbd"
|
|
20
|
+
POLICY_DIR="$TEST_DIR/docs/jtbd"
|
|
21
|
+
|
|
22
|
+
MARKER="/tmp/${SYSTEM}-reviewed-${TEST_SESSION}"
|
|
23
|
+
HASH_FILE="${MARKER}.hash"
|
|
24
|
+
rm -f "$MARKER" "$HASH_FILE"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
teardown() {
|
|
28
|
+
rm -f "$MARKER" "$HASH_FILE"
|
|
29
|
+
rm -rf "$TEST_DIR"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_install_and_mark() {
|
|
33
|
+
local body="$1"
|
|
34
|
+
printf '%s' "$body" > "$POLICY_DIR/JTBD-001.proposed.md"
|
|
35
|
+
touch "$MARKER"
|
|
36
|
+
store_review_hash "$TEST_SESSION" "$SYSTEM" "$POLICY_DIR"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Case (a) — trivial edits do NOT re-trigger
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
@test "jtbd substance-aware: trailing-whitespace edit does NOT re-trigger drift" {
|
|
44
|
+
_install_and_mark "# JTBD-001
|
|
45
|
+
|
|
46
|
+
A job-to-be-done description.
|
|
47
|
+
"
|
|
48
|
+
printf '%s' "# JTBD-001
|
|
49
|
+
|
|
50
|
+
A job-to-be-done description.
|
|
51
|
+
" > "$POLICY_DIR/JTBD-001.proposed.md"
|
|
52
|
+
|
|
53
|
+
run check_review_gate "$TEST_SESSION" "$SYSTEM" "$POLICY_DIR"
|
|
54
|
+
[ "$status" -eq 0 ]
|
|
55
|
+
[ -f "$MARKER" ]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@test "jtbd substance-aware: CRLF→LF edit does NOT re-trigger drift" {
|
|
59
|
+
_install_and_mark "# JTBD-001
|
|
60
|
+
|
|
61
|
+
Body.
|
|
62
|
+
"
|
|
63
|
+
printf '# JTBD-001\r\n\r\nBody.\r\n' > "$POLICY_DIR/JTBD-001.proposed.md"
|
|
64
|
+
|
|
65
|
+
run check_review_gate "$TEST_SESSION" "$SYSTEM" "$POLICY_DIR"
|
|
66
|
+
[ "$status" -eq 0 ]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Case (b) — substantive edits DO re-trigger
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@test "jtbd substance-aware: new-word edit DOES re-trigger drift" {
|
|
74
|
+
_install_and_mark "# JTBD-001
|
|
75
|
+
|
|
76
|
+
A job-to-be-done description.
|
|
77
|
+
"
|
|
78
|
+
printf '%s' "# JTBD-001
|
|
79
|
+
|
|
80
|
+
A different job-to-be-done description.
|
|
81
|
+
" > "$POLICY_DIR/JTBD-001.proposed.md"
|
|
82
|
+
|
|
83
|
+
run check_review_gate "$TEST_SESSION" "$SYSTEM" "$POLICY_DIR"
|
|
84
|
+
[ "$status" -ne 0 ]
|
|
85
|
+
[ ! -f "$MARKER" ]
|
|
86
|
+
[ ! -f "$HASH_FILE" ]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@test "jtbd substance-aware: new JTBD file DOES re-trigger drift" {
|
|
90
|
+
_install_and_mark "# JTBD-001
|
|
91
|
+
|
|
92
|
+
Body.
|
|
93
|
+
"
|
|
94
|
+
printf '%s' "# JTBD-002
|
|
95
|
+
|
|
96
|
+
New job.
|
|
97
|
+
" > "$POLICY_DIR/JTBD-002.proposed.md"
|
|
98
|
+
|
|
99
|
+
run check_review_gate "$TEST_SESSION" "$SYSTEM" "$POLICY_DIR"
|
|
100
|
+
[ "$status" -ne 0 ]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Case (c) — atomic write persists reliably
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
@test "jtbd atomic-write: store_review_hash lands marker + hash together" {
|
|
108
|
+
printf '%s' "# JTBD-001
|
|
109
|
+
|
|
110
|
+
Body.
|
|
111
|
+
" > "$POLICY_DIR/JTBD-001.proposed.md"
|
|
112
|
+
touch "$MARKER"
|
|
113
|
+
run store_review_hash "$TEST_SESSION" "$SYSTEM" "$POLICY_DIR"
|
|
114
|
+
[ "$status" -eq 0 ]
|
|
115
|
+
[ -f "$MARKER" ]
|
|
116
|
+
[ -f "$HASH_FILE" ]
|
|
117
|
+
# Stored hash matches the substance hash of the policy content.
|
|
118
|
+
local expected
|
|
119
|
+
expected=$(_substance_hash_path "$POLICY_DIR")
|
|
120
|
+
[ "$(cat "$HASH_FILE")" = "$expected" ]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Case (d) — conservative boundary holds
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
@test "jtbd conservative: single-numeral change DOES re-trigger drift" {
|
|
128
|
+
_install_and_mark "# JTBD-001
|
|
129
|
+
|
|
130
|
+
Threshold is 5 minutes.
|
|
131
|
+
"
|
|
132
|
+
printf '%s' "# JTBD-001
|
|
133
|
+
|
|
134
|
+
Threshold is 6 minutes.
|
|
135
|
+
" > "$POLICY_DIR/JTBD-001.proposed.md"
|
|
136
|
+
|
|
137
|
+
run check_review_gate "$TEST_SESSION" "$SYSTEM" "$POLICY_DIR"
|
|
138
|
+
[ "$status" -ne 0 ]
|
|
139
|
+
}
|
package/package.json
CHANGED
|
@@ -49,6 +49,8 @@ For each job/persona in the ordered queue, surface it as an `AskUserQuestion` (c
|
|
|
49
49
|
|
|
50
50
|
The trailing clause exists for cross-reference value — it is optional, NOT a re-entry point for meta-leading. When the meta does not add load-bearing context for the confirm decision, omit it entirely.
|
|
51
51
|
|
|
52
|
+
**Brief-before-ID discipline (P350).** The `question` and `options` text MUST inline what each referenced artefact is and what is at stake before naming it by ID. `JTBD-NNN` / `P-NNN` / `ADR-NNN` / `RFC-NNN` references are audit-trail annotations, NEVER carriers of meaning — the user reads this prompt without project filesystem access (mobile clients, accessibility tooling, notification surfaces) and cannot follow links. Acceptable: *"This job statement: developers want governance enforced automatically so they get manual-review safety without the overhead (cited by the AFK-orchestration backlog ticket)."* Unacceptable: *"This job statement: enforce governance without slowing down (cited by P256, sibling to JTBD-006)."* Trailing parenthetical IDs are permitted ONLY after a self-contained explanation, never as the explanation itself. Mirrors the canonical `/wr-architect:create-adr` Step 5 § 5a Rule 3 ("No IDs as explainers"). See also session memory `feedback_brief_before_id.md`.
|
|
53
|
+
|
|
52
54
|
This is a genuine human-decision surface (the point of P288/ADR-068) — `AskUserQuestion` is correct here, not over-asking. Do not auto-confirm; do not prose-ask.
|
|
53
55
|
|
|
54
56
|
### Step 4: Apply the outcome
|