@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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/wr-retrospective-check-tarball-shipped-shims +2 -0
- package/package.json +2 -1
- package/scripts/check-ask-hygiene.sh +163 -0
- package/scripts/check-briefing-budgets.sh +121 -0
- package/scripts/check-internal-id-leaks.sh +169 -0
- package/scripts/check-readme-jtbd-currency.sh +207 -0
- package/scripts/check-skill-md-budgets.sh +130 -0
- package/scripts/check-tarball-shipped-shims.sh +227 -0
- package/scripts/check-tickets-deferred-cause.sh +220 -0
- package/scripts/list-plugin-attribution.sh +139 -0
- package/scripts/measure-context-budget.sh +270 -0
- package/scripts/test/check-ask-hygiene.bats +248 -0
- package/scripts/test/check-briefing-budgets.bats +307 -0
- package/scripts/test/check-internal-id-leaks.bats +286 -0
- package/scripts/test/check-readme-jtbd-currency.bats +217 -0
- package/scripts/test/check-skill-md-budgets.bats +336 -0
- package/scripts/test/check-tarball-shipped-shims.bats +284 -0
- package/scripts/test/check-tickets-deferred-cause.bats +330 -0
- package/scripts/test/list-plugin-attribution.bats +149 -0
- package/scripts/test/measure-context-budget.bats +236 -0
|
@@ -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
|