@windyroad/itil 0.54.3 → 0.54.4-preview.802
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/package.json +1 -1
- package/scripts/effort-tally.sh +169 -11
- package/scripts/test/effort-tally.bats +91 -0
package/package.json
CHANGED
package/scripts/effort-tally.sh
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# wr-itil — per-ticket effort tally from AFK iteration cost metadata (ADR-067, P248)
|
|
3
3
|
#
|
|
4
|
-
# @jtbd JTBD-006 (Progress the Backlog While I'm Away — AFK actuals feed WSJF calibration)
|
|
4
|
+
# @jtbd JTBD-006 (Progress the Backlog While I'm Away — AFK actuals feed WSJF calibration + audit trail)
|
|
5
5
|
# @jtbd JTBD-202 (Run Pre-Flight Governance Checks — structured, auditable effort tally)
|
|
6
6
|
#
|
|
7
7
|
# Attributes `.afk-run-state/iter*.json` actuals back to their source ticket
|
|
8
|
-
# (the `pNNN` token in the filename) and emits
|
|
9
|
-
#
|
|
8
|
+
# (the `pNNN` token in the filename) and emits the `## Effort Tally` schema. The
|
|
9
|
+
# reusable core shared by:
|
|
10
10
|
# - the backfill (seed historical tickets — ADR-067 Decision Outcome item 4)
|
|
11
11
|
# - go-forward per-iter append (work-problems — ADR-067 item 2)
|
|
12
12
|
#
|
|
@@ -16,24 +16,70 @@
|
|
|
16
16
|
# BEST-EFFORT (undercount when a subprocess exits on a background-ack turn) and
|
|
17
17
|
# are emitted with a `~` best-effort marker.
|
|
18
18
|
#
|
|
19
|
-
#
|
|
19
|
+
# Modes:
|
|
20
20
|
# effort-tally.sh [AFK_DIR]
|
|
21
|
-
#
|
|
21
|
+
# Legacy list mode — one stdout line per ticket, sorted by descending cost:
|
|
22
|
+
# P<NNN> | iters=<N> | cost_usd=<authoritative> | minutes=<reliable> | tokens=~<best-effort>M
|
|
23
|
+
# effort-tally.sh --render [--source <afk-backfill|live-iter>] <ticket-file> [AFK_DIR]
|
|
24
|
+
# Print the `## Effort Tally` markdown section for ONE ticket to stdout.
|
|
25
|
+
# effort-tally.sh --write [--source <afk-backfill|live-iter>] <ticket-file> [AFK_DIR]
|
|
26
|
+
# Idempotently inject/replace that section in the ticket file (lazy-empty:
|
|
27
|
+
# zero iters → section removed). Mirrors update-problem-references-section.sh.
|
|
22
28
|
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
29
|
+
# --source defaults to afk-backfill (ADR-067 item 2a — every row generated from
|
|
30
|
+
# pre-existing .afk-run-state data is a historical backfill until a go-forward
|
|
31
|
+
# per-iter append wires `--source live-iter`).
|
|
32
|
+
#
|
|
33
|
+
# Phase bucketing (ADR-067 item 2 — RCA vs RFC): derived from the ticket's
|
|
34
|
+
# **Status** body line — `Open` → RCA, anything else → RFC.
|
|
35
|
+
# ponytail: single-phase attribution; a ticket that accrued iters while Open
|
|
36
|
+
# then more while Known Error buckets ALL iters to its current phase. Named
|
|
37
|
+
# upgrade path = per-iter git-log status discrimination (ADR-067 item 2 Phase
|
|
38
|
+
# 2 design). Correct for the common single-phase case; the ceiling travels in
|
|
39
|
+
# the AUTO-GENERATED marker below.
|
|
40
|
+
#
|
|
41
|
+
# Always exits 0 (legacy + render); --write exits 0 on success.
|
|
26
42
|
|
|
27
43
|
set -euo pipefail
|
|
28
44
|
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
MODE="list"
|
|
46
|
+
SOURCE="afk-backfill"
|
|
47
|
+
TICKET_FILE=""
|
|
48
|
+
|
|
49
|
+
# Arg parse: flags in any order before positionals.
|
|
50
|
+
POS=()
|
|
51
|
+
while [ $# -gt 0 ]; do
|
|
52
|
+
case "$1" in
|
|
53
|
+
--render) MODE="render"; shift ;;
|
|
54
|
+
--write) MODE="write"; shift ;;
|
|
55
|
+
--source) SOURCE="${2:-afk-backfill}"; shift 2 ;;
|
|
56
|
+
*) POS+=("$1"); shift ;;
|
|
57
|
+
esac
|
|
58
|
+
done
|
|
59
|
+
|
|
60
|
+
if [ "$MODE" = "list" ]; then
|
|
61
|
+
AFK_DIR="${POS[0]:-.afk-run-state}"
|
|
62
|
+
else
|
|
63
|
+
TICKET_FILE="${POS[0]:-}"
|
|
64
|
+
AFK_DIR="${POS[1]:-.afk-run-state}"
|
|
65
|
+
if [ -z "$TICKET_FILE" ] || [ ! -f "$TICKET_FILE" ]; then
|
|
66
|
+
echo "ERROR: --$MODE needs an existing <ticket-file>" >&2
|
|
67
|
+
exit 1
|
|
68
|
+
fi
|
|
69
|
+
fi
|
|
31
70
|
|
|
32
|
-
|
|
71
|
+
# aggregate <afk-dir> [ticket-id]
|
|
72
|
+
# Emits the per-ticket tally lines (all tickets, or just the one when ticket-id
|
|
73
|
+
# is given). The single source of the iter-JSON aggregation logic.
|
|
74
|
+
aggregate() {
|
|
75
|
+
local afk_dir="$1" only_tid="${2:-}"
|
|
76
|
+
[ -d "$afk_dir" ] || return 0
|
|
77
|
+
python3 - "$afk_dir" "$only_tid" <<'PY'
|
|
33
78
|
import json, glob, os, re, sys
|
|
34
79
|
from collections import defaultdict
|
|
35
80
|
|
|
36
81
|
afk_dir = sys.argv[1]
|
|
82
|
+
only_tid = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
37
83
|
|
|
38
84
|
def cost_obj(d):
|
|
39
85
|
"""Return the dict carrying total_cost_usd, whether d is a dict or an event list."""
|
|
@@ -51,6 +97,8 @@ for path in glob.glob(os.path.join(afk_dir, "*.json")):
|
|
|
51
97
|
if not m:
|
|
52
98
|
continue
|
|
53
99
|
tid = "P" + m.group(1)
|
|
100
|
+
if only_tid and tid != only_tid:
|
|
101
|
+
continue
|
|
54
102
|
try:
|
|
55
103
|
with open(path) as fh:
|
|
56
104
|
d = json.load(fh)
|
|
@@ -72,3 +120,113 @@ for tid, a in sorted(agg.items(), key=lambda kv: kv[1]["cost"], reverse=True):
|
|
|
72
120
|
print(f"{tid} | iters={a['iters']} | cost_usd={a['cost']:.2f} | "
|
|
73
121
|
f"minutes={a['dur_ms']/60000:.1f} | tokens=~{a['tokens']/1e6:.1f}M")
|
|
74
122
|
PY
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# --- legacy list mode -------------------------------------------------------
|
|
126
|
+
if [ "$MODE" = "list" ]; then
|
|
127
|
+
aggregate "$AFK_DIR"
|
|
128
|
+
exit 0
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
# --- render / write modes ---------------------------------------------------
|
|
132
|
+
|
|
133
|
+
# Ticket id from filename (NNN-slug.md → PNNN).
|
|
134
|
+
tid_num="$(basename "$TICKET_FILE" | grep -oE '^[0-9]+' || true)"
|
|
135
|
+
if [ -z "$tid_num" ]; then
|
|
136
|
+
echo "ERROR: cannot extract ticket ID from filename: $(basename "$TICKET_FILE")" >&2
|
|
137
|
+
exit 1
|
|
138
|
+
fi
|
|
139
|
+
TID="P${tid_num}"
|
|
140
|
+
|
|
141
|
+
# Phase from the **Status** body line: Open → RCA, else → RFC (see header ceiling).
|
|
142
|
+
status_line="$(grep -m1 '^\*\*Status\*\*:' "$TICKET_FILE" | sed -E 's/^\*\*Status\*\*:[[:space:]]*//' || true)"
|
|
143
|
+
case "$status_line" in
|
|
144
|
+
Open|open) PHASE="RCA" ;;
|
|
145
|
+
*) PHASE="RFC" ;;
|
|
146
|
+
esac
|
|
147
|
+
|
|
148
|
+
# Aggregate just this ticket.
|
|
149
|
+
line="$(aggregate "$AFK_DIR" "$TID")"
|
|
150
|
+
|
|
151
|
+
# Render the section (empty string ⇒ lazy-empty: no iters for this ticket).
|
|
152
|
+
new_section=""
|
|
153
|
+
if [ -n "$line" ]; then
|
|
154
|
+
iters="$(echo "$line" | sed -E 's/.*iters=([0-9]+).*/\1/')"
|
|
155
|
+
cost="$(echo "$line" | sed -E 's/.*cost_usd=([0-9.]+).*/\1/')"
|
|
156
|
+
minutes="$(echo "$line" | sed -E 's/.*minutes=([0-9.]+).*/\1/')"
|
|
157
|
+
tokens="$(echo "$line" | sed -E 's/.*tokens=~([0-9.]+M).*/\1/')"
|
|
158
|
+
new_section="## Effort Tally"$'\n\n'
|
|
159
|
+
new_section+="<!-- AUTO-GENERATED by wr-itil-effort-tally; do not hand-edit. source: ${SOURCE}."$'\n'
|
|
160
|
+
new_section+=" Phase bucketed from current **Status** (single-phase ceiling — ADR-067 item 2). -->"$'\n\n'
|
|
161
|
+
new_section+="| Phase | Iters | Cost (USD, authoritative) | Time (min) | Tokens (best-effort) |"$'\n'
|
|
162
|
+
new_section+="|---|---|---|---|---|"$'\n'
|
|
163
|
+
new_section+="| ${PHASE} | ${iters} | \$${cost} | ${minutes} | ~${tokens} |"$'\n'
|
|
164
|
+
fi
|
|
165
|
+
|
|
166
|
+
if [ "$MODE" = "render" ]; then
|
|
167
|
+
printf '%s' "$new_section"
|
|
168
|
+
[ -n "$new_section" ] && echo
|
|
169
|
+
exit 0
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
# --- write mode: idempotent replace-section -------------------------------
|
|
173
|
+
# Strip any existing `## Effort Tally` section (and the blank run that abutted
|
|
174
|
+
# it), normalise trailing whitespace to one final newline, then re-insert
|
|
175
|
+
# before `## Fix Released` (else `## Related`, else EOF). Mirrors the awk idiom
|
|
176
|
+
# in update-problem-references-section.sh.
|
|
177
|
+
tmp_file="$(mktemp)"
|
|
178
|
+
awk -v sec="## Effort Tally" '
|
|
179
|
+
BEGIN { in_target=0; blank_buffer=0 }
|
|
180
|
+
# On section start, FLUSH the pending blank (it is the body-side separator,
|
|
181
|
+
# not part of the section) so strip+reinsert is blank-stable / idempotent.
|
|
182
|
+
$0 == sec { if (blank_buffer) { print ""; blank_buffer=0 } in_target=1; next }
|
|
183
|
+
in_target && /^## / && $0 != sec { in_target=0 }
|
|
184
|
+
!in_target {
|
|
185
|
+
if ($0 ~ /^[[:space:]]*$/) { blank_buffer=1; next }
|
|
186
|
+
if (blank_buffer) { print ""; blank_buffer=0 }
|
|
187
|
+
print
|
|
188
|
+
}
|
|
189
|
+
' "$TICKET_FILE" > "$tmp_file"
|
|
190
|
+
|
|
191
|
+
# Collapse blank runs to exactly one + ensure a single final newline. Collapsing
|
|
192
|
+
# (not preserving count) is what makes a double-blank from insertion idempotent.
|
|
193
|
+
tmp_file2="$(mktemp)"
|
|
194
|
+
awk 'BEGIN{c=0} /^[[:space:]]*$/{c++; next} {if(c>0)print ""; c=0; print} END{print ""}' "$tmp_file" > "$tmp_file2"
|
|
195
|
+
mv "$tmp_file2" "$tmp_file"
|
|
196
|
+
|
|
197
|
+
if [ -n "$new_section" ]; then
|
|
198
|
+
if grep -q '^## Fix Released' "$tmp_file"; then
|
|
199
|
+
anchor='^## Fix Released'
|
|
200
|
+
elif grep -q '^## Related' "$tmp_file"; then
|
|
201
|
+
anchor='^## Related'
|
|
202
|
+
else
|
|
203
|
+
anchor=''
|
|
204
|
+
fi
|
|
205
|
+
# Pass the multi-line section via a file (awk -v rejects embedded newlines on
|
|
206
|
+
# BSD awk); getline keeps it portable across BSD awk + gawk. new_section ends
|
|
207
|
+
# in a single \n, so the file carries no trailing blank line; the separator
|
|
208
|
+
# blank between section and anchor is emitted explicitly (one `print ""`).
|
|
209
|
+
section_file="$(mktemp)"
|
|
210
|
+
printf '%s' "$new_section" > "$section_file"
|
|
211
|
+
if [ -n "$anchor" ]; then
|
|
212
|
+
tmp_file2="$(mktemp)"
|
|
213
|
+
awk -v sf="$section_file" -v anchor="$anchor" '
|
|
214
|
+
$0 ~ anchor && !done {
|
|
215
|
+
while ((getline ln < sf) > 0) print ln
|
|
216
|
+
close(sf); print ""; done=1
|
|
217
|
+
}
|
|
218
|
+
{ print }
|
|
219
|
+
' "$tmp_file" > "$tmp_file2"
|
|
220
|
+
mv "$tmp_file2" "$tmp_file"
|
|
221
|
+
else
|
|
222
|
+
printf '\n%s' "$new_section" >> "$tmp_file"
|
|
223
|
+
fi
|
|
224
|
+
rm -f "$section_file"
|
|
225
|
+
fi
|
|
226
|
+
|
|
227
|
+
if ! cmp -s "$tmp_file" "$TICKET_FILE"; then
|
|
228
|
+
mv "$tmp_file" "$TICKET_FILE"
|
|
229
|
+
else
|
|
230
|
+
rm -f "$tmp_file"
|
|
231
|
+
fi
|
|
232
|
+
exit 0
|
|
@@ -77,3 +77,94 @@ EOF
|
|
|
77
77
|
[ "$status" -eq 0 ]
|
|
78
78
|
[ -z "$output" ]
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
# --- render / write modes (ADR-067 item 2 + 2a source flag) ---
|
|
82
|
+
|
|
83
|
+
mk_ticket() { # mk_ticket <filename> <status> e.g. mk_ticket 087-foo.md Open
|
|
84
|
+
cat > "$DIR/$1" <<EOF
|
|
85
|
+
# Problem 087: Example
|
|
86
|
+
|
|
87
|
+
**Status**: $2
|
|
88
|
+
**Priority**: 6 (Medium)
|
|
89
|
+
**Effort**: M
|
|
90
|
+
|
|
91
|
+
## Description
|
|
92
|
+
|
|
93
|
+
Body.
|
|
94
|
+
|
|
95
|
+
## Related
|
|
96
|
+
|
|
97
|
+
- none
|
|
98
|
+
EOF
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@test "--render prints an Effort Tally section with authoritative cost + best-effort tokens" {
|
|
102
|
+
mk_ticket "087-foo.md" Open
|
|
103
|
+
mk_iter "iter1-p087.json" 12.50 600000 2000000
|
|
104
|
+
run bash "$SCRIPT" --render "$DIR/087-foo.md" "$DIR/.afk-run-state"
|
|
105
|
+
[ "$status" -eq 0 ]
|
|
106
|
+
[[ "$output" == *"## Effort Tally"* ]]
|
|
107
|
+
[[ "$output" == *"AUTO-GENERATED"* ]]
|
|
108
|
+
[[ "$output" == *'$12.50'* ]]
|
|
109
|
+
[[ "$output" == *"~2.0M"* ]]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@test "--render buckets an Open ticket under RCA" {
|
|
113
|
+
mk_ticket "087-foo.md" Open
|
|
114
|
+
mk_iter "iter1-p087.json" 5.00 60000 100000
|
|
115
|
+
run bash "$SCRIPT" --render "$DIR/087-foo.md" "$DIR/.afk-run-state"
|
|
116
|
+
[[ "$output" == *"RCA"* ]]
|
|
117
|
+
[[ "$output" != *"| RFC |"* ]]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@test "--render buckets a Known Error ticket under RFC" {
|
|
121
|
+
mk_ticket "087-foo.md" "Known Error"
|
|
122
|
+
mk_iter "iter1-p087.json" 5.00 60000 100000
|
|
123
|
+
run bash "$SCRIPT" --render "$DIR/087-foo.md" "$DIR/.afk-run-state"
|
|
124
|
+
[[ "$output" == *"RFC"* ]]
|
|
125
|
+
[[ "$output" != *"| RCA |"* ]]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@test "--render defaults source to afk-backfill; --source live-iter flips it" {
|
|
129
|
+
mk_ticket "087-foo.md" Open
|
|
130
|
+
mk_iter "iter1-p087.json" 5.00 60000 100000
|
|
131
|
+
run bash "$SCRIPT" --render "$DIR/087-foo.md" "$DIR/.afk-run-state"
|
|
132
|
+
[[ "$output" == *"source: afk-backfill"* ]]
|
|
133
|
+
run bash "$SCRIPT" --render --source live-iter "$DIR/087-foo.md" "$DIR/.afk-run-state"
|
|
134
|
+
[[ "$output" == *"source: live-iter"* ]]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@test "--write injects the section into the ticket and is idempotent" {
|
|
138
|
+
mk_ticket "087-foo.md" Open
|
|
139
|
+
mk_iter "iter1-p087.json" 5.00 60000 100000
|
|
140
|
+
run bash "$SCRIPT" --write "$DIR/087-foo.md" "$DIR/.afk-run-state"
|
|
141
|
+
[ "$status" -eq 0 ]
|
|
142
|
+
grep -q "## Effort Tally" "$DIR/087-foo.md"
|
|
143
|
+
# original body preserved
|
|
144
|
+
grep -q "^## Description" "$DIR/087-foo.md"
|
|
145
|
+
# idempotent: second run produces no diff
|
|
146
|
+
cp "$DIR/087-foo.md" "$DIR/087-foo.before"
|
|
147
|
+
run bash "$SCRIPT" --write "$DIR/087-foo.md" "$DIR/.afk-run-state"
|
|
148
|
+
run diff "$DIR/087-foo.before" "$DIR/087-foo.md"
|
|
149
|
+
[ "$status" -eq 0 ]
|
|
150
|
+
# exactly one section (no duplication)
|
|
151
|
+
[ "$(grep -c '^## Effort Tally' "$DIR/087-foo.md")" -eq 1 ]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@test "--write lazy-empties: a ticket with zero iters gets no section" {
|
|
155
|
+
mk_ticket "099-bar.md" Open
|
|
156
|
+
run bash "$SCRIPT" --write "$DIR/099-bar.md" "$DIR/.afk-run-state"
|
|
157
|
+
[ "$status" -eq 0 ]
|
|
158
|
+
! grep -q "## Effort Tally" "$DIR/099-bar.md"
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@test "--write removes a stale section when iters disappear (lazy-empty on re-run)" {
|
|
162
|
+
mk_ticket "087-foo.md" Open
|
|
163
|
+
mk_iter "iter1-p087.json" 5.00 60000 100000
|
|
164
|
+
bash "$SCRIPT" --write "$DIR/087-foo.md" "$DIR/.afk-run-state"
|
|
165
|
+
grep -q "## Effort Tally" "$DIR/087-foo.md"
|
|
166
|
+
rm "$DIR/.afk-run-state/iter1-p087.json"
|
|
167
|
+
run bash "$SCRIPT" --write "$DIR/087-foo.md" "$DIR/.afk-run-state"
|
|
168
|
+
[ "$status" -eq 0 ]
|
|
169
|
+
! grep -q "## Effort Tally" "$DIR/087-foo.md"
|
|
170
|
+
}
|