@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,130 @@
1
+ #!/usr/bin/env bash
2
+ # packages/retrospective/scripts/check-skill-md-budgets.sh
3
+ #
4
+ # Diagnose-only advisory script for SKILL.md byte budgets per ADR-054.
5
+ # Walks `<root-dir>/packages/*/skills/*/SKILL.md` and
6
+ # `<root-dir>/.claude/skills/*/SKILL.md`, measures byte size per file, and
7
+ # reports each SKILL.md exceeding the WARN threshold so retro Step 2b can
8
+ # surface the rotation candidate (interactive) or defer to retro summary
9
+ # (AFK).
10
+ #
11
+ # Usage:
12
+ # check-skill-md-budgets.sh [<root-dir>]
13
+ #
14
+ # Default <root-dir> is `.`.
15
+ # Thresholds:
16
+ # WARN ≥ SKILL_MD_WARN_BYTES (default 8192)
17
+ # MUST_SPLIT ≥ SKILL_MD_MUST_SPLIT_BYTES (default 16384)
18
+ #
19
+ # Exit codes:
20
+ # 0 = always (advisory only — overflow is signal, not failure)
21
+ # 2 = parse error (root dir missing or unreadable)
22
+ #
23
+ # Output format on overflow (one line per file, terse machine-readable
24
+ # per ADR-038 progressive-disclosure budget):
25
+ # OVER <plugin>/<skill> bytes=<N> threshold=<N>
26
+ #
27
+ # Files at >= MUST_SPLIT also emit a second line:
28
+ # MUST_SPLIT <plugin>/<skill> reason=<code>
29
+ #
30
+ # This mirrors the OVER / MUST_SPLIT pair shape from `check-briefing-budgets.sh`
31
+ # (P099 / P145 / ADR-040) deliberately so adopters learn one concept across
32
+ # two surfaces.
33
+ #
34
+ # Output ordering (deterministic for stable retro-summary diffs):
35
+ # 1. All OVER lines, sorted by `<plugin>/<skill>` identifier.
36
+ # 2. Then all MUST_SPLIT lines, sorted by identifier.
37
+ #
38
+ # Output is empty (no lines) when no SKILL.md exceeds the WARN threshold.
39
+ # REFERENCE.md sibling files (per ADR-054) are excluded from the scan —
40
+ # they are intentionally lazy-loaded and not subject to the runtime budget.
41
+ #
42
+ # Read-only — does NOT mutate any SKILL.md file.
43
+ #
44
+ # @problem P097 (initial advisory — SKILL.md runtime budget surface)
45
+ # @adr ADR-054 (SKILL.md runtime budget policy — taxonomy + sibling pattern + budget)
46
+ # @adr ADR-040 (Session-start briefing surface — Tier 3 OVER / MUST_SPLIT vocabulary precedent)
47
+ # @adr ADR-038 (Progressive disclosure — per-row byte budget)
48
+ # @adr ADR-052 (Behavioural-tests-default — fixture is behavioural)
49
+ # @adr ADR-005 (Plugin testing strategy)
50
+ # @jtbd JTBD-001 / JTBD-006 / JTBD-101
51
+
52
+ set -uo pipefail
53
+
54
+ ROOT_DIR="${1:-.}"
55
+ WARN_BYTES="${SKILL_MD_WARN_BYTES:-8192}"
56
+ MUST_SPLIT_BYTES="${SKILL_MD_MUST_SPLIT_BYTES:-16384}"
57
+
58
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
59
+
60
+ if [ ! -d "$ROOT_DIR" ]; then
61
+ echo "check-skill-md-budgets: root dir not found: $ROOT_DIR" >&2
62
+ exit 2
63
+ fi
64
+
65
+ # ── Scan ────────────────────────────────────────────────────────────────────
66
+ # Collect SKILL.md paths from two surfaces:
67
+ # 1. <root>/packages/*/skills/*/SKILL.md
68
+ # 2. <root>/.claude/skills/*/SKILL.md
69
+ # REFERENCE.md siblings are NOT scanned (per ADR-054).
70
+
71
+ shopt -s nullglob
72
+ plugin_skills=("$ROOT_DIR"/packages/*/skills/*/SKILL.md)
73
+ local_skills=("$ROOT_DIR"/.claude/skills/*/SKILL.md)
74
+ shopt -u nullglob
75
+
76
+ if [ "${#plugin_skills[@]}" -eq 0 ] && [ "${#local_skills[@]}" -eq 0 ]; then
77
+ exit 0
78
+ fi
79
+
80
+ # Build (identifier, bytes) pairs.
81
+ # Identifier shape:
82
+ # plugin-skill: <plugin>/<skill> (e.g. "itil/manage-problem")
83
+ # project-local: .claude/<skill> (e.g. ".claude/install-updates")
84
+ declare -a entries=()
85
+ for path in "${plugin_skills[@]}"; do
86
+ # Path shape: <root>/packages/<plugin>/skills/<skill>/SKILL.md
87
+ skill_dir="$(dirname "$path")"
88
+ skill="$(basename "$skill_dir")"
89
+ plugin="$(basename "$(dirname "$(dirname "$skill_dir")")")"
90
+ identifier="$plugin/$skill"
91
+ bytes=$(wc -c < "$path" | tr -d ' ')
92
+ entries+=("$identifier $bytes")
93
+ done
94
+ for path in "${local_skills[@]}"; do
95
+ # Path shape: <root>/.claude/skills/<skill>/SKILL.md
96
+ skill_dir="$(dirname "$path")"
97
+ skill="$(basename "$skill_dir")"
98
+ identifier=".claude/$skill"
99
+ bytes=$(wc -c < "$path" | tr -d ' ')
100
+ entries+=("$identifier $bytes")
101
+ done
102
+
103
+ if [ "${#entries[@]}" -eq 0 ]; then
104
+ exit 0
105
+ fi
106
+
107
+ # Sort entries by identifier for deterministic output
108
+ IFS=$'\n' sorted=($(printf '%s\n' "${entries[@]}" | sort))
109
+ unset IFS
110
+
111
+ # Pass 1: emit OVER lines for every file at or above WARN threshold.
112
+ # Track MUST_SPLIT candidates (>= MUST_SPLIT_BYTES) for pass 2.
113
+ declare -a must_split=()
114
+ for entry in "${sorted[@]}"; do
115
+ identifier="${entry% *}"
116
+ bytes="${entry##* }"
117
+ if [ "$bytes" -ge "$WARN_BYTES" ]; then
118
+ echo "OVER $identifier bytes=$bytes threshold=$WARN_BYTES"
119
+ if [ "$bytes" -ge "$MUST_SPLIT_BYTES" ]; then
120
+ must_split+=("$identifier")
121
+ fi
122
+ fi
123
+ done
124
+
125
+ # Pass 2: emit MUST_SPLIT lines (already in basename-sorted order from pass 1).
126
+ for identifier in "${must_split[@]}"; do
127
+ echo "MUST_SPLIT $identifier reason=ratio-exceeds-must-split"
128
+ done
129
+
130
+ exit 0
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env bash
2
+ # packages/retrospective/scripts/check-tarball-shipped-shims.sh
3
+ #
4
+ # Diagnose-only advisory script for plugin-published tarball-shipped-shim
5
+ # integrity per WR-P154 (P137 detector must run against npm pack output not
6
+ # source tree) and the WR-ADR-049 plugin-script-resolution-via-bin-on-PATH
7
+ # contract.
8
+ #
9
+ # Walks workspace packages under `<root-dir>/packages/<plugin>/`. For each
10
+ # workspace, runs `npm pack --dry-run --json` to enumerate the file set that
11
+ # WOULD ship to npm (per `package.json#files` filtering). For every
12
+ # WR-ADR-049-grammar bin shim (`bin/wr-<plugin>-<name>`) in that file set,
13
+ # parses the shim source to extract the `exec`'d `scripts/<name>.sh` target
14
+ # and asserts the target path is also in the tarball file set.
15
+ #
16
+ # The asymmetry between source-tree state and tarball state is the gap
17
+ # WR-P137 Phase 1 (check-internal-id-leaks.sh, source-tree-walking) does
18
+ # not see — `scripts/` exists on disk but is omitted from `package.json#files`
19
+ # so the shim shipped to adopters exec-fails with `no such file or directory`
20
+ # at invocation time. WR-P154 closes the prevention surface from the
21
+ # publish-manifest side.
22
+ #
23
+ # Usage:
24
+ # check-tarball-shipped-shims.sh [<root-dir>]
25
+ #
26
+ # Default <root-dir> is `.`.
27
+ #
28
+ # WR-ADR-049-grammar shims that this script considers:
29
+ # bin/wr-<plugin>-<name>
30
+ # Non-grammar bins (e.g. `bin/install.mjs`, `bin/check-deps.sh`,
31
+ # `bin/windyroad-<plugin>` legacy installers) are skipped — they don't
32
+ # follow the script-resolution-via-bin-on-PATH WR-ADR-049 contract.
33
+ #
34
+ # Exit codes:
35
+ # 0 = always (advisory only — drift is signal, not failure)
36
+ # 2 = parse error (root dir missing or unreadable, npm unavailable)
37
+ #
38
+ # Output format on drift (terse machine-readable per WR-ADR-038):
39
+ # TARBALL_DRIFT package=<name> shim=<bin/wr-...> target=<scripts/...> tarball-status=missing
40
+ #
41
+ # Followed by a final aggregate summary line:
42
+ # TOTAL packages=<N> with_drift=<M> missing_targets=<K>
43
+ #
44
+ # Output is empty (no lines) when no shipped artefact carries broken
45
+ # shims — silent-on-pass per WR-ADR-045 hook injection budget discipline.
46
+ #
47
+ # Output ordering (deterministic for stable retro-summary diffs):
48
+ # TARBALL_DRIFT lines sorted by `<package>/<shim>` identifier.
49
+ # TOTAL line last.
50
+ #
51
+ # Read-only — does NOT mutate any artefact. Per WR-ADR-052, the bats
52
+ # fixture at scripts/test/check-tarball-shipped-shims.bats is BEHAVIOURAL —
53
+ # asserts script output on temp-fixture trees, NOT script source content.
54
+ #
55
+ # @problem P154 (P137 namespace-prefix detector must run against
56
+ # npm pack output not source tree)
57
+ # @problem P140 (Step 6.5 fix-and-continue — same prevention surface
58
+ # from the publisher side)
59
+ # @adr ADR-049 (Plugin script resolution via bin/ on PATH)
60
+ # @adr ADR-038 (Progressive disclosure — terse machine-readable signal)
61
+ # @adr ADR-045 (Hook injection budget — silent-on-pass discipline)
62
+ # @adr ADR-052 (Behavioural-tests-default — fixture pattern)
63
+ # @adr ADR-055 (Plugin-published namespace-prefixed permalinks —
64
+ # sibling adopter-context decision)
65
+ # @adr ADR-005 (Plugin testing strategy)
66
+ # @jtbd JTBD-302 (Trust That the README Describes the Plugin I Just
67
+ # Installed — executable correctness axis of adopter-facing content)
68
+ # @jtbd JTBD-101 (Extend the Suite with New Plugins — secondary
69
+ # plugin-developer feedback surface)
70
+
71
+ set -uo pipefail
72
+
73
+ ROOT_DIR="${1:-.}"
74
+
75
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
76
+
77
+ if [ ! -d "$ROOT_DIR" ]; then
78
+ echo "check-tarball-shipped-shims: root dir not found: $ROOT_DIR" >&2
79
+ exit 2
80
+ fi
81
+
82
+ if ! command -v npm >/dev/null 2>&1; then
83
+ echo "check-tarball-shipped-shims: npm not found on PATH" >&2
84
+ exit 2
85
+ fi
86
+
87
+ # ── Walk workspaces ─────────────────────────────────────────────────────────
88
+
89
+ shopt -s nullglob
90
+ declare -a workspaces=()
91
+ for pkg_json in "$ROOT_DIR"/packages/*/package.json; do
92
+ workspaces+=("$(dirname "$pkg_json")")
93
+ done
94
+ shopt -u nullglob
95
+
96
+ if [ "${#workspaces[@]}" -eq 0 ]; then
97
+ exit 0
98
+ fi
99
+
100
+ # ── Per-workspace pack + drift detection ────────────────────────────────────
101
+ # For each workspace:
102
+ # 1. cd into workspace dir; run `npm pack --dry-run --json` to get the
103
+ # shipped file list. Independent of monorepo workspaces config — works
104
+ # in adopter trees too.
105
+ # 2. Parse JSON via python3 (always available on the host) to extract
106
+ # the file paths AND the package name.
107
+ # 3. For each `bin/wr-<plugin>-<name>` shim in the file list, parse the
108
+ # shim source on disk (NOT from the tarball — same content) to find
109
+ # the `exec`'d `../scripts/<name>.sh` target.
110
+ # 4. Check whether the resolved target path is in the tarball file list.
111
+ # 5. Emit `TARBALL_DRIFT` lines for missing targets.
112
+
113
+ declare -a drifts=()
114
+ declare -A package_set=()
115
+ declare -i missing_total=0
116
+
117
+ for ws in "${workspaces[@]}"; do
118
+ # Pack the workspace. Capture stdout; suppress stderr (npm chatter).
119
+ pack_json=$(cd "$ws" && npm pack --dry-run --json 2>/dev/null) || continue
120
+ if [ -z "$pack_json" ]; then
121
+ continue
122
+ fi
123
+
124
+ # Extract package name + file path list via python3.
125
+ # Output format (one record per line):
126
+ # NAME <package-name>
127
+ # FILE <relative-path>
128
+ # ...
129
+ parsed=$(printf '%s' "$pack_json" | python3 -c '
130
+ import sys, json
131
+ data = json.load(sys.stdin)
132
+ if not data:
133
+ sys.exit(0)
134
+ entry = data[0] if isinstance(data, list) else data
135
+ name = entry.get("name", "")
136
+ files = entry.get("files", []) or []
137
+ print("NAME " + name)
138
+ for f in files:
139
+ p = f.get("path", "")
140
+ if p:
141
+ print("FILE " + p)
142
+ ') || continue
143
+
144
+ pkg_name=""
145
+ declare -a tarball_files=()
146
+ while IFS= read -r line; do
147
+ case "$line" in
148
+ "NAME "*)
149
+ pkg_name="${line#NAME }"
150
+ ;;
151
+ "FILE "*)
152
+ tarball_files+=("${line#FILE }")
153
+ ;;
154
+ esac
155
+ done <<< "$parsed"
156
+
157
+ if [ -z "$pkg_name" ]; then
158
+ continue
159
+ fi
160
+
161
+ # Build a lookup set of tarball file paths.
162
+ declare -A tarball_set=()
163
+ for f in "${tarball_files[@]}"; do
164
+ tarball_set["$f"]=1
165
+ done
166
+
167
+ # Find WR-ADR-049-grammar shims in the tarball file list.
168
+ # Grammar: bin/wr-<plugin>-<name> (excludes bin/install.mjs, bin/check-deps.sh,
169
+ # bin/windyroad-<plugin>, bin/anything-without-wr-prefix).
170
+ workspace_has_drift=0
171
+ for f in "${tarball_files[@]}"; do
172
+ case "$f" in
173
+ bin/wr-*)
174
+ # Parse the shim source on disk for the exec target.
175
+ shim_path="$ws/$f"
176
+ if [ ! -f "$shim_path" ]; then
177
+ continue
178
+ fi
179
+ # Heuristic: extract the relative path after `$(dirname "$0")/../`.
180
+ # Matches the WR-ADR-049 grammar both for `exec "$(dirname "$0")/../scripts/foo.sh" "$@"`
181
+ # and the same shape with single quotes / no quotes around dirname.
182
+ target=$(perl -ne '
183
+ if (/\$\(dirname\s+["'\'']?\$0["'\'']?\)\/\.\.\/(\S+?)["'\'']?\s/) {
184
+ print $1; exit;
185
+ }
186
+ ' "$shim_path")
187
+
188
+ if [ -z "$target" ]; then
189
+ continue
190
+ fi
191
+
192
+ if [ -z "${tarball_set[$target]:-}" ]; then
193
+ drifts+=("$pkg_name|$f|$target")
194
+ missing_total=$((missing_total + 1))
195
+ workspace_has_drift=1
196
+ fi
197
+ ;;
198
+ esac
199
+ done
200
+
201
+ if [ "$workspace_has_drift" -eq 1 ]; then
202
+ package_set["$pkg_name"]=1
203
+ fi
204
+
205
+ unset tarball_set
206
+ unset tarball_files
207
+ done
208
+
209
+ if [ "${#drifts[@]}" -eq 0 ]; then
210
+ exit 0
211
+ fi
212
+
213
+ # ── Emit TARBALL_DRIFT lines (sorted) ───────────────────────────────────────
214
+
215
+ IFS=$'\n' sorted=($(printf '%s\n' "${drifts[@]}" | sort))
216
+ unset IFS
217
+
218
+ for entry in "${sorted[@]}"; do
219
+ IFS='|' read -r pkg shim target <<< "$entry"
220
+ echo "TARBALL_DRIFT package=$pkg shim=$shim target=$target tarball-status=missing"
221
+ done
222
+
223
+ # ── Emit TOTAL summary ──────────────────────────────────────────────────────
224
+
225
+ echo "TOTAL packages=${#package_set[@]} with_drift=${#package_set[@]} missing_targets=$missing_total"
226
+
227
+ exit 0
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env bash
2
+ # packages/retrospective/scripts/check-tickets-deferred-cause.sh
3
+ #
4
+ # Diagnose-only advisory script for Step 4b Stage 1's "Tickets Deferred"
5
+ # fallback (P148). Walks docs/retros/*.md retro summary files, parses the
6
+ # `### Tickets Deferred` table in each, and counts entries whose `Cause`
7
+ # column is not in the allowlist `{skill_unavailable}`. Emits one line
8
+ # per retro file plus a trailing TOTAL summary line.
9
+ #
10
+ # Exit code is always 0 — the script is advisory per ADR-040
11
+ # declarative-first / ADR-013 Rule 6 fail-safe. Violation count is
12
+ # emitted as data on stdout; downstream consumers (retro summary
13
+ # rendering, future enforcement-hook escalation per P135 R6 trajectory)
14
+ # decide whether to act on the count.
15
+ #
16
+ # Usage:
17
+ # check-tickets-deferred-cause.sh [<retros-dir>]
18
+ #
19
+ # Default <retros-dir> is ./docs/retros.
20
+ #
21
+ # Exit codes:
22
+ # 0 = always (advisory only — count is signal, not failure)
23
+ # 2 = parse error (retros dir missing or unreadable)
24
+ #
25
+ # Output format (one line per retro file with a Tickets Deferred section,
26
+ # oldest first by date prefix):
27
+ # RETRO <YYYY-MM-DD> file=<basename> deferred=<N> with_valid_cause=<M> violations=<K>
28
+ #
29
+ # Plus a trailing TOTAL line summarising the window:
30
+ # TOTAL files=<N> deferred=<N> with_valid_cause=<M> violations=<K>
31
+ #
32
+ # Output is empty (no lines) when no retro files contain a Tickets
33
+ # Deferred section — this is the expected steady state when Stage 1
34
+ # ticketing is firing as designed.
35
+ #
36
+ # Tickets Deferred table shape (per run-retro SKILL.md Step 5 template):
37
+ #
38
+ # ### Tickets Deferred
39
+ #
40
+ # | Observation | Cause | Citation |
41
+ # |-------------|-------|----------|
42
+ # | <text> | `skill_unavailable` | <text> |
43
+ #
44
+ # The script tolerates:
45
+ # - Cause values wrapped in backticks or bold markers
46
+ # - Missing Cause column entirely (treated as a violation)
47
+ # - Out-of-allowlist cause values (treated as a violation)
48
+ # - Empty Cause cell (treated as a violation)
49
+ #
50
+ # Read-only — does NOT mutate any retro file.
51
+ #
52
+ # @problem P148 (Agent defers ticket creation — broadens Stage 1 fallback gate)
53
+ # @problem P145 (Sibling defer-pattern at Tier 3 rotation — same class of behaviour)
54
+ # @adr ADR-044 (Decision-Delegation Contract — framework-resolution boundary)
55
+ # @adr ADR-040 (Tier 3 advisory-not-fail-closed — declarative-first precedent)
56
+ # @adr ADR-013 Rule 6 (non-interactive fail-safe — advisory script never blocks AFK)
57
+ # @adr ADR-005 / ADR-037 (Plugin testing strategy — behavioural tests)
58
+ # @jtbd JTBD-001 (enforce governance without slowing down)
59
+ # @jtbd JTBD-006 (progress backlog while AFK — anti-defer is the load-bearing job)
60
+ # @jtbd JTBD-201 (audit trail for AI-assisted work — Cause field IS the audit trail)
61
+
62
+ set -uo pipefail
63
+
64
+ RETROS_DIR="${1:-docs/retros}"
65
+
66
+ # Allowlist — single source of truth for valid Cause values. The
67
+ # allowlist intentionally has ONE entry. Adding entries requires a
68
+ # matching SKILL.md AFK-branch update (and ideally a sibling ADR).
69
+ ALLOWLIST_CAUSES=("skill_unavailable")
70
+
71
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
72
+
73
+ if [ ! -d "$RETROS_DIR" ]; then
74
+ echo "check-tickets-deferred-cause: retros dir not found: $RETROS_DIR" >&2
75
+ exit 2
76
+ fi
77
+
78
+ # ── Scan ────────────────────────────────────────────────────────────────────
79
+ # Iterate retro summary files at the top level of RETROS_DIR. Skip
80
+ # ask-hygiene trail files (those are check-ask-hygiene.sh's surface);
81
+ # skip context-analysis files. Glob expansion uses a portable for-loop
82
+ # (P124 lesson — `shopt -s nullglob` is bash-only).
83
+
84
+ retro_files=()
85
+ for path in "$RETROS_DIR"/*.md; do
86
+ [ -e "$path" ] || continue
87
+ # Skip non-summary files
88
+ basename="$(basename "$path")"
89
+ case "$basename" in
90
+ *-ask-hygiene.md) continue ;;
91
+ *-context-analysis.md) continue ;;
92
+ *)
93
+ # Only consider files whose basename starts with a YYYY-MM-DD date prefix
94
+ if [[ "$basename" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2} ]]; then
95
+ retro_files+=("$path")
96
+ fi
97
+ ;;
98
+ esac
99
+ done
100
+
101
+ if [ "${#retro_files[@]}" -eq 0 ]; then
102
+ exit 0
103
+ fi
104
+
105
+ # Sort by basename date prefix, oldest first
106
+ IFS=$'\n' sorted_files=($(printf '%s\n' "${retro_files[@]}" | sort))
107
+ unset IFS
108
+
109
+ # ── Helpers ─────────────────────────────────────────────────────────────────
110
+
111
+ extract_date() {
112
+ local basename
113
+ basename="$(basename "$1")"
114
+ # Take the leading YYYY-MM-DD prefix
115
+ echo "${basename:0:10}"
116
+ }
117
+
118
+ # Determine if a cause value is in the allowlist
119
+ is_valid_cause() {
120
+ local raw="$1"
121
+ # Strip backticks, bold asterisks, and surrounding whitespace
122
+ local cleaned
123
+ cleaned=$(echo "$raw" | sed 's/`//g' | sed 's/\*\*//g' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
124
+ if [ -z "$cleaned" ]; then
125
+ return 1
126
+ fi
127
+ for allowed in "${ALLOWLIST_CAUSES[@]}"; do
128
+ if [ "$cleaned" = "$allowed" ]; then
129
+ return 0
130
+ fi
131
+ done
132
+ return 1
133
+ }
134
+
135
+ # Parse a single retro file's Tickets Deferred section. Sets the global
136
+ # variables `parsed_deferred`, `parsed_valid`, `parsed_violations` for
137
+ # the caller to read.
138
+ parse_retro_file() {
139
+ local path="$1"
140
+ parsed_deferred=0
141
+ parsed_valid=0
142
+ parsed_violations=0
143
+
144
+ # Extract the lines between `### Tickets Deferred` and the next `###`
145
+ # heading (or EOF). awk's range pattern handles this idiomatically.
146
+ local section
147
+ section=$(awk '
148
+ /^### Tickets Deferred[[:space:]]*$/ { in_section=1; next }
149
+ in_section && /^### / { in_section=0 }
150
+ in_section { print }
151
+ ' "$path")
152
+
153
+ if [ -z "$section" ]; then
154
+ return 0
155
+ fi
156
+
157
+ # Walk the table rows. A table row starts with `|`, has at least 3
158
+ # cells, and is not the header or separator row.
159
+ while IFS= read -r line; do
160
+ # Skip blank lines, prose, and non-table content
161
+ [[ "$line" =~ ^\| ]] || continue
162
+ # Skip the separator row (cells of dashes)
163
+ [[ "$line" =~ ^\|[[:space:]]*-+[[:space:]]*\| ]] && continue
164
+ # Skip the header row — detected by literal "Observation" in cell 1
165
+ if [[ "$line" =~ ^\|[[:space:]]*Observation[[:space:]]*\| ]]; then
166
+ continue
167
+ fi
168
+ # Skip the placeholder example row — detected by `<one-line` template marker
169
+ if [[ "$line" =~ \<one-line\ observation\ summary\> ]]; then
170
+ continue
171
+ fi
172
+
173
+ parsed_deferred=$(( parsed_deferred + 1 ))
174
+
175
+ # Split the row into cells. The first cell is empty (leading `|`).
176
+ # Use a perl-free awk split on `|`.
177
+ local cause_cell
178
+ cause_cell=$(echo "$line" | awk -F'|' '{print $3}')
179
+
180
+ if [ -z "$cause_cell" ]; then
181
+ # No Cause column at all — violation
182
+ parsed_violations=$(( parsed_violations + 1 ))
183
+ continue
184
+ fi
185
+
186
+ if is_valid_cause "$cause_cell"; then
187
+ parsed_valid=$(( parsed_valid + 1 ))
188
+ else
189
+ parsed_violations=$(( parsed_violations + 1 ))
190
+ fi
191
+ done <<< "$section"
192
+ }
193
+
194
+ # ── Emit per-file lines ─────────────────────────────────────────────────────
195
+
196
+ total_files=0
197
+ total_deferred=0
198
+ total_valid=0
199
+ total_violations=0
200
+
201
+ for path in "${sorted_files[@]}"; do
202
+ parse_retro_file "$path"
203
+ if [ "$parsed_deferred" -eq 0 ]; then
204
+ continue
205
+ fi
206
+ date=$(extract_date "$path")
207
+ basename="$(basename "$path")"
208
+ echo "RETRO $date file=$basename deferred=$parsed_deferred with_valid_cause=$parsed_valid violations=$parsed_violations"
209
+ total_files=$(( total_files + 1 ))
210
+ total_deferred=$(( total_deferred + parsed_deferred ))
211
+ total_valid=$(( total_valid + parsed_valid ))
212
+ total_violations=$(( total_violations + parsed_violations ))
213
+ done
214
+
215
+ # Emit TOTAL line when at least one file contributed
216
+ if [ "$total_files" -gt 0 ]; then
217
+ echo "TOTAL files=$total_files deferred=$total_deferred with_valid_cause=$total_valid violations=$total_violations"
218
+ fi
219
+
220
+ exit 0