zuvo:retro
Generate an engineering retrospective from git history, backlog state, and skill usage trends. Produces a narrative report with actionable items.
Scope: Post-release or periodic retrospective covering shipping velocity, code churn, backlog health, and quality trends. Out of scope: Actually fixing issues (use the suggested zuvo commands in the Actionable Items section).
Argument Parsing
| Input | Action |
|---|---|
| (empty) | Auto-detect window from memory/last-ship.json or last two git tags |
<range> |
Explicit git range, e.g., v1.0.0..v1.2.0 |
--since <tag> |
Start of retrospective window (open-ended, through HEAD) |
--path <dir> |
Scope git analysis to a subdirectory (required for monorepos) |
Environment Compatibility
Dispatch follows ../../shared/includes/execution-policy.md through env-compat. Reuse existing
authorization within that policy; session restrictions take precedence. Run each required gate
and report its actual independence or an unmet requirement.
Read ../../shared/includes/env-compat.md for agent dispatch patterns, path resolution, and progress tracking across all supported platforms.
Mandatory File Loading
CORE FILES LOADED:
1. ../../shared/includes/env-compat.md -- READ
2. ../../shared/includes/run-logger.md -- READ
Both files must be read before proceeding. If either is missing, note "DEGRADED -- [file] unavailable" in the report and continue with reduced capability.
Phase 0: Window Detection
Determine the retrospective window in this priority order:
- Explicit argument — if
<range>was provided (e.g.,v1.0.0..v1.2.0), use it directly. --since <tag>— if provided, window is<tag>..HEAD. No--untilflag is defined.memory/last-ship.json— read the file if it exists. Use therangefield (SHA-based, e.g.,"abc1234..def5678"). If the artifact uses a legacy version-based range, fall back to it.- Last two git tags — run
git describe --tags --abbrev=0twice (with--excludeon first result) to derive<prev-tag>..<latest-tag>. - Fallback — if fewer than two tags exist, use the last 30 commits: derive range as
HEAD~30..HEAD.
Count commits in window:
git log --oneline <range> | wc -l
Edge Case E13 — Insufficient History
If commit count is < 10: output "Insufficient history for statistical metrics" and produce a qualitative-only report. Skip Phases 1 and 3 (no git metrics, no skill trends to aggregate). Use Phase 2 (backlog health) as the sole data source. Still produce Phase 4 actionable items and Phase 5 report.
Edge Case E14 — Monorepo Detection
Check for monorepo signals in the project root:
turbo.jsonnx.jsonpnpm-workspace.yaml
If any of these files exist AND no --path argument was provided:
- First, try auto-detection: run
git diff --name-only <range>and extract unique top-level directories matching workspace package paths. - If only ONE package was touched: auto-scope to that package with
[AUTO-DECISION]: scoped to <package>. - If multiple packages were touched:
- Interactive: list the affected packages and ask the user to pick one or confirm full-repo analysis.
- Non-interactive:
[AUTO-DECISION]: monorepo detected, analyzing all affected packages. Results may be noisy — re-run with --path for focused analysis.Proceed with full-repo analysis.
If --path was provided: scope all git log commands with -- <path> and note the scope in the report.
Phase 1: Git Metrics
Run these commands against the resolved <range> (append -- <path> if --path was provided).
Deployment Frequency
Count release tags within the window, not arbitrary tags.
Counting method:
git tag --sort=creatordate --merged HEAD | grep -E '^v[0-9]' | while read tag; do
TAG_DATE=$(git log -1 --format="%ai" "$tag")
# keep only tags whose date falls within the window
done
Priority for identifying release tags:
- Tags matching a semver-like pattern (
v*.*.*,v[0-9]*) - Tags referenced by
memory/last-ship.json(newTag,previousTag) — cross-reference, not sole source - If no release tag pattern can be identified, report:
Deployment frequency unavailable — no reliable release tag pattern detected
Ignore unrelated tags (experimental, package-local, scratch tags). memory/last-ship.json covers only the most recent release; for multi-release windows, the git tag scan is the primary data source.
Release Cycle Span
Estimate the time span covered by the release window:
git log --format="%ai %H" <range>
Use the earliest commit date in the window as the start and the latest tag date as the end. Report as: N days (earliest commit → latest tag). Note: this is the window span, not per-PR lead time — exact branch-to-merge lead time requires per-PR analysis with gh pr list.
Churn Hotspots
Find the top 5 most-changed files in the window:
git log --name-only --pretty=format: <range> | sort | uniq -c | sort -rn | head -5
Git, deliberately — not CodeSift analyze_hotspots. A retrospective must run on any
checkout, including one with no index and no MCP server, and churn-by-commit-count is
exactly what this section reports. The frontmatter used to declare analyze_hotspots as
"KEY" alongside nine other tools that no step ever called; compute-preload reads that
field, so it was instructing agents to load ten tools for a skill that uses none.
Filter out empty lines. Report as a ranked list: filename (N changes).
Phase 2: Backlog Health
Read memory/backlog.md if it exists — at the MAIN checkout root, resolved per ../../shared/includes/backlog-protocol.md "Where the Backlog Lives" (in a linked worktree a CWD-relative read silently sees no backlog).
If the file does not exist: note "No backlog tracked. Run zuvo:review or zuvo:code-audit to populate." Skip all backlog metrics.
If the file exists, count:
- Open items — rows where Status =
OPEN - Critical items — OPEN rows where Severity =
CRITICAL - Resolved in window — rows where Status =
RESOLVEDand the Resolved/Updated date (not Added date) falls within the window. If backlog.md has no Resolved date column, skip this metric and note: "Resolved-in-window metric unavailable — backlog lacks Resolved date column." - Added in window — rows where Added date falls within the retrospective window
Also identify the oldest unresolved item (earliest Added date among OPEN rows) and report its ID and age in days.
Phase 3: Skill Usage Trends
Read the skill usage log using the environment-aware path from run-logger.md (primary: ~/.zuvo/runs.log, Codex App fallback: memory/zuvo-runs.log).
If the file does not exist at either path: note "No skill usage history found." Skip this section.
If the file exists, parse each line by splitting on tab characters (\t):
- Lines with 10 tab characters (11 fields): populate all fields:
DATE SKILL PROJECT CQ_SCORE Q_SCORE VERDICT TASKS DURATION NOTES BRANCH HEAD_SHA7 - Lines with 8 tab characters (9 fields): populate the first 9 fields as above, treat BRANCH and HEAD_SHA7 as
- - Lines that match neither pattern (e.g., pipe-delimited legacy entries like
2026-03-31T05:20:44Z | SKILL: brainstorm | ...): skip silently
DATE parsing: handle both T...Z (with Z suffix) and T... (without Z) formats as UTC.
Filter to entries where:
PROJECTmatches the current project directory basenameDATEfalls within the retrospective window
From the filtered entries, aggregate:
- Most-used skills — rank by frequency
- Average CQ score — parse
CQ_SCOREfield, skip-entries, average the numerator/denominator separately - Average Q score — same approach as CQ
- Pass/fail ratio — count PASS vs FAIL vs WARN verdicts
- Branch distribution: [branch: N runs, ...] (e.g.,
main: 8, feature/x: 2) — only display when at least one 11-field entry exists in the filtered window
Phase 3b: Per-Task Telemetry (optional)
Read the per-task telemetry file zuvo:execute appends at Step 9b — one JSON line per finished
task, schema documented in ../../shared/includes/session-state.md →
zuvo/context/task-telemetry.jsonl. This is a project-local, per-task counterpart to Phase 3's
HOME-global ~/.zuvo/runs.log: gate-failure counts, reviewer-route distribution, and retry hotspots
that Phase 3's one-line-per-run log cannot surface.
Resolve the file the same way the writer does: $ZUVO_OUTPUT_DIR, else <git root>/zuvo, then
context/task-telemetry.jsonl under it — no pwd fallback (deliberately removed from the writer;
do not reintroduce one here). No project-name filter — unlike runs.log (shared across every
project on the machine), this file is project-local by construction, so filtering would be wrong,
not merely redundant.
If the file does not exist: note "No per-task telemetry found." Skip this section — the same
degradation shape Phase 3 already uses for a missing runs.log.
If the file exists, parse it line by line. A blank or whitespace-only physical line — the normal
trailing newline of an append-only file, not a corrupt one — is not a record and is excluded from
BOTH counts below; every non-blank line is then either fully counted in records= or fully
counted in skipped=, never dropped in between. A kill between write and fsync can leave a
truncated trailing line, and this file is never rewritten to repair one (see session-state.md's
"Reader contract").
Three corruption shapes must each become a counted skip, because each of them aborts a naive
reader outside a try that wraps only json.loads, and an abort discards every record already
parsed — uncounted in both records= and skipped=, which is precisely the silent data loss the
Reader contract forbids:
- Unparseable text — plain garbage;
json.loadsraisesValueError. - Valid JSON that is not an object —
null, a bare number,[]. These parse fine and then raiseAttributeErrorat the first.get, so the guard must wrap the whole per-line body, not just the parse, and must reject a non-dictexplicitly. - A truncated multi-byte UTF-8 tail — the exact "crash between
writeandfsync" case. Text mode decodes eagerly, soUnicodeDecodeErroris raised by the iteration itself, before any per-linetryis reached. Read the file in binary and decode each line defensively.
Reading is also this reader's own responsibility, not the shell's: an unreadable/vanished path and
any other failure are reported by the reader with its partial counts intact. The || echo "[WARN]"
tail stays as a last resort, not the normal error path.
From the well-formed records, aggregate:
- Gate-failure counts — a gate field (
spec-review,quality-review,adversarial) lands in one of three states, never a plain pass/fail: failed (present, a string, and not the passing value), missing (absent,null, or any non-string JSON value — a bool/number/nested object stringified into"True"/"3"is malformed input, not a real verdict, and must never be compared as one), or passing.gate-failures=counts only the failed state;gate-missing=reports the missing state separately, so a pre-schema or truncated record can never inflate the failure tally. ThePASSprefix match onquality-review/adversarialis case-sensitive by contract — the writer always emits the literal uppercase form, so a lowercased value is itself a sign of a hand-edited or corrupt record and must fail, not silently pass. - Reviewer-route distribution — tally by
reviewer-route(review-primary,review-alt,same-model-fallback,routing-failed). - Implementer-status tally (blocked/skipped reasons) — tally by
implementer-status(DONE,DONE_WITH_CONCERNS,NEEDS_CONTEXT,BLOCKED). - Failure-strategy distribution — bucketed into
halt,skip-and-continue,degraded,unknownandmissing. Two rules matter here:missingis its own bucket, never folded intohalt. The writer always emits the field, so an absent one means an OLD or CORRUPT record — reporting it ashaltwould present silence as a deliberate decision.degraded:<desc>collapses to onedegradedbucket, withdegraded-distinct-descriptions=<N>printed alongside — capped at 64 distinct descriptions tracked for that count; past the cap, further distinct text is tallied as an approximate(+N more)overflow instead of growing the tracked set without bound.<desc>is free text; keying the tally by it yields one entry per task on a long run — a distribution with no signal.
# >>> zuvo:retro-telemetry
RT_DIR="${ZUVO_OUTPUT_DIR:-}"
if [ -z "$RT_DIR" ]; then
_RT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$_RT_ROOT" ]; then RT_DIR="$_RT_ROOT/zuvo"; fi
fi
RT_PATH="${RT_DIR:+$RT_DIR/context/task-telemetry.jsonl}"
if [ -z "$RT_PATH" ] || [ ! -f "$RT_PATH" ]; then
echo "No per-task telemetry found."
else
python3 - "$RT_PATH" <<'PY' \
|| echo "[WARN] per-task telemetry read failed — continuing (diagnostic, never a gate)"
import json, sys
# SCHEMA SSOT — every `F_* = "<key>"` below is a telemetry field name this reader
# touches, and they are declared HERE, once. Case (y) of tests/skill-suite/test-
# task-telemetry-contract.sh checks these literals against the
# `zuvo:telemetry-schema` table in shared/includes/session-state.md — the same
# discipline the WRITER's `K = [...]` follows, but as CONTAINMENT rather than the
# writer's equality diff, because this reader deliberately touches only a subset
# of the documented keys. Without that check a schema rename is invisible here:
# `rec.get(key, default)` never raises on a renamed key, so retro would report
# 100% gate failure forever and nothing would say so. Never inline a field name at
# a use site — the test forbids a literal `.get("<key>"` here for that reason.
F_SPEC = "spec-review"
F_QUALITY = "quality-review"
F_ADVERSARIAL = "adversarial"
F_ROUTE = "reviewer-route"
F_STATUS = "implementer-status"
F_STRATEGY = "failure-strategy"
# Bound on distinct free-text `degraded:<desc>` tails tracked for the
# degraded-distinct-descriptions count below. Free text is author-controlled
# and a long-lived file could carry many distinct ones; past this many the
# reader stops trying to dedup exactly and reports the remainder as an
# approximate "+N more" overflow instead of growing the tracked set forever.
DEGRADED_DESC_CAP = 64
path = sys.argv[1]
n = 0
skipped = 0
gate = {F_SPEC: 0, F_QUALITY: 0, F_ADVERSARIAL: 0}
gate_missing = {F_SPEC: 0, F_QUALITY: 0, F_ADVERSARIAL: 0}
reviewer_route = {}
implementer_status = {}
failure_strategy = {}
degraded_descs = set()
degraded_overflow = 0
def bump(tally, key):
tally[key] = tally.get(key, 0) + 1
def enum_str(rec, key):
val = rec.get(key)
return val if isinstance(val, str) and val else "unknown"
def gate_status(rec, key, mode):
# Same isinstance/non-empty guard as strategy_bucket() below: an ABSENT
# field and a non-string field (a bool/int/nested value that str() would
# otherwise stringify into a fake "True"/"3" verdict) are BOTH "missing",
# never "failed" — a pre-schema or truncated record must not inflate the
# failure tally, and a non-string value must never be compared as a verdict.
val = rec.get(key)
if not isinstance(val, str) or not val:
return "missing"
if mode == "exact":
return "passed" if val == "COMPLIANT" else "failed"
# mode == "prefix": case-sensitive BY CONTRACT, not an oversight — the
# writer always emits the literal uppercase "PASS ..." form (schema in
# session-state.md), so a lowercased/mixed-case value is itself a sign of
# a hand-edited or corrupt record and must fail, never silently pass.
return "passed" if val.startswith("PASS") else "failed"
def strategy_bucket(rec):
# An ABSENT failure-strategy is NOT a declared `halt`: the writer always emits
# the field, so absence means an old or corrupt record. `missing` keeps silence
# from being reported as a decision. `degraded:<desc>` is BUCKETED rather than
# keyed by its free-text description (one key per task is not a distribution);
# the distinct-description count is printed alongside so variety is not lost.
val = rec.get(F_STRATEGY)
if not isinstance(val, str) or not val:
return "missing", None
if val in ("halt", "skip-and-continue"):
return val, None
if val.startswith("degraded:"):
return "degraded", val[len("degraded:"):]
return "unknown", None
def emit():
print("records=%d skipped=%d" % (n, skipped))
print("gate-failures spec-review=%d quality-review=%d adversarial=%d"
% (gate[F_SPEC], gate[F_QUALITY], gate[F_ADVERSARIAL]))
print("gate-missing spec-review=%d quality-review=%d adversarial=%d"
% (gate_missing[F_SPEC], gate_missing[F_QUALITY], gate_missing[F_ADVERSARIAL]))
print("reviewer-route " + " ".join("%s=%d" % kv for kv in sorted(reviewer_route.items())))
print("implementer-status " + " ".join("%s=%d" % kv for kv in sorted(implementer_status.items())))
strategies = " ".join("%s=%d" % kv for kv in sorted(failure_strategy.items()))
if degraded_descs:
strategies += " degraded-distinct-descriptions=%d" % len(degraded_descs)
if degraded_overflow:
strategies += " (+%d more)" % degraded_overflow
print("failure-strategy " + strategies)
try:
# Binary, deliberately: a text-mode `for raw in fh` decodes EAGERLY, so a
# truncated multi-byte tail raises UnicodeDecodeError from the ITERATION —
# outside any per-line try — and kills the read after records already counted.
fh = open(path, "rb")
except OSError as exc:
# This reader owns its errors. Falling through to the shell's `|| echo` would
# discard the counts entirely and say nothing about which path failed.
print("per-task telemetry unreadable (%s) — skipping" % (exc,))
print("records=0 skipped=0")
sys.exit(0)
try:
with fh:
for raw in fh:
# The WHOLE per-line body is guarded, not just the parse: valid JSON
# that is not an object (`null`, `3`, `[]`) parses fine and then raises
# AttributeError at the first `.get`. Values are computed into locals
# first and committed only afterwards, so a line can never land in
# BOTH records= and skipped=.
try:
line = raw.decode("utf-8", "replace").strip()
if not line:
# A blank/whitespace-only physical line is NOT a record —
# the normal trailing newline of an append-only file, not
# a corrupt one. Excluded from BOTH records= and skipped=
# on purpose: the Reader contract counts every REAL line
# into one bucket or the other, and a blank line is not a
# real line to begin with.
continue
rec = json.loads(line)
if not isinstance(rec, dict):
raise ValueError("record is not a JSON object")
spec_status = gate_status(rec, F_SPEC, "exact")
quality_status = gate_status(rec, F_QUALITY, "prefix")
adversarial_status = gate_status(rec, F_ADVERSARIAL, "prefix")
route = enum_str(rec, F_ROUTE)
status = enum_str(rec, F_STATUS)
strategy, desc = strategy_bucket(rec)
except Exception:
skipped += 1
continue
n += 1
if spec_status == "failed":
gate[F_SPEC] += 1
elif spec_status == "missing":
gate_missing[F_SPEC] += 1
if quality_status == "failed":
gate[F_QUALITY] += 1
elif quality_status == "missing":
gate_missing[F_QUALITY] += 1
if adversarial_status == "failed":
gate[F_ADVERSARIAL] += 1
elif adversarial_status == "missing":
gate_missing[F_ADVERSARIAL] += 1
bump(reviewer_route, route)
bump(implementer_status, status)
bump(failure_strategy, strategy)
if desc is not None:
if desc in degraded_descs or len(degraded_descs) < DEGRADED_DESC_CAP:
degraded_descs.add(desc)
else:
degraded_overflow += 1
except Exception as exc:
# A read that dies mid-file still reports what it already aggregated. Partial
# results plus the skip count beat the shell fallback's total silence.
print("per-task telemetry read aborted after %d records (%s)" % (n, exc))
emit()
sys.exit(0)
PY
fi
# <<< zuvo:retro-telemetry
Phase 4: Actionable Items
Generate at least 3 specific, actionable items. Each item must:
- Reference a specific file, directory, or backlog item (not generic advice)
- Include a specific zuvo command
- State the reason derived from the data
Derivation rules (priority order):
- High-churn files — for each file in the churn hotspot list with N ≥ 5 changes: check whether a corresponding test file exists. If no test file found:
zuvo:write-tests <file>with reason "high-churn, N changes, no test coverage found". - Churn instability — for files with N ≥ 5 changes that already have tests:
zuvo:refactor <file>with reason "N changes suggest instability or unclear responsibilities". - Critical backlog items — for each OPEN CRITICAL item:
zuvo:backlog fix <ID>with reason "critical debt item open N days". - Old open items — if oldest open item is > 14 days:
zuvo:backlog prioritizewith reason "oldest item open N days, prioritization overdue". - Low CQ scores — if average CQ score < 79% of applicable:
zuvo:code-audit <top-churn-directory>with reason "CQ average below the 79% threshold". - Low Q scores — if average Q score < 53% of applicable:
zuvo:write-tests <top-churn-directory>with reason "Q average below the 53% threshold suggests test quality gaps".
If fewer than 3 items can be derived from the data, supplement with: "Run zuvo:code-audit . to establish a quality baseline" or "Run zuvo:test-audit . to identify test gaps."
Phase 5: Report and Output
Write Report File
Create the report using a readable suffix:
- Prefer tag-based names when
previousTag/newTagare known:zuvo/reports/retro-YYYY-MM-DD-<previousTag>_<newTag>.md - Otherwise fall back to a shortened SHA-based suffix (first 7 chars of
baseShaandreleaseCommitShafromlast-ship.json):zuvo/reports/retro-YYYY-MM-DD-<baseSha7>_<releaseCommitSha7>.md
This prevents collisions and keeps filenames readable. If zuvo/reports/ does not exist, create it.
Use this exact structure:
# Engineering Retrospective — YYYY-MM-DD
## Summary
[Tweetable one-liner: period, commits, releases, key metric]
## Shipping Velocity
- **Window:** <range> (<N> days, <N> commits)
- **Deployment frequency:** N releases in period (frequency: 1 per X days)
- **Release cycle span:** N days (earliest commit → latest tag)
- **Commits:** N total, N/day average
## Churn Hotspots
[Top 5 most-changed files with change count. Note if test file was found for each.]
| Rank | File | Changes | Has Tests |
|------|------|---------|-----------|
| 1 | src/orders/service.ts | 12 | No |
| 2 | src/auth/guard.ts | 8 | Yes |
## Backlog Health
- **Open items:** N (N critical, N high)
- **Added this period:** N
- **Resolved this period:** N
- **Oldest unresolved:** [item ID] — open N days
## Quality Trends
- **CQ scores:** avg N/<applicable> ([N runs in period], or "no runs.log data")
- **Q scores:** avg N/<applicable> ([N runs in period], or "no runs.log data")
- **Most-used skills:** [skill1 (N runs), skill2 (N runs)]
- **Pass/fail ratio:** N PASS, N WARN, N FAIL
## Actionable Items
1. `zuvo:write-tests src/orders/` — high-churn (12 changes), no test file found
2. `zuvo:refactor src/auth/guard.ts` — 8 changes suggest instability
3. `zuvo:backlog fix BD-007` — critical debt item open 21 days
## Comparison vs Prior Retro
[Delta table or "First retro — run again next period for trends"]
Prior Retro Comparison
Check for existing zuvo/reports/retro-*.md files. If one or more exist, compare with the most recent:
| Metric | Prior | Current | Delta |
|---|---|---|---|
| Deployment frequency | N/period | N/period | +/-N |
| Release cycle span | N days | N days | +/-N days |
| Open backlog items | N | N | +/-N |
| Avg CQ score | N/ | N/ | +/-N |
| Avg Q score | N/ | N/ | +/-N |
If no prior retro exists: note "First retro — run again next period for trends."
Terminal Output Block
Print the RETRO COMPLETE block after writing the report:
RETRO COMPLETE
Window: v1.1.0..v1.2.0 (14 days, 47 commits)
Releases: 2 in period (frequency: 1 per week)
Cycle span: 14 days (earliest commit → latest tag)
Hotspots: src/orders/service.ts (12 changes), src/auth/guard.ts (8 changes)
Backlog: +5 added, -3 resolved, 12 open (2 critical)
Report: zuvo/reports/retro-2026-03-28-v1.1.0_v1.2.0.md
Actions:
1. zuvo:write-tests src/orders/ — high-churn, low coverage
2. zuvo:refactor src/auth/guard.ts — 8 changes suggest instability
3. zuvo:backlog fix BD-007 — critical debt item open 21 days
Run: <ISO-8601-Z> retro <project> - - <VERDICT> - 6-phase <NOTES> <BRANCH> <SHA7> <INCLUDES> <TIER>
After printing this block, append the `Run:` line value (without the `Run: ` prefix) to the log file path resolved per `run-logger.md`.
If E13 (insufficient history) was triggered, show:
RETRO COMPLETE [QUALITATIVE ONLY — <10 commits in window]
Window: HEAD~30..HEAD (fallback — fewer than 10 commits found)
Backlog: +N added, -N resolved, N open (N critical)
Report: zuvo/reports/retro-YYYY-MM-DD-<suffix>.md
Actions:
1. [derived from backlog only]
Run: <ISO-8601-Z> retro <project> - - <VERDICT> - qualitative <NOTES> <BRANCH> <SHA7> <INCLUDES> <TIER>
After printing this block, append the `Run:` line value (without the `Run: ` prefix) to the log file path resolved per `run-logger.md`.