@windyroad/architect 0.16.0 → 0.17.0-preview.694

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.
@@ -123,5 +123,5 @@
123
123
  }
124
124
  },
125
125
  "name": "wr-architect",
126
- "version": "0.16.0"
126
+ "version": "0.17.0"
127
127
  }
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env bash
2
+ # architect-compendium-update-entry.sh — PostToolUse:Edit|Write hook.
3
+ #
4
+ # ADR-078 Phase 1, Option 9 (architect-on-edit writes README entry directly).
5
+ # RFC-014 Story A. Closes P337 structurally: every edit to a per-ADR body
6
+ # triggers a same-hook re-authoring of that ADR's entry in the decisions
7
+ # compendium (docs/decisions/README.md), so body↔compendium drift becomes
8
+ # impossible by construction rather than detectable-and-fixable.
9
+ #
10
+ # Mechanism:
11
+ # 1. Fires on Edit/Write events whose file_path is docs/decisions/<NNN>-*.md
12
+ # (excludes README.md and any -history.md / -summary.md sibling).
13
+ # 2. Spawns a `claude -p` subprocess invoking wr-architect:agent with the
14
+ # just-edited ADR body + the current README entry for that ADR-ID (or an
15
+ # empty string when the ADR is new). The architect emits the updated
16
+ # compendium entry shape (### ADR-NNN header + Status/Oversight/Supersedes
17
+ # badges + **Decides:** + **Confirmation:** + **Related:**).
18
+ # 3. Captures the architect's emit from the subprocess JSON `.result` field;
19
+ # replaces the existing entry block for that ADR-ID in-place, or inserts a
20
+ # new one in numeric-sort order under the correct section (in-force for
21
+ # proposed/accepted; historical for superseded/rejected/deprecated).
22
+ # 4. Stages docs/decisions/README.md so it lands in the same commit as the
23
+ # ADR body change (paired by architect-readme-pairing-check.sh — Story B).
24
+ #
25
+ # Failure mode (ADR-078 Confirmation criterion l): if the subprocess fails
26
+ # (network, quota, model error) or emits nothing usable, the hook logs a
27
+ # warning to stderr and leaves README unchanged (degraded mode). It does NOT
28
+ # block the body edit (exit 0). The stale README is then caught by Story B's
29
+ # pre-commit pairing check on the next `git commit`, surfacing the failure for
30
+ # manual recovery via `wr-architect-generate-decisions-compendium`.
31
+ #
32
+ # Opt-out (ADR-078 Confirmation criterion k): set
33
+ # ARCHITECT_AUTO_UPDATE_COMPENDIUM=0 to suppress the hook entirely (for
34
+ # API-cost-sensitive adopter setups). The hook self-suppresses with a stderr
35
+ # message directing the user to the manual generator.
36
+ #
37
+ # The compendium is no longer generator-derived (ADR-077 criterion b/g/h
38
+ # retired by ADR-078); the generator script is kept as a one-release-cycle
39
+ # backstop only (RFC-014 Story C). ADR-031 authoritative-state is preserved:
40
+ # the per-ADR body remains the source of truth; this entry is a derived view.
41
+
42
+ set -uo pipefail
43
+
44
+ # --- Opt-out (criterion k) -------------------------------------------------
45
+ if [ "${ARCHITECT_AUTO_UPDATE_COMPENDIUM:-1}" = "0" ]; then
46
+ echo "architect-compendium-update-entry: ARCHITECT_AUTO_UPDATE_COMPENDIUM=0 — hook suppressed. Refresh the compendium manually with: wr-architect-generate-decisions-compendium && git add docs/decisions/README.md" >&2
47
+ exit 0
48
+ fi
49
+
50
+ # PostToolUse input arrives on stdin as JSON.
51
+ input=$(cat)
52
+
53
+ tool_name=$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null)
54
+ case "$tool_name" in
55
+ Edit|Write|MultiEdit) ;;
56
+ *) exit 0 ;;
57
+ esac
58
+
59
+ file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""' 2>/dev/null)
60
+ [ -n "$file_path" ] || exit 0
61
+
62
+ # Path gate: only docs/decisions/<NNN>-*.md ADR bodies. Exclude README and the
63
+ # non-ADR sibling files the generator also excludes.
64
+ base=$(basename "$file_path")
65
+ case "$file_path" in
66
+ */docs/decisions/*|docs/decisions/*) ;;
67
+ *) exit 0 ;;
68
+ esac
69
+ case "$base" in
70
+ README.md|*-history.md|*-summary.md) exit 0 ;;
71
+ esac
72
+ # Must be a numbered ADR file (NNN-...).
73
+ echo "$base" | grep -qE '^[0-9]+-' || exit 0
74
+
75
+ adr_id_padded=$(echo "$base" | grep -oE '^[0-9]+') # zero-padded form for display (ADR-049)
76
+ adr_id=$((10#$adr_id_padded)) # numeric form for sort comparison (49)
77
+
78
+ # Resolve repo root so the README path + git staging are stable regardless of
79
+ # the hook's runtime CWD (P191 project-root anchoring).
80
+ project_dir="${CLAUDE_PROJECT_DIR:-}"
81
+ if [ -z "$project_dir" ]; then
82
+ project_dir=$(git rev-parse --show-toplevel 2>/dev/null) || project_dir="$PWD"
83
+ fi
84
+ readme="$project_dir/docs/decisions/README.md"
85
+
86
+ # The edited ADR body must exist on disk (the Write/Edit already landed —
87
+ # PostToolUse fires after the tool succeeds).
88
+ if [ ! -f "$file_path" ]; then
89
+ # Try the project-root-relative path if file_path was relative.
90
+ if [ -f "$project_dir/$file_path" ]; then
91
+ file_path="$project_dir/$file_path"
92
+ else
93
+ echo "architect-compendium-update-entry: edited ADR body not found ($file_path) — leaving compendium unchanged" >&2
94
+ exit 0
95
+ fi
96
+ fi
97
+ [ -f "$readme" ] || {
98
+ echo "architect-compendium-update-entry: compendium not found ($readme) — leaving unchanged (run wr-architect-generate-decisions-compendium to bootstrap)" >&2
99
+ exit 0
100
+ }
101
+
102
+ # --- Determine target section from the ADR's status ------------------------
103
+ adr_status=$(awk '
104
+ /^---$/ { fm = !fm; if (!fm) exit; next }
105
+ fm && /^status:/ {
106
+ sub(/^status: */, ""); gsub(/^["'"'"']|["'"'"']$/, "")
107
+ sub(/^ +/, ""); sub(/ +$/, ""); print; exit
108
+ }
109
+ ' "$file_path")
110
+ case "$adr_status" in
111
+ superseded|rejected|deprecated) target_section="historical" ;;
112
+ *) target_section="inforce" ;;
113
+ esac
114
+
115
+ # --- Extract the current README entry block for this ADR-ID -----------------
116
+ # Block = the `### ADR-<id> —` line and following lines up to the next
117
+ # `### ` / `## ` / `---` / EOF. Empty when the ADR is new.
118
+ current_entry=$(awk -v id="$adr_id" '
119
+ function bid(l, s){ s=l; sub(/^### ADR-/,"",s); return s+0 }
120
+ {
121
+ if ($0 ~ /^### ADR-[0-9]+/) { cap = (bid($0)==id) ? 1 : 0; if (cap) { print; next } }
122
+ else if ($0 ~ /^### / || $0 ~ /^## / || $0 ~ /^---[[:space:]]*$/) { cap = 0 }
123
+ if (cap) print
124
+ }
125
+ ' "$readme")
126
+
127
+ # --- Spawn the architect subprocess (claude -p) ----------------------------
128
+ adr_body=$(cat "$file_path")
129
+ prompt=$(cat <<PROMPT
130
+ You are the wr-architect compendium-entry author. Re-author the single
131
+ docs/decisions/README.md compendium entry for ADR-${adr_id_padded} from its
132
+ current body. Emit ONLY the entry block — no preamble, no code fence, no
133
+ trailing commentary. The entry shape is exactly:
134
+
135
+ ### ADR-${adr_id_padded} — <title>
136
+ **Status:** <status> | **Oversight:** <human-oversight> [| **Supersedes:** <list>]
137
+ **Decides:** <one or two sentence semantic TL;DR of the Decision Outcome — what was decided and why, in plain prose>
138
+ **Confirmation:** <short "; "-joined digest of the Confirmation criteria>
139
+ **Related:** <deduped ADR-NNN list from the Related section and inline mentions>
140
+
141
+ Omit the Supersedes badge when the body has no supersedes. Omit any of the
142
+ Decides / Confirmation / Related lines only when the body genuinely has no such
143
+ content. Keep the whole entry compact (a few lines) — it is a routine-load
144
+ index surface, not the full body.
145
+
146
+ --- CURRENT COMPENDIUM ENTRY (may be empty if the ADR is new) ---
147
+ ${current_entry}
148
+
149
+ --- ADR BODY ---
150
+ ${adr_body}
151
+ PROMPT
152
+ )
153
+
154
+ # Capture the architect's emit. `claude -p --output-format json` returns a JSON
155
+ # envelope with a `.result` string. PATH-resolved `claude` (so bats fixtures can
156
+ # stub it with a fixed-response shim placed first on PATH — RFC-014 SQ-014-1).
157
+ subprocess_out=$(printf '%s' "$prompt" | claude -p --output-format json 2>/dev/null)
158
+ subprocess_rc=$?
159
+
160
+ new_entry=""
161
+ if [ "$subprocess_rc" -eq 0 ] && [ -n "$subprocess_out" ]; then
162
+ new_entry=$(printf '%s' "$subprocess_out" | jq -r '.result // empty' 2>/dev/null)
163
+ fi
164
+
165
+ # Degraded mode (criterion l): no usable emit → warn + leave README unchanged,
166
+ # do NOT block the edit.
167
+ if [ -z "$new_entry" ] || ! printf '%s' "$new_entry" | grep -qE '^### ADR-[0-9]+'; then
168
+ echo "architect-compendium-update-entry: architect subprocess produced no usable entry for ADR-${adr_id} (degraded mode) — compendium left unchanged. The pre-commit pairing check will surface this; recover with wr-architect-generate-decisions-compendium && git add docs/decisions/README.md" >&2
169
+ exit 0
170
+ fi
171
+
172
+ # --- Apply the entry: delete any existing block, then insert sorted ---------
173
+ tmp_entry=$(mktemp -t architect-entry.XXXXXX)
174
+ tmp_readme=$(mktemp -t architect-readme.XXXXXX)
175
+ trap 'rm -f "$tmp_entry" "$tmp_readme"' EXIT
176
+ printf '%s\n' "$new_entry" > "$tmp_entry"
177
+
178
+ # Pass 1 — remove any existing block for this ADR-ID (and the single blank line
179
+ # that precedes it), collapsing blank runs so deletion leaves no double-gap.
180
+ awk -v id="$adr_id" '
181
+ function bid(l, s){ s=l; sub(/^### ADR-/,"",s); return s+0 }
182
+ {
183
+ if ($0 ~ /^### ADR-[0-9]+/) {
184
+ if (bid($0)==id) { skipping=1; pendingblank=0; next }
185
+ skipping=0
186
+ if (pendingblank) { print ""; pendingblank=0 }
187
+ print; next
188
+ }
189
+ if (skipping) {
190
+ if ($0 ~ /^### / || $0 ~ /^## / || $0 ~ /^---[[:space:]]*$/) { skipping=0 }
191
+ else next
192
+ }
193
+ if ($0 ~ /^[[:space:]]*$/) { pendingblank=1; next }
194
+ if (pendingblank) { print ""; pendingblank=0 }
195
+ print
196
+ }
197
+ END { if (pendingblank) print "" }
198
+ ' "$readme" > "$tmp_readme"
199
+
200
+ # Pass 2 — insert the new block in numeric-sort order within the target section.
201
+ awk -v id="$adr_id" -v section="$target_section" -v entryfile="$tmp_entry" '
202
+ function bid(l, s){ s=l; sub(/^### ADR-/,"",s); return s+0 }
203
+ BEGIN {
204
+ while ((getline l < entryfile) > 0) entry = (entry=="" ? l : entry "\n" l)
205
+ insec=0; done=0
206
+ }
207
+ /^## In-force decisions/ { insec=(section=="inforce") }
208
+ /^## Historical decisions/ { insec=(section=="historical") }
209
+ {
210
+ if (!done && insec && $0 ~ /^### ADR-[0-9]+/ && bid($0) > id) {
211
+ print entry; print ""; done=1
212
+ }
213
+ else if (!done && insec && ($0 ~ /^---[[:space:]]*$/ || ($0 ~ /^## / && $0 !~ /In-force decisions|Historical decisions/))) {
214
+ print entry; print ""; done=1; insec=0
215
+ }
216
+ print
217
+ }
218
+ END { if (!done) { print ""; print entry } }
219
+ ' "$tmp_readme" > "$readme"
220
+
221
+ # Stage the compendium so it lands in the same commit as the ADR body change.
222
+ ( cd "$project_dir" && git add docs/decisions/README.md 2>/dev/null ) || \
223
+ echo "architect-compendium-update-entry: git add docs/decisions/README.md failed (not a git repo or staging error) — stage it manually before commit" >&2
224
+
225
+ echo "architect-compendium-update-entry: refreshed compendium entry for ADR-${adr_id} (${target_section})" >&2
226
+ exit 0
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bash
2
+ # architect-readme-pairing-check.sh — PreToolUse:Bash hook.
3
+ #
4
+ # ADR-078 Phase 1, Option 9. RFC-014 Story B. The structural replacement for
5
+ # the retired architect-compendium-refresh-discipline.sh: instead of comparing
6
+ # the staged compendium against PROGRAMMATIC generator output (which no longer
7
+ # holds under architect-authored entries), this hook asserts a simpler, robust
8
+ # invariant — every commit that stages a `docs/decisions/<NNN>-*.md` ADR body
9
+ # change MUST also stage `docs/decisions/README.md`.
10
+ #
11
+ # Under Option 9 the PostToolUse architect-compendium-update-entry.sh hook
12
+ # (Story A) re-authors + stages the README entry on every ADR body edit, so a
13
+ # commit that touches a body but NOT the README means Story A did not run (or
14
+ # ran in degraded mode after a subprocess failure). Denying surfaces that for
15
+ # manual recovery: re-run the edit (re-triggers Story A) or regenerate via
16
+ # `wr-architect-generate-decisions-compendium && git add docs/decisions/README.md`.
17
+ #
18
+ # Replaces ADR-077 Confirmation criterion (g) (the generator-output drift gate /
19
+ # bats test 2145). See ADR-078 § "Drift safety under Option 9".
20
+ #
21
+ # Allow paths (exit 0 silently per ADR-045 Pattern 1):
22
+ # - tool_name != "Bash"
23
+ # - command's leading effective executable is not `git commit`
24
+ # - `RISK_BYPASS: architect-compendium-deferred` token present in command
25
+ # - BYPASS_COMPENDIUM_REFRESH_GATE=1 (batch/migration parity)
26
+ # - staged set has no `docs/decisions/<NNN>-*.md` ADR body change
27
+ # - staged set already includes `docs/decisions/README.md`
28
+ #
29
+ # Deny path (exit 2 with PreToolUse deny JSON on stderr):
30
+ # - ADR body staged but README not staged
31
+
32
+ set -uo pipefail
33
+
34
+ # PreToolUse input arrives on stdin as JSON.
35
+ input=$(cat)
36
+
37
+ tool_name=$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null)
38
+ [ "$tool_name" = "Bash" ] || exit 0
39
+
40
+ command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null)
41
+ [ -n "$command" ] || exit 0
42
+
43
+ # Only fire on `git commit` invocations. Leading-executable check (P268
44
+ # pattern): a bare substring match would catch unrelated commands that mention
45
+ # "git commit" (grep/sed/cat). Strip leading whitespace + env assignments, then
46
+ # require the first effective tokens to be `git commit`.
47
+ echo "$command" | awk '
48
+ {
49
+ sub(/^[[:space:]]+/, "")
50
+ while ($0 ~ /^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+/) {
51
+ sub(/^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+/, "")
52
+ }
53
+ if ($0 ~ /^git[[:space:]]+commit([[:space:]]|$)/) exit 0
54
+ exit 1
55
+ }
56
+ ' || exit 0
57
+
58
+ # Allow-list bypass token (parity with the retired refresh-discipline hook and
59
+ # ADR-014 commit-message bypass shape).
60
+ if echo "$command" | grep -qF 'RISK_BYPASS: architect-compendium-deferred'; then
61
+ exit 0
62
+ fi
63
+
64
+ # Env-var bypass for batch/migration cases.
65
+ if [ "${BYPASS_COMPENDIUM_REFRESH_GATE:-0}" = "1" ]; then
66
+ exit 0
67
+ fi
68
+
69
+ # Resolve repo root so the git plumbing is path-stable.
70
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
71
+ cd "$repo_root" || exit 0
72
+
73
+ # Inspect the staged set. ADR bodies are docs/decisions/<NNN>-*.md (exclude the
74
+ # README itself and the -history / -summary siblings).
75
+ staged_adrs=$(git diff --cached --name-only 2>/dev/null \
76
+ | awk '/^docs\/decisions\/[0-9]+-.*\.md$/ { print }' \
77
+ | head -20)
78
+ [ -n "$staged_adrs" ] || exit 0
79
+
80
+ staged_compendium=$(git diff --cached --name-only 2>/dev/null \
81
+ | awk '/^docs\/decisions\/README\.md$/ { print }')
82
+
83
+ if [ -z "$staged_compendium" ]; then
84
+ first_adr=$(echo "$staged_adrs" | head -1)
85
+ cat >&2 <<EOF
86
+ {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "architect-readme-pairing-check: '${first_adr}' is staged for commit but 'docs/decisions/README.md' is NOT. Under ADR-078 Option 9 every ADR body change must be paired with its compendium entry refresh (the architect-compendium-update-entry PostToolUse hook does this automatically — if README is unstaged the hook did not run or hit degraded mode). Recover: re-run the ADR edit to re-trigger the hook, OR run 'wr-architect-generate-decisions-compendium && git add docs/decisions/README.md'. Intentional follow-up split: append 'RISK_BYPASS: architect-compendium-deferred' to the commit message. Batch/migration: set BYPASS_COMPENDIUM_REFRESH_GATE=1."}}
87
+ EOF
88
+ exit 2
89
+ fi
90
+
91
+ # Both staged — pairing satisfied. (No generator-output comparison: the README
92
+ # is architect-authored under Option 9, not generator-derived.)
93
+ exit 0
package/hooks/hooks.json CHANGED
@@ -10,11 +10,12 @@
10
10
  { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/architect-enforce-edit.sh" }] },
11
11
  { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/architect-oversight-marker-discipline.sh" }] },
12
12
  { "matcher": "ExitPlanMode", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/architect-plan-enforce.sh" }] },
13
- { "matcher": "Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/architect-compendium-refresh-discipline.sh" }] }
13
+ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/architect-readme-pairing-check.sh" }] }
14
14
  ],
15
15
  "PostToolUse": [
16
16
  { "matcher": "Agent", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/architect-mark-reviewed.sh" }] },
17
17
  { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/architect-refresh-hash.sh" }] },
18
+ { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/architect-compendium-update-entry.sh" }] },
18
19
  { "matcher": "Agent|Bash|Skill", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/architect-slide-marker.sh" }] }
19
20
  ]
20
21
  }
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Behavioural tests for architect-compendium-update-entry.sh (RFC-014 Story A,
4
+ # ADR-078 Phase 1 / Option 9). Exercises the hook against fixture compendium
5
+ # trees; asserts on its side-effects (README mutation, staging, exit code) and
6
+ # stderr signals — never on hook source content (feedback_behavioural_tests).
7
+ #
8
+ # The `claude -p` subprocess is stubbed with a PATH-priority fake-claude shim
9
+ # (RFC-014 SQ-014-1) that emits a fixed-shape `{"result": "<entry>"}` envelope
10
+ # for whichever ADR-ID appears in the prompt. Real-subprocess integration is
11
+ # out of scope for Phase 1.
12
+
13
+ setup() {
14
+ HOOK="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/architect-compendium-update-entry.sh"
15
+ PROJ="$(mktemp -d)"
16
+ mkdir -p "$PROJ/docs/decisions"
17
+ ( cd "$PROJ" && git init -q && git config user.email t@e.x && git config user.name t )
18
+
19
+ # PATH-priority fake-claude shim: emits a {"result": "<entry>"} envelope with
20
+ # an entry block for the ADR-ID found in the prompt. Marker text STUBBED-<id>
21
+ # makes the emitted Decides line assertable.
22
+ SHIMDIR="$(mktemp -d)"
23
+ cat > "$SHIMDIR/claude" <<'SHIM'
24
+ #!/usr/bin/env bash
25
+ prompt=$(cat)
26
+ id=$(printf '%s' "$prompt" | grep -oE 'ADR-[0-9]+' | head -1 | sed 's/ADR-//')
27
+ entry="### ADR-${id} — Stub Title
28
+ **Status:** proposed | **Oversight:** confirmed
29
+ **Decides:** STUBBED-${id} decision body.
30
+ **Confirmation:** stub crit a; stub crit b
31
+ **Related:** ADR-001"
32
+ jq -cn --arg r "$entry" '{result:$r}'
33
+ SHIM
34
+ chmod +x "$SHIMDIR/claude"
35
+
36
+ # Failing shim variant: exits non-zero, emits nothing (degraded-mode tests).
37
+ FAILSHIMDIR="$(mktemp -d)"
38
+ cat > "$FAILSHIMDIR/claude" <<'SHIM'
39
+ #!/usr/bin/env bash
40
+ cat >/dev/null
41
+ exit 7
42
+ SHIM
43
+ chmod +x "$FAILSHIMDIR/claude"
44
+
45
+ ORIG_PATH="$PATH"
46
+ export PATH="$SHIMDIR:$PATH"
47
+ }
48
+
49
+ teardown() {
50
+ export PATH="$ORIG_PATH"
51
+ rm -rf "$PROJ" "$SHIMDIR" "$FAILSHIMDIR"
52
+ }
53
+
54
+ # Writes a minimal compendium README with one in-force section (ADRs 003, 049,
55
+ # 051) and one historical section (ADR 010). Stable fixture for placement tests.
56
+ mk_readme() {
57
+ cat > "$PROJ/docs/decisions/README.md" <<'EOF'
58
+ # Decisions Compendium
59
+
60
+ Intro prose.
61
+
62
+ ---
63
+
64
+ ## In-force decisions
65
+
66
+ _3 ADRs._
67
+
68
+ ### ADR-003 — Three
69
+ **Status:** proposed | **Oversight:** confirmed
70
+ **Decides:** Decides three.
71
+
72
+ ### ADR-049 — FortyNine
73
+ **Status:** accepted | **Oversight:** confirmed
74
+ **Decides:** Decides forty-nine.
75
+
76
+ ### ADR-051 — FiftyOne
77
+ **Status:** proposed | **Oversight:** confirmed
78
+ **Decides:** Decides fifty-one.
79
+
80
+ ---
81
+
82
+ ## Historical decisions
83
+
84
+ _1 ADR._
85
+
86
+ ### ADR-010 — Ten
87
+ **Status:** superseded | **Oversight:** confirmed
88
+ **Decides:** Decides ten.
89
+ EOF
90
+ }
91
+
92
+ # mk_adr <nnn> <status> <title>
93
+ mk_adr() {
94
+ local nnn="$1" status="$2" title="$3"
95
+ cat > "$PROJ/docs/decisions/${nnn}-slug.${status}.md" <<EOF
96
+ ---
97
+ status: "$status"
98
+ date: 2026-06-09
99
+ human-oversight: confirmed
100
+ ---
101
+
102
+ # $title
103
+
104
+ ## Decision Outcome
105
+
106
+ Chosen option: **"$title impl"**, because reasons.
107
+
108
+ ## Confirmation
109
+
110
+ - (a) first
111
+ - (b) second
112
+
113
+ ## Related
114
+
115
+ - Relates to [ADR-001](001-foo.proposed.md)
116
+ EOF
117
+ echo "$PROJ/docs/decisions/${nnn}-slug.${status}.md"
118
+ }
119
+
120
+ run_hook() {
121
+ local fp="$1"
122
+ echo "{\"tool_name\":\"Edit\",\"tool_input\":{\"file_path\":\"$fp\"},\"session_id\":\"s1\"}" \
123
+ | CLAUDE_PROJECT_DIR="$PROJ" bash "$HOOK"
124
+ }
125
+
126
+ @test "fires on Edit to a numbered ADR body and refreshes its entry in-place (criterion 1+4)" {
127
+ mk_readme
128
+ fp=$(mk_adr "049" "accepted" "FortyNine")
129
+ run run_hook "$fp"
130
+ [ "$status" -eq 0 ]
131
+ # In-place replacement: the entry now carries the stubbed Decides marker...
132
+ grep -q 'STUBBED-049 decision body' "$PROJ/docs/decisions/README.md"
133
+ # ...and there is exactly ONE ADR-049 header (no duplicate).
134
+ [ "$(grep -c '^### ADR-049 ' "$PROJ/docs/decisions/README.md")" -eq 1 ]
135
+ }
136
+
137
+ @test "produces an observable stderr signal on every invocation (criterion 2)" {
138
+ mk_readme
139
+ fp=$(mk_adr "049" "accepted" "FortyNine")
140
+ run run_hook "$fp"
141
+ [[ "$output" == *"architect-compendium-update-entry"* ]]
142
+ }
143
+
144
+ @test "emits the expected entry shape with a Decides line (criterion 3)" {
145
+ mk_readme
146
+ fp=$(mk_adr "049" "accepted" "FortyNine")
147
+ run_hook "$fp"
148
+ grep -qE '^\*\*Decides:\*\* STUBBED-049' "$PROJ/docs/decisions/README.md"
149
+ }
150
+
151
+ @test "inserts a new ADR entry in numeric-sort order within in-force (criterion 5)" {
152
+ mk_readme
153
+ fp=$(mk_adr "050" "proposed" "Fifty")
154
+ run run_hook "$fp"
155
+ [ "$status" -eq 0 ]
156
+ # ADR-050 must appear between ADR-049 and ADR-051 in the in-force section.
157
+ line049=$(grep -n '^### ADR-049 ' "$PROJ/docs/decisions/README.md" | cut -d: -f1)
158
+ line050=$(grep -n '^### ADR-050 ' "$PROJ/docs/decisions/README.md" | cut -d: -f1)
159
+ line051=$(grep -n '^### ADR-051 ' "$PROJ/docs/decisions/README.md" | cut -d: -f1)
160
+ [ -n "$line050" ]
161
+ [ "$line049" -lt "$line050" ]
162
+ [ "$line050" -lt "$line051" ]
163
+ }
164
+
165
+ @test "routes a superseded ADR's entry into the historical section (criterion 6)" {
166
+ mk_readme
167
+ fp=$(mk_adr "012" "superseded" "Twelve")
168
+ run run_hook "$fp"
169
+ [ "$status" -eq 0 ]
170
+ # ADR-012 must land AFTER the Historical-decisions header, not in In-force.
171
+ hist=$(grep -n '^## Historical decisions' "$PROJ/docs/decisions/README.md" | cut -d: -f1)
172
+ line012=$(grep -n '^### ADR-012 ' "$PROJ/docs/decisions/README.md" | cut -d: -f1)
173
+ [ -n "$line012" ]
174
+ [ "$line012" -gt "$hist" ]
175
+ }
176
+
177
+ @test "migrates an entry from in-force to historical when status flips (criterion 6)" {
178
+ mk_readme
179
+ # ADR-049 currently renders in the in-force fixture section; re-author it as
180
+ # superseded — the hook must remove the in-force block and place it historical.
181
+ fp=$(mk_adr "049" "superseded" "FortyNine")
182
+ run run_hook "$fp"
183
+ [ "$status" -eq 0 ]
184
+ # Exactly one ADR-049 entry, and it is now below the Historical header.
185
+ [ "$(grep -c '^### ADR-049 ' "$PROJ/docs/decisions/README.md")" -eq 1 ]
186
+ hist=$(grep -n '^## Historical decisions' "$PROJ/docs/decisions/README.md" | cut -d: -f1)
187
+ line049=$(grep -n '^### ADR-049 ' "$PROJ/docs/decisions/README.md" | cut -d: -f1)
188
+ [ "$line049" -gt "$hist" ]
189
+ }
190
+
191
+ @test "stages docs/decisions/README.md after refresh (criterion: same-commit pairing)" {
192
+ mk_readme
193
+ ( cd "$PROJ" && git add -A && git commit -q -m init )
194
+ fp=$(mk_adr "049" "accepted" "FortyNine")
195
+ run_hook "$fp"
196
+ # README must be in the staged set.
197
+ ( cd "$PROJ" && git diff --cached --name-only ) | grep -q 'docs/decisions/README.md'
198
+ }
199
+
200
+ @test "subprocess failure leaves README unchanged + does NOT block the edit (criterion 7)" {
201
+ mk_readme
202
+ before=$(cat "$PROJ/docs/decisions/README.md")
203
+ fp=$(mk_adr "049" "accepted" "FortyNine")
204
+ export PATH="$FAILSHIMDIR:$ORIG_PATH"
205
+ run run_hook "$fp"
206
+ [ "$status" -eq 0 ] # does not block the body edit
207
+ after=$(cat "$PROJ/docs/decisions/README.md")
208
+ [ "$before" = "$after" ] # README unchanged (degraded mode)
209
+ [[ "$output" == *"degraded mode"* ]]
210
+ }
211
+
212
+ @test "opt-out ARCHITECT_AUTO_UPDATE_COMPENDIUM=0 self-suppresses (criterion 8)" {
213
+ mk_readme
214
+ before=$(cat "$PROJ/docs/decisions/README.md")
215
+ fp=$(mk_adr "049" "accepted" "FortyNine")
216
+ run bash -c "echo '{\"tool_name\":\"Edit\",\"tool_input\":{\"file_path\":\"$fp\"}}' | ARCHITECT_AUTO_UPDATE_COMPENDIUM=0 CLAUDE_PROJECT_DIR='$PROJ' bash '$HOOK'"
217
+ [ "$status" -eq 0 ]
218
+ [ "$before" = "$(cat "$PROJ/docs/decisions/README.md")" ]
219
+ [[ "$output" == *"ARCHITECT_AUTO_UPDATE_COMPENDIUM=0"* ]]
220
+ }
221
+
222
+ @test "ignores README.md edits (no self-recursion)" {
223
+ mk_readme
224
+ before=$(cat "$PROJ/docs/decisions/README.md")
225
+ run run_hook "$PROJ/docs/decisions/README.md"
226
+ [ "$status" -eq 0 ]
227
+ [ "$before" = "$(cat "$PROJ/docs/decisions/README.md")" ]
228
+ }
229
+
230
+ @test "ignores non-decisions file edits" {
231
+ mk_readme
232
+ echo "x" > "$PROJ/other.ts"
233
+ run run_hook "$PROJ/other.ts"
234
+ [ "$status" -eq 0 ]
235
+ }
236
+
237
+ @test "registered in hooks.json on PostToolUse Edit|Write (criterion 9)" {
238
+ HOOKS_JSON="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/hooks.json"
239
+ run jq -e '.hooks.PostToolUse[] | select(.matcher | test("Edit")) | .hooks[] | select(.command | test("architect-compendium-update-entry"))' "$HOOKS_JSON"
240
+ [ "$status" -eq 0 ]
241
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Behavioural tests for architect-readme-pairing-check.sh (RFC-014 Story B,
4
+ # ADR-078 Phase 1 / Option 9). Exercises the hook against a real staged git
5
+ # index; asserts on its PreToolUse allow/deny decision (exit code + deny JSON).
6
+ # Behavioural — no grep on hook source (feedback_behavioural_tests).
7
+
8
+ setup() {
9
+ HOOK="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/architect-readme-pairing-check.sh"
10
+ REPO="$(mktemp -d)"
11
+ cd "$REPO"
12
+ git init -q
13
+ git config user.email t@e.x
14
+ git config user.name t
15
+ mkdir -p docs/decisions
16
+ echo "# compendium" > docs/decisions/README.md
17
+ echo "# adr 049" > docs/decisions/049-x.proposed.md
18
+ git add -A && git commit -q -m init
19
+ }
20
+
21
+ teardown() {
22
+ cd /
23
+ rm -rf "$REPO"
24
+ }
25
+
26
+ # Run the hook with a synthetic `git commit` Bash PreToolUse payload.
27
+ run_commit_hook() {
28
+ local cmd="${1:-git commit -m wip}"
29
+ echo "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"$cmd\"}}" | bash "$HOOK"
30
+ }
31
+
32
+ @test "denies commit when an ADR body is staged without README (criterion 1)" {
33
+ echo "# adr 049 edited" > docs/decisions/049-x.proposed.md
34
+ git add docs/decisions/049-x.proposed.md
35
+ run run_commit_hook
36
+ [ "$status" -eq 2 ]
37
+ [[ "$output" == *"deny"* ]]
38
+ [[ "$output" == *"049-x.proposed.md"* ]]
39
+ }
40
+
41
+ @test "permits commit when ADR body AND README are both staged (criterion 2)" {
42
+ echo "# adr 049 edited" > docs/decisions/049-x.proposed.md
43
+ echo "# compendium refreshed" > docs/decisions/README.md
44
+ git add docs/decisions/049-x.proposed.md docs/decisions/README.md
45
+ run run_commit_hook
46
+ [ "$status" -eq 0 ]
47
+ }
48
+
49
+ @test "permits commit when only README is staged (compendium-only edit) (criterion 3)" {
50
+ echo "# compendium refreshed" > docs/decisions/README.md
51
+ git add docs/decisions/README.md
52
+ run run_commit_hook
53
+ [ "$status" -eq 0 ]
54
+ }
55
+
56
+ @test "permits commit when no ADR-touching change is staged (criterion 4)" {
57
+ echo "x" > unrelated.txt
58
+ git add unrelated.txt
59
+ run run_commit_hook
60
+ [ "$status" -eq 0 ]
61
+ }
62
+
63
+ @test "deny message names the unpaired ADR file + recovery directive (criterion 5)" {
64
+ echo "# adr 049 edited" > docs/decisions/049-x.proposed.md
65
+ git add docs/decisions/049-x.proposed.md
66
+ run run_commit_hook
67
+ [ "$status" -eq 2 ]
68
+ [[ "$output" == *"049-x.proposed.md"* ]]
69
+ [[ "$output" == *"wr-architect-generate-decisions-compendium"* ]]
70
+ }
71
+
72
+ @test "allows non-commit Bash commands silently" {
73
+ echo "# adr 049 edited" > docs/decisions/049-x.proposed.md
74
+ git add docs/decisions/049-x.proposed.md
75
+ run run_commit_hook "git status"
76
+ [ "$status" -eq 0 ]
77
+ }
78
+
79
+ @test "RISK_BYPASS token permits an intentional split" {
80
+ echo "# adr 049 edited" > docs/decisions/049-x.proposed.md
81
+ git add docs/decisions/049-x.proposed.md
82
+ run run_commit_hook "git commit -m 'wip RISK_BYPASS: architect-compendium-deferred'"
83
+ [ "$status" -eq 0 ]
84
+ }
85
+
86
+ @test "registered in hooks.json as PreToolUse Bash (criterion 6)" {
87
+ HOOKS_JSON="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/hooks.json"
88
+ run jq -e '.hooks.PreToolUse[] | select(.matcher=="Bash") | .hooks[] | select(.command | test("architect-readme-pairing-check"))' "$HOOKS_JSON"
89
+ [ "$status" -eq 0 ]
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/architect",
3
- "version": "0.16.0",
3
+ "version": "0.17.0-preview.694",
4
4
  "description": "Architecture decision enforcement for AI coding agents",
5
5
  "bin": {
6
6
  "windyroad-architect": "./bin/install.mjs"
@@ -34,11 +34,25 @@ set -uo pipefail
34
34
  # truncation — both halves are needed to close P334.
35
35
  export LC_ALL=C
36
36
 
37
+ # --- ADR-078 (Option 9) deprecation notice (Confirmation criterion j) -------
38
+ # ADR-078 retires this generator as the load-bearing primary path. The
39
+ # compendium is now architect-authored per-edit by the PostToolUse hook
40
+ # `architect-compendium-update-entry.sh` (RFC-014 Story A); body↔compendium
41
+ # pairing is enforced at commit by `architect-readme-pairing-check.sh`
42
+ # (Story B). This script remains a one-release-cycle backstop / bootstrap +
43
+ # offline-reproducibility tool only, to be removed after one
44
+ # @windyroad/architect minor-version cycle (RFC-014 Story C). The notice fires
45
+ # on every invocation per ADR-078 Confirmation criterion (j).
46
+ echo "generate-decisions-compendium: DEPRECATED per ADR-078 (Option 9) — the compendium is now architect-authored on every ADR edit (architect-compendium-update-entry.sh PostToolUse hook). This script is a backstop/bootstrap tool only and will be removed after one @windyroad/architect minor-version cycle (RFC-014 Story C)." >&2
47
+
37
48
  # --- Flag parsing ----------------------------------------------------------
38
49
  # `--check` (no write): generate to a temp file and diff against the on-disk
39
50
  # compendium. Exit 0 if byte-identical, 1 if drift, 2 if directory missing.
40
- # Used by the architect-compendium-refresh-discipline.sh enforcement hook
41
- # (Slice 2) to verify the staged compendium matches the working-tree ADRs.
51
+ # Formerly used by the retired architect-compendium-refresh-discipline.sh
52
+ # enforcement hook (Story D, ADR-078) to verify the staged compendium matched
53
+ # the working-tree ADRs. Under Option 9 the compendium is architect-authored,
54
+ # so --check no longer has a live caller; it is retained for the backstop /
55
+ # offline-reproducibility window only.
42
56
  CHECK_MODE=0
43
57
  case "${1:-}" in
44
58
  --check)
@@ -53,10 +53,15 @@ mk_adr() {
53
53
  # --- ADR-077 (g) drift-detection contract on the live committed state -------
54
54
 
55
55
  @test "committed compendium matches generator output (CI drift gate)" {
56
- # This is the load-bearing assertion from ADR-077 (g): the committed
57
- # docs/decisions/README.md MUST match what the generator produces from
58
- # the current docs/decisions/<NNN>-*.md bodies. If this fails in CI, the
59
- # safety-net hook either failed open or was bypassed.
56
+ # RETIRED per ADR-078 (Option 9) / RFC-014 Story C (test 2145). Under
57
+ # architect-on-edit authoring the committed docs/decisions/README.md is
58
+ # LLM-authored and intentionally no longer byte-matches programmatic
59
+ # generator output, so this idempotency/drift assertion no longer holds.
60
+ # Replacement enforcement: the architect-readme-pairing-check.sh pre-commit
61
+ # hook (Story B) asserts body↔README pairing at commit time. Removed entirely
62
+ # with the generator script after the backstop window (ADR-078 reassessment
63
+ # 2026-08-30).
64
+ skip "test 2145 retired per ADR-078 Option 9 — compendium is architect-authored, not generator-derived (RFC-014 Story C; pairing enforced by architect-readme-pairing-check.sh)"
60
65
  cd "$REPO_ROOT"
61
66
  run bash "$SCRIPT" --check docs/decisions
62
67
  [ "$status" -eq 0 ]
@@ -1,113 +0,0 @@
1
- #!/usr/bin/env bash
2
- # architect-compendium-refresh-discipline.sh — PreToolUse:Bash hook
3
- # (safety net per ADR-077). Denies `git commit` invocations whose staged
4
- # set includes a `docs/decisions/<NNN>-*.md` ADR change but does NOT also
5
- # stage a refreshed `docs/decisions/README.md` compendium that matches
6
- # the current ADR bodies.
7
- #
8
- # Per ADR-077: the architect agent and the architect skills
9
- # (`/wr-architect:create-adr`, `/wr-architect:capture-adr`,
10
- # `/wr-architect:review-decisions`) are the PRIMARY mechanism for keeping
11
- # the compendium fresh — they invoke `wr-architect-generate-decisions-compendium`
12
- # at the right point in their flows. This hook is the SAFETY NET: it
13
- # catches edits that bypass those flows (hand-edits via Edit/Write tools,
14
- # off-skill bulk renames, direct file modifications). Mirrors the
15
- # P165 `itil-readme-refresh-discipline.sh` pattern at the decisions surface.
16
- #
17
- # Allow paths (exit 0 silently per ADR-045 Pattern 1):
18
- # - tool_name != "Bash"
19
- # - command's leading effective executable is not `git commit`
20
- # - `RISK_BYPASS: architect-compendium-deferred` token present in command
21
- # (intentional follow-up refresh; same allow-list shape as the P165 +
22
- # ADR-014 commit-message bypass-token pattern)
23
- # - staged set has no `docs/decisions/<NNN>-*.md` ADR change
24
- #
25
- # Deny paths (exit 2 with PreToolUse deny JSON on stderr):
26
- # - ADR staged but compendium not staged
27
- # - both staged but staged compendium does not match generator output
28
- # against current working-tree ADR bodies
29
- #
30
- # Recovery is mechanical per ADR-013 Rule 1:
31
- # wr-architect-generate-decisions-compendium && git add docs/decisions/README.md
32
- #
33
- # Override (legitimate intentional split):
34
- # append "RISK_BYPASS: architect-compendium-deferred" to the commit message
35
- #
36
- # Cross-ref: ADR-077 Confirmation item (h). See also packages/itil/hooks/itil-readme-refresh-discipline.sh
37
- # for the P165 sibling pattern.
38
-
39
- set -uo pipefail
40
-
41
- # PreToolUse input arrives on stdin as JSON.
42
- input=$(cat)
43
-
44
- # Tool gate: only Bash.
45
- tool_name=$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null)
46
- [ "$tool_name" = "Bash" ] || exit 0
47
-
48
- # Extract the command.
49
- command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null)
50
- [ -n "$command" ] || exit 0
51
-
52
- # Only fire on `git commit` invocations. Leading-executable check (P268
53
- # pattern): substring "git commit" anywhere can match unrelated commands
54
- # (e.g. grep / sed / cat with that literal). We check the first effective
55
- # token sequence: optional env-var assignments + optional `git`-aliasing
56
- # wrappers (none in this codebase) + the literal `git commit`.
57
- echo "$command" | awk '
58
- {
59
- # Strip leading whitespace and env assignments (FOO=bar).
60
- sub(/^[[:space:]]+/, "")
61
- while ($0 ~ /^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+/) {
62
- sub(/^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+/, "")
63
- }
64
- # Match git followed by commit.
65
- if ($0 ~ /^git[[:space:]]+commit\b/) exit 0
66
- exit 1
67
- }
68
- ' || exit 0
69
-
70
- # Allow-list bypass token. Same shape as P165 and ADR-014.
71
- if echo "$command" | grep -qF 'RISK_BYPASS: architect-compendium-deferred'; then
72
- exit 0
73
- fi
74
-
75
- # Env-var bypass for batch/migration cases (parity with BYPASS_README_REFRESH_GATE).
76
- if [ "${BYPASS_COMPENDIUM_REFRESH_GATE:-0}" = "1" ]; then
77
- exit 0
78
- fi
79
-
80
- # Resolve repo root so subsequent git plumbing is path-stable.
81
- repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
82
- cd "$repo_root" || exit 0
83
-
84
- # Inspect the staged set.
85
- staged_adrs=$(git diff --cached --name-only 2>/dev/null \
86
- | awk '/^docs\/decisions\/[0-9]+-.*\.md$/ { print }' \
87
- | head -20)
88
- [ -n "$staged_adrs" ] || exit 0
89
-
90
- staged_compendium=$(git diff --cached --name-only 2>/dev/null \
91
- | awk '/^docs\/decisions\/README\.md$/ { print }')
92
-
93
- if [ -z "$staged_compendium" ]; then
94
- # ADR staged but compendium not staged. Deny.
95
- first_adr=$(echo "$staged_adrs" | head -1)
96
- cat >&2 <<EOF
97
- {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "architect-compendium-refresh-discipline: '${first_adr}' is staged for commit but 'docs/decisions/README.md' is NOT. The compendium is the architect agent's routine load surface (ADR-077). Run: wr-architect-generate-decisions-compendium && git add docs/decisions/README.md. Intentional follow-up split: append 'RISK_BYPASS: architect-compendium-deferred' to the commit message. Batch/migration: set BYPASS_COMPENDIUM_REFRESH_GATE=1."}}
98
- EOF
99
- exit 2
100
- fi
101
-
102
- # Both staged. Verify the staged compendium matches generator output for the
103
- # current ADR bodies (working tree). The --check mode generates to temp, no
104
- # mutation. Exit 0 => match; exit 1 => stale.
105
- if ! wr-architect-generate-decisions-compendium --check >/dev/null 2>&1; then
106
- cat >&2 <<EOF
107
- {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "architect-compendium-refresh-discipline: 'docs/decisions/README.md' is staged but does NOT match the current ADR bodies (stale compendium). Run: wr-architect-generate-decisions-compendium && git add docs/decisions/README.md to refresh, then re-commit. Intentional follow-up split: append 'RISK_BYPASS: architect-compendium-deferred' to the commit message."}}
108
- EOF
109
- exit 2
110
- fi
111
-
112
- # All clear.
113
- exit 0