@windyroad/retrospective 0.16.0 → 0.17.0-preview.277

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.
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env bash
2
+ # packages/retrospective/scripts/list-plugin-attribution.sh
3
+ #
4
+ # Read-only diagnostic for per-plugin hooks/skills byte attribution
5
+ # consumed by the deep-layer skill `/wr-retrospective:analyze-context`
6
+ # (Step 2 — Decompose per-plugin attribution).
7
+ #
8
+ # Replaces the inline `for plugin_dir in packages/*/hooks; do ... done`
9
+ # loops that lived in SKILL.md before P153/ADR-049-reassessment.clause-3.
10
+ # Those loops worked in source-repo dev sessions but expanded to nothing
11
+ # in adopter sessions (no `packages/` dir under adopter project root),
12
+ # emitting zero PLUGIN-HOOKS / PLUGIN-SKILLS rows with no error signal.
13
+ #
14
+ # This helper resolves both modes:
15
+ # 1. Source-tree mode — walk `<project-root>/packages/<plugin>/{hooks,skills}`.
16
+ # 2. Cache-fallback — sniff `$PATH` for entries shaped like
17
+ # `*/cache/<owner>/<plugin>/<version>/bin`
18
+ # and back-walk to each plugin's root.
19
+ # 3. Neither resolves — emit `PLUGIN-ATTRIBUTION not-measured
20
+ # reason=no-plugin-source-resolvable` per ADR-026.
21
+ #
22
+ # Usage:
23
+ # list-plugin-attribution.sh [<project-root>]
24
+ #
25
+ # Default <project-root> is the current working directory.
26
+ #
27
+ # Output (one row per plugin, terse machine-readable per ADR-038 ≤150 bytes):
28
+ # PLUGIN-HOOKS <plugin> bytes=<N>
29
+ # PLUGIN-SKILLS <plugin> bytes=<N>
30
+ # PLUGIN-ATTRIBUTION not-measured reason=<reason>
31
+ #
32
+ # Sorted by row-type then plugin name for stable diffs.
33
+ #
34
+ # Exit code: 0 always (advisory only — matches measure-context-budget.sh
35
+ # contract; missing data is signal, not failure).
36
+ #
37
+ # @problem P153
38
+ # @adr ADR-049 (Plugin-bundled scripts via `bin/` on `$PATH` —
39
+ # reassessment-criteria clause 3 explicitly anticipates this surface)
40
+ # @adr ADR-038 (Progressive disclosure — per-row byte budget)
41
+ # @adr ADR-026 (Agent output grounding — explicit not-measured sentinels
42
+ # when neither resolution mode resolves)
43
+ # @jtbd JTBD-301 (Plugin-user) / JTBD-101 (Plugin-developer)
44
+
45
+ set -uo pipefail
46
+
47
+ PROJECT_ROOT="${1:-$(pwd)}"
48
+
49
+ sum_dir_bytes() {
50
+ local dir="$1"
51
+ local pattern="$2"
52
+ if [ ! -d "$dir" ] || [ ! -r "$dir" ]; then
53
+ echo 0
54
+ return
55
+ fi
56
+ local total=0 b
57
+ while IFS= read -r -d '' f; do
58
+ b=$(wc -c < "$f" 2>/dev/null | tr -d ' ')
59
+ total=$(( total + ${b:-0} ))
60
+ done < <(find "$dir" -type f -name "$pattern" -print0 2>/dev/null)
61
+ echo "$total"
62
+ }
63
+
64
+ declare -a ROWS=()
65
+
66
+ emit_plugin_row() {
67
+ local row_type="$1"
68
+ local plugin="$2"
69
+ local bytes="$3"
70
+ ROWS+=( "$row_type $plugin bytes=$bytes" )
71
+ }
72
+
73
+ source_resolved=0
74
+
75
+ if [ -d "$PROJECT_ROOT/packages" ]; then
76
+ shopt -s nullglob
77
+ hook_dirs=( "$PROJECT_ROOT"/packages/*/hooks )
78
+ skill_dirs=( "$PROJECT_ROOT"/packages/*/skills )
79
+ shopt -u nullglob
80
+
81
+ for d in ${hook_dirs[@]+"${hook_dirs[@]}"}; do
82
+ plugin=$(basename "$(dirname "$d")")
83
+ bytes=$(sum_dir_bytes "$d" '*.sh')
84
+ emit_plugin_row PLUGIN-HOOKS "$plugin" "$bytes"
85
+ source_resolved=1
86
+ done
87
+
88
+ for d in ${skill_dirs[@]+"${skill_dirs[@]}"}; do
89
+ plugin=$(basename "$(dirname "$d")")
90
+ bytes=$(sum_dir_bytes "$d" 'SKILL.md')
91
+ emit_plugin_row PLUGIN-SKILLS "$plugin" "$bytes"
92
+ source_resolved=1
93
+ done
94
+ fi
95
+
96
+ cache_resolved=0
97
+
98
+ if [ "$source_resolved" -eq 0 ] && [ -n "${PATH:-}" ]; then
99
+ # bash 3.2 on macOS lacks `declare -A` — track seen plugins in a
100
+ # delimiter-bounded string. Membership probe: case "$SEEN_PLUGINS" in
101
+ # *"|$plugin|"*) ;; esac.
102
+ SEEN_PLUGINS="|"
103
+ IFS=':' read -r -a path_entries <<< "$PATH"
104
+
105
+ for entry in ${path_entries[@]+"${path_entries[@]}"}; do
106
+ [ -z "$entry" ] && continue
107
+ entry="${entry%/}"
108
+ [[ "$entry" == */bin ]] || continue
109
+ [[ "$entry" == */cache/* ]] || continue
110
+
111
+ plugin_root="${entry%/bin}"
112
+ plugin=$(basename "$(dirname "$plugin_root")")
113
+ [ -z "$plugin" ] && continue
114
+ case "$SEEN_PLUGINS" in *"|$plugin|"*) continue ;; esac
115
+ SEEN_PLUGINS="${SEEN_PLUGINS}${plugin}|"
116
+
117
+ hooks_dir="$plugin_root/hooks"
118
+ skills_dir="$plugin_root/skills"
119
+
120
+ if [ -d "$hooks_dir" ]; then
121
+ bytes=$(sum_dir_bytes "$hooks_dir" '*.sh')
122
+ emit_plugin_row PLUGIN-HOOKS "$plugin" "$bytes"
123
+ cache_resolved=1
124
+ fi
125
+ if [ -d "$skills_dir" ]; then
126
+ bytes=$(sum_dir_bytes "$skills_dir" 'SKILL.md')
127
+ emit_plugin_row PLUGIN-SKILLS "$plugin" "$bytes"
128
+ cache_resolved=1
129
+ fi
130
+ done
131
+ fi
132
+
133
+ if [ "$source_resolved" -eq 1 ] || [ "$cache_resolved" -eq 1 ]; then
134
+ printf '%s\n' ${ROWS[@]+"${ROWS[@]}"} | LC_ALL=C sort
135
+ else
136
+ echo "PLUGIN-ATTRIBUTION not-measured reason=no-plugin-source-resolvable"
137
+ fi
138
+
139
+ exit 0
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env bash
2
+ # packages/retrospective/scripts/measure-context-budget.sh
3
+ #
4
+ # Read-only diagnostic script for context-usage measurement (P101 / ADR-043
5
+ # Progressive context-usage measurement and reporting for retrospective sessions).
6
+ # Walks the session's on-disk context contributors and reports per-source
7
+ # bucket byte totals so run-retro Step 2c can render the cheap-layer table
8
+ # (interactive or AFK; same output shape) and the deep-layer skill can
9
+ # consume the same data as its baseline.
10
+ #
11
+ # Usage:
12
+ # measure-context-budget.sh [<project-root>]
13
+ #
14
+ # Default <project-root> is $CLAUDE_PROJECT_DIR if set, else the current
15
+ # working directory.
16
+ #
17
+ # Threshold for the optional fail-open ceiling is read from
18
+ # CONTEXT_BUDGET_MAX_BYTES (default 10240 — the 5% / 200K cheap-layer
19
+ # envelope per ADR-043). The script does NOT enforce the threshold; it
20
+ # is exposed for the bats fixture and for Step 2c's defensive trip.
21
+ #
22
+ # Exit codes:
23
+ # 0 = always (advisory only — overflow is signal, not failure)
24
+ # 2 = parse error (project root missing or unreadable)
25
+ #
26
+ # Output format (one line per bucket, terse machine-readable per ADR-038
27
+ # progressive-disclosure budget — ≤150 bytes per row):
28
+ # BUCKET <name> bytes=<N>
29
+ # BUCKET <name> not-measured reason=<reason>
30
+ #
31
+ # The output is sorted by bucket name for stable diffs (per the
32
+ # check-briefing-budgets.sh precedent + bats fixture contract).
33
+ #
34
+ # Read-only — does NOT mutate any project file. Snapshot persistence
35
+ # (HTML-comment trailer in docs/retros/<date>-context-analysis.md) is the
36
+ # deep-layer skill's responsibility, not this script's.
37
+ #
38
+ # @problem P101
39
+ # @adr ADR-043 (Progressive context-usage measurement and reporting for
40
+ # retrospective sessions; this script is the measurement primitive)
41
+ # @adr ADR-038 (Progressive disclosure — per-row byte budget)
42
+ # @adr ADR-026 (Agent output grounding — explicit not-measured sentinels
43
+ # for surfaces without an on-disk source)
44
+ # @adr ADR-013 (Rule 1 / Rule 6 — interactive vs AFK; this script's
45
+ # advisory exit-0 contract supports both)
46
+ # @adr ADR-005 (Plugin testing strategy)
47
+ # @adr ADR-037 (Skill testing strategy — bats-contract precedent)
48
+ # @jtbd JTBD-001 / JTBD-005 / JTBD-006
49
+
50
+ set -uo pipefail
51
+
52
+ PROJECT_ROOT="${1:-${CLAUDE_PROJECT_DIR:-$(pwd)}}"
53
+ THRESHOLD="${CONTEXT_BUDGET_MAX_BYTES:-10240}"
54
+
55
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
56
+
57
+ if [ ! -d "$PROJECT_ROOT" ]; then
58
+ echo "measure-context-budget: project root not found: $PROJECT_ROOT" >&2
59
+ exit 2
60
+ fi
61
+
62
+ # Helper — sum byte sizes of files matching a glob. Returns 0 (zero bytes)
63
+ # if the glob matches nothing, distinguishing "scanned, empty" from
64
+ # "surface not present".
65
+ sum_globs() {
66
+ local total=0
67
+ local file
68
+ for pattern in "$@"; do
69
+ # Use shopt nullglob so an empty match expands to nothing
70
+ shopt -s nullglob
71
+ local matches=( $pattern )
72
+ shopt -u nullglob
73
+ for file in "${matches[@]}"; do
74
+ if [ -f "$file" ] && [ -r "$file" ]; then
75
+ local bytes
76
+ bytes=$(wc -c < "$file" 2>/dev/null | tr -d ' ')
77
+ total=$(( total + ${bytes:-0} ))
78
+ fi
79
+ done
80
+ done
81
+ echo "$total"
82
+ }
83
+
84
+ # Helper — emit one bucket row. Use a "not-measured" sentinel when the
85
+ # surface is absent (e.g. project has no docs/jtbd/) per ADR-026's
86
+ # ungrounded-field rule.
87
+ emit_bucket() {
88
+ local name="$1"
89
+ local bytes="$2"
90
+ local present="$3" # 1 if surface present, 0 if absent
91
+ local reason="${4:-source-absent}"
92
+ if [ "$present" = "1" ]; then
93
+ echo "BUCKET $name bytes=$bytes"
94
+ else
95
+ echo "BUCKET $name not-measured reason=$reason"
96
+ fi
97
+ }
98
+
99
+ # ── Bucket: hooks ───────────────────────────────────────────────────────────
100
+ # Aggregate over packages/*/hooks/**/*.sh + project-local .claude/hooks/**/*.sh
101
+ # Surface present if either exists.
102
+
103
+ hooks_present=0
104
+ if [ -d "$PROJECT_ROOT/packages" ] || [ -d "$PROJECT_ROOT/.claude/hooks" ]; then
105
+ hooks_present=1
106
+ fi
107
+
108
+ if [ "$hooks_present" = "1" ]; then
109
+ hooks_bytes=$(
110
+ cd "$PROJECT_ROOT" 2>/dev/null && {
111
+ shopt -s nullglob globstar
112
+ pkg_files=( packages/*/hooks/**/*.sh )
113
+ proj_files=( .claude/hooks/**/*.sh )
114
+ shopt -u nullglob globstar
115
+ total=0
116
+ for f in "${pkg_files[@]}" "${proj_files[@]}"; do
117
+ if [ -f "$f" ] && [ -r "$f" ]; then
118
+ b=$(wc -c < "$f" 2>/dev/null | tr -d ' ')
119
+ total=$(( total + ${b:-0} ))
120
+ fi
121
+ done
122
+ echo "$total"
123
+ }
124
+ )
125
+ fi
126
+ emit_bucket hooks "${hooks_bytes:-0}" "$hooks_present"
127
+
128
+ # ── Bucket: skills ──────────────────────────────────────────────────────────
129
+ # Aggregate over packages/*/skills/**/SKILL.md + .claude/skills/**/SKILL.md
130
+
131
+ skills_present=0
132
+ if [ -d "$PROJECT_ROOT/packages" ] || [ -d "$PROJECT_ROOT/.claude/skills" ]; then
133
+ skills_present=1
134
+ fi
135
+
136
+ if [ "$skills_present" = "1" ]; then
137
+ skills_bytes=$(
138
+ cd "$PROJECT_ROOT" 2>/dev/null && {
139
+ shopt -s nullglob globstar
140
+ pkg_files=( packages/*/skills/**/SKILL.md )
141
+ proj_files=( .claude/skills/**/SKILL.md )
142
+ shopt -u nullglob globstar
143
+ total=0
144
+ for f in "${pkg_files[@]}" "${proj_files[@]}"; do
145
+ if [ -f "$f" ] && [ -r "$f" ]; then
146
+ b=$(wc -c < "$f" 2>/dev/null | tr -d ' ')
147
+ total=$(( total + ${b:-0} ))
148
+ fi
149
+ done
150
+ echo "$total"
151
+ }
152
+ )
153
+ fi
154
+ emit_bucket skills "${skills_bytes:-0}" "$skills_present"
155
+
156
+ # ── Bucket: briefing ────────────────────────────────────────────────────────
157
+ # Aggregate over docs/briefing/*.md (top-level only; nested archives are
158
+ # the deep layer's concern). Single bucket row aggregating per-file detail
159
+ # already exposed via P099's check-briefing-budgets.sh.
160
+
161
+ briefing_dir="$PROJECT_ROOT/docs/briefing"
162
+ briefing_present=0
163
+ briefing_bytes=0
164
+ if [ -d "$briefing_dir" ]; then
165
+ briefing_present=1
166
+ briefing_bytes=$( cd "$PROJECT_ROOT" && sum_globs "docs/briefing/*.md" )
167
+ fi
168
+ emit_bucket briefing "$briefing_bytes" "$briefing_present"
169
+
170
+ # ── Bucket: decisions ───────────────────────────────────────────────────────
171
+
172
+ decisions_dir="$PROJECT_ROOT/docs/decisions"
173
+ decisions_present=0
174
+ decisions_bytes=0
175
+ if [ -d "$decisions_dir" ]; then
176
+ decisions_present=1
177
+ decisions_bytes=$( cd "$PROJECT_ROOT" && sum_globs "docs/decisions/*.md" )
178
+ fi
179
+ emit_bucket decisions "$decisions_bytes" "$decisions_present"
180
+
181
+ # ── Bucket: problems ────────────────────────────────────────────────────────
182
+
183
+ problems_dir="$PROJECT_ROOT/docs/problems"
184
+ problems_present=0
185
+ problems_bytes=0
186
+ if [ -d "$problems_dir" ]; then
187
+ problems_present=1
188
+ problems_bytes=$( cd "$PROJECT_ROOT" && sum_globs "docs/problems/*.md" )
189
+ fi
190
+ emit_bucket problems "$problems_bytes" "$problems_present"
191
+
192
+ # ── Bucket: jtbd ────────────────────────────────────────────────────────────
193
+
194
+ jtbd_dir="$PROJECT_ROOT/docs/jtbd"
195
+ jtbd_present=0
196
+ jtbd_bytes=0
197
+ if [ -d "$jtbd_dir" ]; then
198
+ jtbd_present=1
199
+ jtbd_bytes=$(
200
+ cd "$PROJECT_ROOT" 2>/dev/null && {
201
+ shopt -s nullglob globstar
202
+ files=( docs/jtbd/**/*.md )
203
+ shopt -u nullglob globstar
204
+ total=0
205
+ for f in "${files[@]}"; do
206
+ if [ -f "$f" ] && [ -r "$f" ]; then
207
+ b=$(wc -c < "$f" 2>/dev/null | tr -d ' ')
208
+ total=$(( total + ${b:-0} ))
209
+ fi
210
+ done
211
+ echo "$total"
212
+ }
213
+ )
214
+ fi
215
+ emit_bucket jtbd "$jtbd_bytes" "$jtbd_present"
216
+
217
+ # ── Bucket: project-claude-md ───────────────────────────────────────────────
218
+
219
+ project_claude_md="$PROJECT_ROOT/CLAUDE.md"
220
+ claude_md_present=0
221
+ claude_md_bytes=0
222
+ if [ -f "$project_claude_md" ] && [ -r "$project_claude_md" ]; then
223
+ claude_md_present=1
224
+ claude_md_bytes=$( wc -c < "$project_claude_md" 2>/dev/null | tr -d ' ' )
225
+ fi
226
+ emit_bucket project-claude-md "${claude_md_bytes:-0}" "$claude_md_present"
227
+
228
+ # ── Bucket: memory ──────────────────────────────────────────────────────────
229
+ # User-owned per-project memory files. Read-only attempt; emit not-measured
230
+ # sentinel when the directory is inaccessible (e.g. the running agent is
231
+ # in a different user account or the path doesn't exist for this project).
232
+
233
+ memory_root="${HOME:-/tmp}/.claude/projects"
234
+ memory_present=0
235
+ memory_bytes=0
236
+ if [ -d "$memory_root" ] && [ -r "$memory_root" ]; then
237
+ # Best-effort: sum *.md files under any subdirectory of memory_root.
238
+ # Per-project filtering is the deep layer's concern.
239
+ memory_present=1
240
+ shopt -s nullglob globstar
241
+ mem_files=( "$memory_root"/**/memory/*.md )
242
+ shopt -u nullglob globstar
243
+ for f in "${mem_files[@]}"; do
244
+ if [ -f "$f" ] && [ -r "$f" ]; then
245
+ b=$(wc -c < "$f" 2>/dev/null | tr -d ' ')
246
+ memory_bytes=$(( memory_bytes + ${b:-0} ))
247
+ fi
248
+ done
249
+ fi
250
+ if [ "$memory_present" = "1" ]; then
251
+ emit_bucket memory "$memory_bytes" 1
252
+ else
253
+ emit_bucket memory 0 0 user-memory-inaccessible
254
+ fi
255
+
256
+ # ── Bucket: framework-injected ──────────────────────────────────────────────
257
+ # Available-skills, subagent-types, deferred-tools listings are emitted by
258
+ # the framework on every turn but are NOT byte-countable from the project
259
+ # filesystem. Per ADR-026 ungrounded-field rule, emit explicit sentinel.
260
+
261
+ emit_bucket framework-injected 0 0 framework-injected-no-on-disk-source
262
+
263
+ # ── Done ────────────────────────────────────────────────────────────────────
264
+ # Threshold is exposed for callers (Step 2c defensive trip + bats fixture).
265
+ # Echoed as a trailing diagnostic line — callers can grep for `THRESHOLD `
266
+ # to retrieve it without parsing every BUCKET row.
267
+
268
+ echo "THRESHOLD bytes=$THRESHOLD"
269
+
270
+ exit 0
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env bats
2
+ #
3
+ # packages/retrospective/scripts/test/check-ask-hygiene.bats
4
+ #
5
+ # Behavioural tests for `check-ask-hygiene.sh` — the ask-hygiene trail
6
+ # advisory script (P135 Phase 5 / ADR-044). Mirrors the test pattern of
7
+ # `check-briefing-budgets.bats`.
8
+ #
9
+ # Tests are behavioural per ADR-005 / ADR-037 — they exercise the
10
+ # script end-to-end against fixture trail directories and assert on
11
+ # stdout / stderr / exit shape. No structural greps of the script
12
+ # source itself per ADR-044's deviation-default to behavioural-by-
13
+ # default for skill / script testing.
14
+ #
15
+ # @problem P135 (Phase 5 measurement)
16
+ # @adr ADR-044 (Decision-Delegation Contract — lazy-count metric)
17
+ # @adr ADR-040 (Tier 3 advisory-not-fail-closed)
18
+ # @adr ADR-005 / ADR-037 (Plugin testing strategy — behavioural tests)
19
+ # @jtbd JTBD-001 / JTBD-006 / JTBD-201
20
+
21
+ SCRIPT="${BATS_TEST_DIRNAME}/../check-ask-hygiene.sh"
22
+
23
+ setup() {
24
+ TEST_DIR="$(mktemp -d)"
25
+ }
26
+
27
+ teardown() {
28
+ rm -rf "$TEST_DIR"
29
+ }
30
+
31
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
32
+
33
+ @test "script file exists and is executable" {
34
+ [ -f "$SCRIPT" ]
35
+ [ -x "$SCRIPT" ]
36
+ }
37
+
38
+ @test "missing retros dir exits 2 with error message on stderr" {
39
+ run bash "$SCRIPT" "$TEST_DIR/does-not-exist"
40
+ [ "$status" -eq 2 ]
41
+ [[ "$output" == *"retros dir not found"* ]]
42
+ }
43
+
44
+ @test "empty retros dir exits 0 with empty stdout" {
45
+ run bash "$SCRIPT" "$TEST_DIR"
46
+ [ "$status" -eq 0 ]
47
+ [ -z "$output" ]
48
+ }
49
+
50
+ @test "default retros-dir argument is docs/retros (when omitted)" {
51
+ # Behavioural: script must accept no-arg invocation when invoked from a project root.
52
+ # We exercise from a fresh tmp dir with no docs/retros to assert the missing-dir
53
+ # behaviour fires when the default path is used.
54
+ cd "$TEST_DIR"
55
+ run bash "$SCRIPT"
56
+ [ "$status" -eq 2 ]
57
+ [[ "$output" == *"docs/retros"* ]]
58
+ }
59
+
60
+ # ── Single-entry behaviour ──────────────────────────────────────────────────
61
+
62
+ @test "single trail entry emits one RETRO line and no TREND" {
63
+ cat > "$TEST_DIR/2026-04-27-ask-hygiene.md" <<'TRAIL'
64
+ **Lazy count: 5**
65
+ **Direction count: 2**
66
+ TRAIL
67
+ run bash "$SCRIPT" "$TEST_DIR"
68
+ [ "$status" -eq 0 ]
69
+ [[ "$output" == *"RETRO 2026-04-27 lazy=5 direction=2"* ]]
70
+ [[ "$output" != *"TREND"* ]]
71
+ }
72
+
73
+ @test "trail entry without lazy-count line is skipped silently" {
74
+ cat > "$TEST_DIR/2026-04-27-ask-hygiene.md" <<'TRAIL'
75
+ # Some retro file with no lazy count
76
+ **Direction count: 3**
77
+ TRAIL
78
+ run bash "$SCRIPT" "$TEST_DIR"
79
+ [ "$status" -eq 0 ]
80
+ [ -z "$output" ]
81
+ }
82
+
83
+ # ── Multi-entry behaviour ───────────────────────────────────────────────────
84
+
85
+ @test "multiple trail entries emit RETRO lines sorted oldest-first by date" {
86
+ cat > "$TEST_DIR/2026-04-27-ask-hygiene.md" <<'TRAIL'
87
+ **Lazy count: 1**
88
+ TRAIL
89
+ cat > "$TEST_DIR/2026-04-25-ask-hygiene.md" <<'TRAIL'
90
+ **Lazy count: 5**
91
+ TRAIL
92
+ cat > "$TEST_DIR/2026-04-26-ask-hygiene.md" <<'TRAIL'
93
+ **Lazy count: 3**
94
+ TRAIL
95
+ run bash "$SCRIPT" "$TEST_DIR"
96
+ [ "$status" -eq 0 ]
97
+ # Assert order: 04-25 (oldest) → 04-26 → 04-27 (newest)
98
+ line1="${lines[0]}"
99
+ line2="${lines[1]}"
100
+ line3="${lines[2]}"
101
+ [[ "$line1" == *"2026-04-25"* ]]
102
+ [[ "$line2" == *"2026-04-26"* ]]
103
+ [[ "$line3" == *"2026-04-27"* ]]
104
+ }
105
+
106
+ @test "two-or-more entries emit TREND line with first/last/delta" {
107
+ cat > "$TEST_DIR/2026-04-25-ask-hygiene.md" <<'TRAIL'
108
+ **Lazy count: 5**
109
+ TRAIL
110
+ cat > "$TEST_DIR/2026-04-27-ask-hygiene.md" <<'TRAIL'
111
+ **Lazy count: 1**
112
+ TRAIL
113
+ run bash "$SCRIPT" "$TEST_DIR"
114
+ [ "$status" -eq 0 ]
115
+ [[ "$output" == *"TREND lazy_first=5 lazy_last=1 delta=-4"* ]]
116
+ }
117
+
118
+ @test "TREND line shows positive delta when lazy count grew" {
119
+ cat > "$TEST_DIR/2026-04-25-ask-hygiene.md" <<'TRAIL'
120
+ **Lazy count: 1**
121
+ TRAIL
122
+ cat > "$TEST_DIR/2026-04-27-ask-hygiene.md" <<'TRAIL'
123
+ **Lazy count: 4**
124
+ TRAIL
125
+ run bash "$SCRIPT" "$TEST_DIR"
126
+ [ "$status" -eq 0 ]
127
+ [[ "$output" == *"TREND lazy_first=1 lazy_last=4 delta=+3"* ]]
128
+ }
129
+
130
+ @test "TREND line shows zero delta when lazy count unchanged" {
131
+ cat > "$TEST_DIR/2026-04-25-ask-hygiene.md" <<'TRAIL'
132
+ **Lazy count: 2**
133
+ TRAIL
134
+ cat > "$TEST_DIR/2026-04-27-ask-hygiene.md" <<'TRAIL'
135
+ **Lazy count: 2**
136
+ TRAIL
137
+ run bash "$SCRIPT" "$TEST_DIR"
138
+ [ "$status" -eq 0 ]
139
+ [[ "$output" == *"TREND lazy_first=2 lazy_last=2 delta=+0"* ]]
140
+ }
141
+
142
+ # ── Window override ──────────────────────────────────────────────────────────
143
+
144
+ @test "ASK_HYGIENE_WINDOW=2 keeps only the most-recent 2 entries" {
145
+ for d in 25 26 27; do
146
+ cat > "$TEST_DIR/2026-04-$d-ask-hygiene.md" <<TRAIL
147
+ **Lazy count: $d**
148
+ TRAIL
149
+ done
150
+ ASK_HYGIENE_WINDOW=2 run bash "$SCRIPT" "$TEST_DIR"
151
+ [ "$status" -eq 0 ]
152
+ # Assert: 04-25 not present; 04-26 first; 04-27 last
153
+ [[ "$output" != *"2026-04-25"* ]]
154
+ [[ "$output" == *"RETRO 2026-04-26"* ]]
155
+ [[ "$output" == *"RETRO 2026-04-27"* ]]
156
+ # TREND should reflect the windowed pair, not the full 3
157
+ [[ "$output" == *"TREND lazy_first=26 lazy_last=27 delta=+1"* ]]
158
+ }
159
+
160
+ @test "default window 10 keeps all entries when fewer than 10 exist" {
161
+ cat > "$TEST_DIR/2026-04-25-ask-hygiene.md" <<'TRAIL'
162
+ **Lazy count: 5**
163
+ TRAIL
164
+ cat > "$TEST_DIR/2026-04-27-ask-hygiene.md" <<'TRAIL'
165
+ **Lazy count: 1**
166
+ TRAIL
167
+ run bash "$SCRIPT" "$TEST_DIR"
168
+ [ "$status" -eq 0 ]
169
+ [[ "$output" == *"2026-04-25"* ]]
170
+ [[ "$output" == *"2026-04-27"* ]]
171
+ }
172
+
173
+ # ── Category-coverage shape ──────────────────────────────────────────────────
174
+
175
+ @test "RETRO line includes all 6 category counts (lazy + 5 non-lazy)" {
176
+ cat > "$TEST_DIR/2026-04-27-ask-hygiene.md" <<'TRAIL'
177
+ **Lazy count: 0**
178
+ **Direction count: 3**
179
+ **Override count: 1**
180
+ **Silent-framework count: 1**
181
+ **Taste count: 0**
182
+ **Correction-followup count: 0**
183
+ TRAIL
184
+ run bash "$SCRIPT" "$TEST_DIR"
185
+ [ "$status" -eq 0 ]
186
+ [[ "$output" == *"lazy=0"* ]]
187
+ [[ "$output" == *"direction=3"* ]]
188
+ [[ "$output" == *"override=1"* ]]
189
+ [[ "$output" == *"silent=1"* ]]
190
+ [[ "$output" == *"taste=0"* ]]
191
+ [[ "$output" == *"correction=0"* ]]
192
+ }
193
+
194
+ @test "missing non-lazy categories default to 0" {
195
+ cat > "$TEST_DIR/2026-04-27-ask-hygiene.md" <<'TRAIL'
196
+ **Lazy count: 4**
197
+ TRAIL
198
+ run bash "$SCRIPT" "$TEST_DIR"
199
+ [ "$status" -eq 0 ]
200
+ [[ "$output" == *"lazy=4"* ]]
201
+ [[ "$output" == *"direction=0"* ]]
202
+ [[ "$output" == *"override=0"* ]]
203
+ [[ "$output" == *"silent=0"* ]]
204
+ }
205
+
206
+ # ── Format tolerance ──────────────────────────────────────────────────────────
207
+
208
+ @test "lazy-count line works without bold markdown asterisks" {
209
+ cat > "$TEST_DIR/2026-04-27-ask-hygiene.md" <<'TRAIL'
210
+ Lazy count: 7
211
+ TRAIL
212
+ run bash "$SCRIPT" "$TEST_DIR"
213
+ [ "$status" -eq 0 ]
214
+ [[ "$output" == *"lazy=7"* ]]
215
+ }
216
+
217
+ @test "lazy-count line is case-insensitive on the LAZY label" {
218
+ cat > "$TEST_DIR/2026-04-27-ask-hygiene.md" <<'TRAIL'
219
+ **lazy count: 8**
220
+ TRAIL
221
+ run bash "$SCRIPT" "$TEST_DIR"
222
+ [ "$status" -eq 0 ]
223
+ [[ "$output" == *"lazy=8"* ]]
224
+ }
225
+
226
+ # ── Cross-shell portability (P124 / P133 lessons) ───────────────────────────
227
+
228
+ @test "script glob iteration uses portable for-loop existence check (no shopt nullglob)" {
229
+ # Behavioural: invoking an empty retros dir must NOT emit a literal
230
+ # `*-ask-hygiene.md` because the glob unexpanded to a literal pattern.
231
+ # The script's portable iteration handles this without zsh `shopt -s nullglob`.
232
+ run bash "$SCRIPT" "$TEST_DIR"
233
+ [ "$status" -eq 0 ]
234
+ [[ "$output" != *"*-ask-hygiene.md"* ]]
235
+ }
236
+
237
+ # ── Read-only contract ──────────────────────────────────────────────────────
238
+
239
+ @test "script is read-only — fixture tree unchanged after run" {
240
+ cat > "$TEST_DIR/2026-04-27-ask-hygiene.md" <<'TRAIL'
241
+ **Lazy count: 3**
242
+ TRAIL
243
+ pre_hash=$(find "$TEST_DIR" -type f -exec cksum {} \; 2>/dev/null | sort | cksum | awk '{print $1}')
244
+ run bash "$SCRIPT" "$TEST_DIR"
245
+ [ "$status" -eq 0 ]
246
+ post_hash=$(find "$TEST_DIR" -type f -exec cksum {} \; 2>/dev/null | sort | cksum | awk '{print $1}')
247
+ [ "$pre_hash" = "$post_hash" ]
248
+ }