@windyroad/itil 0.54.3-preview.795 → 0.54.4-preview.799

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.
@@ -497,5 +497,5 @@
497
497
  }
498
498
  },
499
499
  "name": "wr-itil",
500
- "version": "0.54.3"
500
+ "version": "0.54.4"
501
501
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.54.3-preview.795",
3
+ "version": "0.54.4-preview.799",
4
4
  "description": "ITIL-aligned IT service management for Claude Code (problem, and future incident/change skills)",
5
5
  "bin": {
6
6
  "windyroad-itil": "./bin/install.mjs"
@@ -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 one tally line per ticket in the
9
- # `## Effort Tally` schema. The reusable core shared by:
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
- # Usage:
19
+ # Modes:
20
20
  # effort-tally.sh [AFK_DIR]
21
- # AFK_DIR defaults to .afk-run-state
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
- # Output (stdout): one line per ticket, sorted by descending cost:
24
- # P<NNN> | iters=<N> | cost_usd=<authoritative> | minutes=<reliable> | tokens=~<best-effort>M
25
- # Always exits 0.
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
- AFK_DIR="${1:-.afk-run-state}"
30
- [ -d "$AFK_DIR" ] || exit 0
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
- python3 - "$AFK_DIR" <<'PY'
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
+ }