From DevFlow
Runs a weekly devflow retrospective: scans merged PRs from watched authors, writes per-PR retrospectives, derives recurring patterns, and files one GitHub issue per actionable pattern.
How this skill is triggered — by the user, by Claude, or both
Slash command
/devflow:retrospective-weeklyThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill is the single entry point the maintainer invokes once a week (or
This skill is the single entry point the maintainer invokes once a week (or
on demand). It is a conductor: it runs deterministic bash/jq scripts from
lib/ at every mechanical step and dispatches LLM subagents only at the two
genuine-judgment points — per-PR retrospective analysis (Stage A) and
per-pattern issue-spec drafting (Stage B). Everything else — fetching,
signal computation, gating, pattern math, and git/issue mechanics — is done by
plain scripts with no LLM tokens. The loop proposes, it does not dispose:
each actionable pattern is filed as one GitHub issue for the normal
implement → review pipeline, not landed as an autonomous PR.
$LIB notation (textual, not a shell variable). Throughout this skill, $LIB in a command denotes the resolved path "${CLAUDE_SKILL_DIR:-<absolute skill base directory this runner reports in context>}"/../../lib — expand it textually (with the anchor already resolved for this runner) when composing each command you actually run. Never rely on a shell variable named LIB persisting from one statement or block to another — each Bash call is a fresh shell, and the Portable helper anchor note below explains why even same-command variable reuse is unsafe on some runners.
Every jq in this skill is invoked through the execution-verified wrapper
$LIB/../scripts/run-jq.sh ($LIB/../scripts is the scripts/ dir beside
lib/), never bare jq — so a shim-shadowed Windows/WSL host resolves a
runnable jq the same way the .sh helper tier does (issue #253, the agent-tier
sibling of #247). DEVFLOW_JQ is not exported to agent shells, so the wrapper
must be invoked by path.
All scratch files live under .devflow/tmp/ (gitignored). Learnings files
(.devflow/learnings/) are tracked and committed via the state PR.
GitHub autolink hygiene (any text you compose that lands on a GitHub surface — issue/PR titles, the state-PR report comment, body content you assemble): never put a bare # immediately before a number unless it is a real issue or PR reference — GitHub renders #2 as a link to issue/PR 2, which misleads readers. For an ordinal, count, or list position, spell it out ("item 2", "step 3"), never #2. Genuine references like #123 stay as-is.
Portable helper anchor (single-statement). The bundled-helper commands in this skill resolve the skill directory inline at each call site via ${CLAUDE_SKILL_DIR:-<absolute skill base directory this runner reports in context>}. When $CLAUDE_SKILL_DIR is set and non-empty (Claude Code), run each command exactly as written. On a runner where it is unset or empty, replace the placeholder with the skill base directory the runner reports in context (e.g. a Base directory for this skill: line) before running the command; if that reported path is Windows-form (C:\...), first convert it to this shell's POSIX form with one standalone wslpath -u '<path>' (WSL) or cygpath -u '<path>' (Git Bash/MSYS2) command and substitute the printed result only if the command succeeds and prints a non-empty path — otherwise fall through to the drive-letter rules exactly as if the tool were absent, the same success-and-non-empty acceptance the platform's path-normalization rules apply (if neither tool exists: lowercase the drive letter, map C:\ to /mnt/c on WSL or /c on MSYS2, and turn backslashes into /; if the environment is neither WSL nor MSYS2, use the path unchanged and report that it could not be normalized — the same arm the platform's path-normalization rules take). Resolve the anchor inline at every call site — never capture it into a shell variable that a later statement reads, because some runners' inline-bash marshaling drops such variables (observed on Copilot CLI). If neither $CLAUDE_SKILL_DIR nor a runner-reported base directory is available, stop and report that the helper anchor could not be resolved rather than running a command with a broken path.
Consumer prompt extension (load first). Before doing this skill's work, load any consumer-supplied prompt extension for this skill and honor it. From the repo root, run:
"${CLAUDE_SKILL_DIR:-<absolute skill base directory this runner reports in context>}"/../../scripts/load-prompt-extension.sh retrospective-weekly
If the invocation fails because the helper path does not exist (No such file, exit 127, or the platform equivalent), that is the anchor-resolution failure described in the Portable helper anchor note above — fix the anchor, don't report a missing extension. Otherwise, if the helper exits non-zero, a consumer extension exists but could not be loaded — surface its stderr message and do not silently proceed as if none existed. If it exits 0 and prints text, treat that text as additional instructions appended to the end of this skill's own prompt for this run — it is upgrade-safe, consumer-owned customization committed under .devflow/prompt-extensions/. If it exits 0 and prints nothing, proceed unchanged.
Confirm the working tree is clean:
git status --porcelain
If the output is non-empty, stop and tell the user to stash or commit their changes before running the loop.
Confirm gh is authenticated:
gh auth status
If it fails, tell the user to run gh auth login and stop.
Confirm you are on main:
git branch --show-current
If not on main, run git checkout main.
Prepare the scratch directory ($LIB below is the textual notation from the top of this skill — expand it when composing commands, do not assign a shell variable). This also removes prior-run per-PR scratch (result-*.json, pr-*.context.json) so a run starts clean and can never read another run's stale bundle or output:
mkdir -p .devflow/tmp
rm -f .devflow/tmp/new-entries.jsonl
# Remove prior-run per-PR scratch. `find … -delete` (not a bare `rm -f <glob>`)
# is shell- and OS-agnostic: it is a safe no-op when neither or only one pattern
# matches, whereas under zsh an unmatched glob is a fatal `no matches found` that
# would abort the whole rm (leaving BOTH patterns uncleaned). Step 1 runs before
# any fetch, so it never touches the current run's own freshly-written files.
find .devflow/tmp -maxdepth 1 -type f \( -name 'result-*.json' -o -name 'pr-*.context.json' \) -delete 2>/dev/null
Fetch the list of unprocessed watched-author PRs merged in the last 7 days:
bash $LIB/scan.sh > .devflow/tmp/scan.json
Ad-hoc / backfill / test runs. To run the loop against a specific set of
PRs instead of the rolling 7-day window — e.g. backfilling old PRs, re-running
after a fix, or testing the pipeline — pass --prs:
bash $LIB/scan.sh --prs 774,786,772,789 > .devflow/tmp/scan.json
--prs skips the GitHub search and the already-processed filter (you named
the PRs, so the loop trusts you), but still drops any number that isn't a merged
retrospected branch. Everything downstream (Steps 3–10) is identical. Do not
use --prs for the scheduled weekly run.
scan.sh writes to stdout and exits non-zero on unrecoverable errors. If
the output array is empty:
$LIB/../scripts/run-jq.sh 'length == 0' .devflow/tmp/scan.json
→ true: report "Nothing to process — no unprocessed watched-author PRs
in the last 7 days." and STOP.
Initialize counters:
prs_scanned=0
clean_count=0
analyzed_count=0
skipped_count=0 # issue #626: mechanically- and Stage-A-skipped PRs
skip_records=() # one-line report records, one per skip (never silent)
needs_analysis=() # array of bundle paths
For each PR number in scan.json (iterate via $LIB/../scripts/run-jq.sh -r '.[].number'):
number=<the pr number>
CTX=$(bash $LIB/fetch-pr-context.sh "$number")
prs_scanned=$((prs_scanned + 1))
fetch-pr-context.sh writes the bundle to .devflow/tmp/pr-<n>.context.json
and echoes that file path to stdout — so $CTX is the path, not the
bundle content.
Run the cheap gate against the bundle content:
GATE=$($LIB/../scripts/run-jq.sh -c -f $LIB/cheap-gate.jq < "$CTX")
Outputs {"clean": <bool>, "reason": "<string>"}.
If clean == true:
Emit a clean entry (every retrospected PR is an implementation PR now — the
old audit-kind path is retired along with autonomous intervention PRs):
$LIB/../scripts/run-jq.sh -c -f $LIB/clean-entry.jq < "$CTX" >> .devflow/tmp/new-entries.jsonl
Increment clean_count.
If clean == false:
First run the mechanical pre-dispatch disposition (issue #626). This decides —
with no LLM dispatch — whether the non-clean bundle warrants Stage A analysis
or is a mechanical skip (a foreign, non-DevFlow PR whose only non-clean signal is a
missing workpad audit trail — Absent means the linked issue did resolve but
carried no workpad comment, so this is not restricted to issueless PRs). It is a suite-driven helper, never inline prose:
DISP=$($LIB/../scripts/run-jq.sh -c --argjson gate "$GATE" -f $LIB/dispatch-disposition.jq < "$CTX") # argjson-ok: gate -- one PR's cheap-gate result (bounded)
DISP is {"disposition": "skip"|"dispatch", "reason": "<string>"}. It returns
skip exactly when the gate reason is a workpad reason, the status is a
sentinel (Absent/NoIssue), and pr_devflow_provenance is false — otherwise
dispatch. So a bundle non-clean on any non-workpad signal (outstanding
REJECT, CI failures, post-bot commits, review comments) is always dispatched,
exactly the analysis it receives today.
If disposition == "skip" (the mechanical no-provenance skip): this is a
permanently-terminal skip. Append a marker entry to the store and write a
one-line run-report record — costing zero LLM dispatches. Do not add it to
needs_analysis.
# $number is this PR (the loop variable); DISP's .reason is the skip reason line.
SKIP_REASON=$(printf '%s' "$DISP" | $LIB/../scripts/run-jq.sh -r '.reason')
# argjson-ok: pr -- scalar PR number.
$LIB/../scripts/run-jq.sh -cn --argjson pr "$number" --arg reason "$SKIP_REASON" \
'{kind:"skip", pr:$pr, reason:$reason}' >> .devflow/tmp/new-entries.jsonl
skip_records+=("PR #$number skipped (mechanical, no DevFlow provenance): $SKIP_REASON")
skipped_count=$((skipped_count + 1))
Record the skip in the run report (see Step 9) with its reason line and increment
skipped_count. The marker entry makes the processed-PRs filter treat this PR as
handled on subsequent runs.
If disposition == "dispatch": add the bundle path to the analysis list:
needs_analysis+=("$CTX")
analyzed_count=$((analyzed_count + 1))
For each bundle path in needs_analysis, dispatch a subagent. Issue up to
3–4 subagents concurrently in a single message (use the Agent tool for
each). Each subagent prompt:
Read and follow
"${CLAUDE_SKILL_DIR:-<absolute skill base directory this runner reports in context>}"/../retrospective/SKILL.mdexactly.Your context bundle path is:
<path>Print exactly one JSON object (the retrospective entry) and nothing else on stdout.
(The subagent picks categories from the fixed vocabulary in that skill — no
"existing tags" list is passed; the vocabulary is the bounded list.)
Wait for all dispatched subagents to finish before continuing.
Collecting results: Each subagent's final message is its JSON object.
Subagent output can contain quotes, backticks, newlines, and $ — never
interpolate it inline into a shell command. Write each subagent's raw result
to a temp file with the Write tool (e.g. .devflow/tmp/result-<n>.json), then
operate on the file. For each result:
Attempt to parse it: $LIB/../scripts/run-jq.sh -c . < .devflow/tmp/result-<n>.json
A Stage A defined skip (issue #626) is recognized by the presence of a
top-level "skip" key ONLY — never by matching substrings of any error text
(agent-authored free text is data, never a discriminator). A "skip"-keyed
return is terminal: no retry, no blocker. Whether it leaves a marker depends
on the bundle's workpad_final_status (a mechanical field, not the skip text):
Cancelled → a permanently-terminal skip → append a marker entry so
the PR is seen as handled next run:
$LIB/../scripts/run-jq.sh -cn --argjson pr <n> --arg reason "<the skip .reason>" '{kind:"skip", pr:$pr, reason:$reason}' >> .devflow/tmp/new-entries.jsonl # argjson-ok: pr -- scalar PR numberSetup/Discovering/…/Documenting) → a transient
skip → append no marker, so the PR stays unprocessed and is re-scanned
while it remains inside the 7-day merge lookback.Either way, record the skip explicitly — append the report record AND increment
the counter, in the same two statements the mechanical branch above uses, so
skipped_count and the rendered skips[] can never diverge:
skip_records+=("PR #<n> skipped (Stage A, <Cancelled|interim>): <the skip .reason>")
skipped_count=$((skipped_count + 1))
That record is what Step 9 renders. Every skip — the mechanical
pre-dispatch skip above, the Cancelled skip, and the interim skip — writes a
one-line report record, so no skip is ever silent.
Otherwise, if parsing fails or the object has an "error" key (a genuine
failure), retry the subagent once with the same prompt.
If still malformed (or still "error"-keyed) after one retry, record a blocker:
"PR #<n>: retrospective analysis failed" and skip that PR.
If valid (a real retrospective entry, no "skip"/"error" key), append:
$LIB/../scripts/run-jq.sh -c . < .devflow/tmp/result-<n>.json >> .devflow/tmp/new-entries.jsonl
Merge all new entries into the retrospectives file (idempotent — existing
entries for the same pr+kind are replaced):
bash $LIB/materialize-retrospectives.sh \
.devflow/tmp/new-entries.jsonl \
.devflow/learnings/retrospectives.jsonl
The script prints "materialized: appended N, replaced M" to stdout.
bash $LIB/actionable-patterns.sh \
.devflow/learnings/retrospectives.jsonl \
.devflow/learnings/overrides.json \
> .devflow/tmp/patterns.json
Print a summary line to the console, for example:
5 PRs: 3 clean, 2 analyzed; 2 actionable patterns: incomplete-edit (x5), lenient-verdict (x3)
Partition patterns.json into two lists:
to_act=$($LIB/../scripts/run-jq.sh '[.[] | select(.cooldown_active == false)]' .devflow/tmp/patterns.json)
cooldown_skipped=$($LIB/../scripts/run-jq.sh '[.[] | select(.cooldown_active == true) | .tag]' .devflow/tmp/patterns.json)
Record cooldown_skipped tags for the final report.
After Step 5 materialized this week's retrospective entries (and before the Step 7 state PR commits the learnings files), assemble the unified experiment record — joining each merged PR's per-run cost to its review outcome (verdict, Important-finding count, denial count, config fingerprint). Anchored here so this week's PRs join against this week's freshly-materialized retrospective entries.
This is a best-effort step and never blocks the retrospective: a non-zero exit is logged as a breadcrumb and the run continues. Carry that breadcrumb into the Step 9 status report as a blocker note so the failure is visible, then proceed.
A non-zero exit means some PRs did not make it into the store — not necessarily that
nothing was written. The assembler exits 2 when any candidate failed to assemble or
had an unestablished merge state (a gh outage, an unresolvable repo), and it still
writes the records that did assemble, so a partial failure leaves a partially-updated
store. Report it as "N PRs missing from the experiment store," not as "the store was not
updated." An unestablished PR does not backfill by itself — it never enters the store,
and only stored or retrospective-listed PRs are re-selected as candidates — so name the
PRs from the breadcrumb and re-run with --prs once the cause is resolved.
Before the reader runs, fetch the telemetry branch (issue #441) into its local ref so
build-experiment-records.py can union each run's durable record off that branch with any
legacy tracked .devflow/logs/. Best-effort: on a fresh repo the branch does not exist yet
(nothing has persisted to it) and the fetch is a harmless no-op — the reader then reads the
legacy archive alone; the retrospective is never blocked by a missing telemetry branch.
# Fetch the telemetry branch into its local ref — deliberately NO force `+` refspec: the
# local ref can be AHEAD of the remote (offline-accumulated `--persist` commits not yet
# reconciled by a writer's union re-parent), and a forced fetch would rewind it and
# permanently orphan those records. A plain fetch fast-forwards the common case and rejects
# exactly the local-ahead/diverged case, leaving the local ref — and its records — intact
# for the reader; reconciling with the remote belongs to the next writing run's
# fetch → re-parent → push loop, never to this read-side fetch.
# The reader (`_index_efficiency`) reads it by its local branch name.
#
# Resolve the branch through the SAME resolver the writer uses — never a third, independent
# re-derivation. `devflow_telemetry_branch` (lib/telemetry-branch.sh) owns the full contract:
# the `.telemetry.branch` read, config-get.sh's coercion, and the `git check-ref-format`
# fallback to the default. A bare `config-get.sh` read here applies NEITHER guard, and both
# gaps end the same way — this fetch targets a branch nobody wrote to, the local ref is never
# populated, and on a fresh clone or a cloud checkout the reader then finds nothing and every
# cost row silently goes missing:
# * a schema-valid but git-invalid name ("my branch", "a..b") → the writer persisted to
# `devflow-telemetry`, but this would try to fetch `my branch`;
# * a malformed config → config-get.sh prints NOTHING and exits 2, so TELEMETRY_BRANCH is
# empty and the command degrades to `git fetch origin ":"`, while the writer and reader
# both fell back to `devflow-telemetry`.
# Source the lib and ask it. The `||` keeps this best-effort: if the lib cannot be sourced, fall
# back to the same default the resolver would have returned.
#
# Do NOT redirect the resolver's stderr to /dev/null. Its breadcrumb is the whole reason for
# routing through it: on a git-invalid `telemetry.branch` it is the one place that names the
# config key to fix. Silencing it would leave an operator with a broken config reading a
# perfectly healthy-looking retrospective that quietly contains no telemetry-branch rows.
# Only stdout is captured; stderr flows to the run log.
#
# Note the lib sources `config-source.sh`, which sets `set -euo pipefail` in THIS shell. Every
# command below is `||`-guarded, so that is harmless today — but keep new commands in this fence
# guarded, or errexit will abort the step.
. "$LIB/telemetry-branch.sh" || true
TELEMETRY_BRANCH=$(devflow_telemetry_branch) || TELEMETRY_BRANCH=""
[ -n "$TELEMETRY_BRANCH" ] || TELEMETRY_BRANCH=devflow-telemetry
git fetch origin "${TELEMETRY_BRANCH}:${TELEMETRY_BRANCH}" 2>/dev/null || \
echo "retrospective-weekly: could not fetch telemetry branch '${TELEMETRY_BRANCH}' (absent on a fresh repo, offline, or the local ref has commits the remote lacks) — the experiment-record reader unions whatever local '${TELEMETRY_BRANCH}' ref exists (if any) with any legacy tracked .devflow/logs/" >&2
python3 $LIB/../scripts/build-experiment-records.py || \
echo "retrospective-weekly: build-experiment-records.py exited non-zero (rc=$?) — one or more PRs are MISSING from the experiment store (see its stderr for which, and whether they failed to assemble or had an unestablished merge state); records that did assemble were still written" >&2
The assembler is idempotent (one line per merged PR, keyed by PR number) and incremental
(it processes merged PRs absent from .devflow/learnings/experiment-records.jsonl plus
any passed via --prs, never a full-history sweep), so re-running is safe and cheap. It
runs on the local/interactive retrospective tier only — it is never invoked from a
workflow. See docs/efficiency-trace.md for the store schema and the abandoned-run bias.
Open the state PR now, before Stage B, so that the learnings files are
committed onto their own branch. This captures the unstaged changes Steps 5–6
wrote to .devflow/learnings/ before any issue is filed, so this run's
retrospective data survives even if Stage B or the filing step fails partway.
Ensure you are on main:
git checkout main
The working tree now has the updated
.devflow/learnings/retrospectives.jsonl (and possibly a modified
.devflow/learnings/overrides.json from meta-issue dismissals in a previous
run). These changes are in-place on main's working tree and have never
been committed to main — open-state-pr.sh handles committing them onto
a separate branch.
STATE_PR=$(bash $LIB/open-state-pr.sh)
open-state-pr.sh (no required args; optional --branch <name>,
--base <ref> — defaults to main —, and --dry-run):
devflow/learnings-<YYYY-MM-DD> from --base
(main by default), so the PR diff is just the learnings files even if the
operator was on a feature branch..devflow/learnings/retrospectives.jsonl
and, if present, .devflow/learnings/overrides.json).main.After it returns, go back to main so the working tree is clean and
Stage B starts from a known-good HEAD:
git checkout main
Initialize Stage B counters:
intervention_issues=() # will hold {tag, url} objects — one per filed pattern
blockers=() # will hold strings
For each actionable pattern, a Stage B subagent drafts a {title, body} issue
spec and the orchestrator files exactly one GitHub issue from it via
meta-issue.sh. No worktrees, no commits, no PRs — the loop proposes; a
human triages each issue and runs it through the normal /devflow:implement →
review pipeline. Your main checkout stays on main and is never edited. The
drafting subagents (8b) parallelize; the cheap filing (8c) is done serially.
For each pattern in to_act, make sure every occurrence bundle is on disk
(fetch the ones not already fetched this run):
for n in $($LIB/../scripts/run-jq.sh -r '.occurrences[].pr' <<< "$pattern"); do
[ -f ".devflow/tmp/pr-${n}.context.json" ] || bash $LIB/fetch-pr-context.sh "$n" >/dev/null
done
Record, per pattern: SLUG ($LIB/../scripts/run-jq.sh -r .slug <<< "$pattern"), TAG
($LIB/../scripts/run-jq.sh -r .tag <<< "$pattern"), the JSON array of absolute bundle paths, and the
pattern object.
Issue one Agent call per pattern, all in a single message so they run in parallel. No worktree is created or passed — the subagent makes no edits. Each subagent's prompt:
Read and follow
"${CLAUDE_SKILL_DIR:-<absolute skill base directory this runner reports in context>}"/../retrospective-audit/SKILL.mdexactly.Occurrence-PR context bundle paths (absolute):
<json array of paths>Pattern metadata:
<the pattern json object>Make no edits and no worktree. Print exactly one JSON object (the
{title, body}return contract from § 5 of that skill) and nothing else on stdout.
Wait for all subagents to finish. Pair each result JSON with its pattern.
For each (pattern, result) pair, in any order:
Write the subagent's raw result to .devflow/tmp/result-${SLUG}.json with the
Write tool (it can contain quotes, backticks, newlines, and $ — never
interpolate it inline into a shell command), then:
# 1. Parse + validate the {title, body} contract. Malformed → blocker, continue.
# The wrapper precheck is a SEPARATE single-statement branch (no rc variable carried
# across statements — the inline-bash marshaling constraint the anchor note documents),
# so an unexpanded $LIB or missing/non-executable run-jq.sh is reported as the anchor
# failure it is, never misdiagnosed as "malformed subagent JSON". `[ ! -x ]` (not `[ ! -e ]`)
# so a PRESENT-but-non-executable wrapper (lost its +x bit → an invocation would exit 126)
# is caught here as an anchor/wrapper failure, mirroring the execution-verified `resolve-*.sh`
# family (#247) — an existence-only check would let it fall through to the elif and mis-read
# the exit-126 as malformed JSON.
if [ ! -x "$LIB/../scripts/run-jq.sh" ]; then
blockers+=("Pattern ${SLUG}: run-jq.sh wrapper not found or not executable (unexpanded \$LIB notation, missing wrapper, or lost +x bit; fix the anchor) — not filed")
elif ! $LIB/../scripts/run-jq.sh -e '.title and .body' < ".devflow/tmp/result-${SLUG}.json" >/dev/null 2>&1; then
# Malformed: record a blocker and file NOTHING. The append below is the
# load-bearing failure path — it MUST run (the run reports this pattern as a
# blocker, never as filed), so it is concrete shell, not a comment.
blockers+=("Pattern ${SLUG}: Stage B subagent returned malformed JSON (missing title/body) — not filed")
else
# 2. Extract the body to a file (via jq, so backticks/$/newlines never hit
# the shell) and the title to a shell var.
$LIB/../scripts/run-jq.sh -r '.body' < ".devflow/tmp/result-${SLUG}.json" > ".devflow/tmp/issue-body-${SLUG}.md"
TITLE="$($LIB/../scripts/run-jq.sh -r '.title' < ".devflow/tmp/result-${SLUG}.json")"
# 3. File exactly one issue. meta-issue.sh stamps DevFlow + Retrospective
# (best-effort), records the overrides.json cooldown, is idempotent (an
# open issue for this pattern → recurrence comment, not a duplicate), and
# fails CLOSED (non-zero exit) on a de-dup-lookup error or a create that
# returned no usable issue URL. An overrides-write failure AFTER a
# successful create is the one exception: the issue genuinely exists, so it
# reports FILED (exit 0 + URL + a loud ::error:: breadcrumb), not blocked —
# the next run's de-dupe recovers the missing cooldown.
if ISSUE_URL="$(bash $LIB/meta-issue.sh \
--tag "$TAG" \
--slug "$SLUG" \
--title "$TITLE" \
--body-file ".devflow/tmp/issue-body-${SLUG}.md" \
--overrides .devflow/learnings/overrides.json)"; then
# Success: record {tag, url} in intervention_issues.
intervention_issues+=("$($LIB/../scripts/run-jq.sh -nc --arg tag "$TAG" --arg url "$ISSUE_URL" '{tag:$tag,url:$url}')")
else
# meta-issue.sh exited non-zero (de-dup lookup / create-returned-no-URL;
# an overrides-write failure does NOT land here — it reports FILED).
# Record a blocker and file NOTHING — the pattern stays absent from
# intervention_issues. Concrete append, same reason as above.
blockers+=("Pattern ${SLUG}: meta-issue.sh failed to file the issue — not filed")
fi
fi
Never report a pattern as filed when it was not. A malformed Stage B result
or a meta-issue.sh non-zero exit records a per-pattern blocker and the run
continues to the next pattern; the pattern is absent from intervention_issues.
Do not post /devflow:implement (or any auto-trigger comment) on a filed
issue — filed issues await human triage.
(meta-issue.sh mutates .devflow/learnings/overrides.json in your main
checkout's working tree. That happens after the Step 7 state PR was opened,
so the new cooldown lands in next week's state PR — see § Notes for the optional
follow-up commit if you want it in this run's PR.)
Collect the per-analyzed-PR digest lines (verdict + a one-line summary) and the
full pattern list (acted-on, cooldown-skipped, dismissed, and below-threshold —
the same patterns.json from Step 6) so the report shows the whole picture, not
just the PRs that produced an intervention:
ANALYZED_JSON="$($LIB/../scripts/run-jq.sh -sc '[.[] | select(.verdict == "imperfect" or .verdict == "blocked") | {pr, verdict, summary}]' .devflow/tmp/new-entries.jsonl)"
PATTERNS_JSON="$(cat .devflow/tmp/patterns.json)"
RECURRING_TARGETS_JSON="$(bash $LIB/recurring-targets.sh .devflow/learnings/retrospectives.jsonl)"
recurring-targets.sh groups every accumulated entry's
suggested_interventions[].candidate_targets[] by exact target path and emits
only the targets named in ≥2 distinct PRs (report-only; [] when nothing
recurs, which render-report.sh then omits).
Build the summary JSON and assign it to $SUMMARY_JSON:
# Route the corpus-sized operands (the --slurpfile flags below) through files rather
# than --argjson argv slots: they grow with the corpus and, as argv slots, overflow the
# kernel arg limit at scale (jq: "Argument list too long", issue #783). --slurpfile wraps
# each file in a one-element array, so the jq program dereferences [0].
_SUMMARY_TMP="$(mktemp -d)"
trap 'rm -rf "$_SUMMARY_TMP"' EXIT
# Preserve --argjson's fail-loud-on-empty semantics after the #783 --slurpfile switch:
# an empty operand slurps to []→[0]=null (silent) where --argjson aborted loud. These
# three are upstream producer output, valid JSON ([] at minimum) on success — an empty
# string means that producer failed, so fail loud rather than emit analyzed/patterns:null.
: "${ANALYZED_JSON:?devflow retrospective Step 9: ANALYZED_JSON is empty — upstream Stage-A analysis failed}"
: "${PATTERNS_JSON:?devflow retrospective Step 9: PATTERNS_JSON is empty — Step 6 patterns.json missing/empty}"
: "${RECURRING_TARGETS_JSON:?devflow retrospective Step 9: RECURRING_TARGETS_JSON is empty — recurring-targets.sh failed}"
printf '%s\n' "${skip_records[@]:-}" | $LIB/../scripts/run-jq.sh -sRc 'split("\n") | map(select(. != ""))' > "$_SUMMARY_TMP/skips.json"
printf '%s' "$ANALYZED_JSON" > "$_SUMMARY_TMP/analyzed.json"
printf '%s' "$PATTERNS_JSON" > "$_SUMMARY_TMP/patterns.json"
printf '%s' "$RECURRING_TARGETS_JSON" > "$_SUMMARY_TMP/recurring_targets.json"
printf '%s\n' "${intervention_issues[@]:-}" | $LIB/../scripts/run-jq.sh -sc '.' > "$_SUMMARY_TMP/intervention_issues.json"
printf '%s\n' "${cooldown_skipped[@]:-}" | $LIB/../scripts/run-jq.sh -sc '.' > "$_SUMMARY_TMP/cooldown_skipped.json"
printf '%s\n' "${blockers[@]:-}" | $LIB/../scripts/run-jq.sh -sc '.' > "$_SUMMARY_TMP/blockers.json"
# Same fail-loud property for the four INLINE producers above. Their `> file` redirect
# truncates the file before the pipeline runs, so a failing jq (unresolvable binary, a
# malformed element under -sc '.') leaves the file EMPTY — and an empty --slurpfile
# operand is []→[0]=null, silently emitting skips/blockers:null where --argjson aborted
# loud. On success each writes at minimum `[]` (non-empty), so an empty file is
# unambiguously producer failure. Guard by file, not by variable, because these operands
# never pass through a shell variable.
for _op in skips intervention_issues cooldown_skipped blockers; do
[ -s "$_SUMMARY_TMP/$_op.json" ] || {
echo "devflow retrospective Step 9: $_op.json is empty — its inline jq producer failed" >&2
rm -rf "$_SUMMARY_TMP"; exit 1
}
done
# argjson-ok: prs_scanned, clean_count, analyzed_count, skipped_count, state_pr --
# bounded scalars (counts and one PR number) — safe as argv.
SUMMARY_JSON="$($LIB/../scripts/run-jq.sh -nc \
--argjson prs_scanned "$prs_scanned" \
--argjson clean_count "$clean_count" \
--argjson analyzed_count "$analyzed_count" \
--argjson skipped_count "$skipped_count" \
--slurpfile skips "$_SUMMARY_TMP/skips.json" \
--slurpfile analyzed "$_SUMMARY_TMP/analyzed.json" \
--slurpfile patterns "$_SUMMARY_TMP/patterns.json" \
--slurpfile recurring_targets "$_SUMMARY_TMP/recurring_targets.json" \
--slurpfile intervention_issues "$_SUMMARY_TMP/intervention_issues.json" \
--slurpfile cooldown_skipped "$_SUMMARY_TMP/cooldown_skipped.json" \
--slurpfile blockers "$_SUMMARY_TMP/blockers.json" \
--argjson state_pr "$STATE_PR" \
'{prs_scanned:$prs_scanned,clean_count:$clean_count,analyzed_count:$analyzed_count,
skipped_count:$skipped_count,skips:$skips[0],
analyzed:$analyzed[0],patterns:$patterns[0],recurring_targets:$recurring_targets[0],
intervention_issues:$intervention_issues[0],
cooldown_skipped:$cooldown_skipped[0],blockers:$blockers[0],state_pr:$state_pr}')"
rm -rf "$_SUMMARY_TMP"
(The "${array[@]:-}" form handles an empty bash array safely under set -u.
render-report.sh renders the analyzed and patterns sections only when
those keys are non-empty, so an older caller that omits them still works.)
Render the report markdown and post it as a comment on the state PR:
source $LIB/render-report.sh
devflow_render_report "$SUMMARY_JSON" > .devflow/tmp/report.md
bash $LIB/post-status.sh --pr "$STATE_PR" --report-file .devflow/tmp/report.md
Print the rendered report (cat .devflow/tmp/report.md) to the console.
Then list each item that needs human action:
https://github.com/<repo>/pull/<state_pr><tag>: <url>If there are any blockers, list them explicitly.
Tell the user:
Review and merge the state PR once CI passes. Each filed issue awaits human triage — pick the ones worth acting on and run them through the normal implement → review pipeline; the loop never starts that for you. The loop is idempotent — re-running next week will only process new PRs not yet in
retrospectives.jsonlonmain, and a pattern already filed this cycle is not re-filed.
Do not run gh pr merge --auto on anything, and do not auto-start
implementation on a filed issue. The maintainer triages and merges manually
after reviewing.
claude -p "/devflow:retrospective-weekly" --permission-mode acceptEdits handles steps
1–9 unattended. Stage B makes no working-tree edits (it only drafts issue
specs) and the orchestrator only files issues and writes .devflow/learnings/,
so the loop is well-suited to an unattended run. For a fully unattended run, add
--dangerously-skip-permissions.
Clean working tree required. The loop modifies .devflow/learnings/
in-place on main's working tree; starting dirty risks mixing pre-existing
changes into the state PR commit.
State PR before Stage B. Opening the state PR (Step 7) before Stage B is
intentional: it commits the learnings files onto devflow/learnings-<date>
before any issue is filed, so this run's retrospective data is captured even
if Stage B or the filing step fails partway. Stage B never touches your main
checkout — it makes no edits at all.
Issue-per-pattern. Stage B dispatches one drafting subagent per actionable
pattern concurrently (each returns a {title, body} spec, no edits), then the
orchestrator files exactly one GitHub issue per pattern via meta-issue.sh.
No worktrees, no commits, no PRs — the loop proposes; a human implements.
Overrides after Stage B. meta-issue.sh records each filed pattern's
cooldown in .devflow/learnings/overrides.json in your main working tree
after the Step 7 state PR was opened, so the change lands in next week's
state PR automatically. If you want it in this run's PR, after Step 8 push a
follow-up commit onto the same devflow/learnings-<date> branch:
if ! git diff --quiet HEAD -- .devflow/learnings/overrides.json 2>/dev/null; then
LB="devflow/learnings-$(date -u +%F)"
git fetch origin "$LB"
git checkout "$LB"
git add .devflow/learnings/overrides.json
git commit -m "chore(devflow): add overrides from Stage B filed issues"
git push --force-with-lease origin "$LB"
git checkout main
fi
Idempotent. Re-running re-processes only PRs whose number is not already
in retrospectives.jsonl on main. A pattern already filed this cycle is not
re-filed: meta-issue.sh finds the open issue and adds a recurrence comment
instead of a duplicate, and the overrides.json cooldown excludes the pattern
on subsequent runs.
Never auto-merge, never auto-implement. The maintainer merges the state PR manually after CI, and triages each filed issue manually — the loop never starts an implement run for you.
materialize-retrospectives.sh signature: takes two explicit positional
args — <new-entries-file> and <jsonl-path>. Always pass both.
actionable-patterns.sh signature: takes two explicit positional args
— <retrospectives.jsonl> and <overrides.json>. Always pass both.
open-state-pr.sh signature: no required args; optional --branch,
--base (defaults to main), --dry-run; prints the PR number
to stdout.
fetch-pr-context.sh return value: echoes the bundle file path to
stdout; the bundle content is on disk at .devflow/tmp/pr-<n>.context.json.
cheap-gate.jq invocation: reads from stdin (the bundle content, not
the path) — use $LIB/../scripts/run-jq.sh -c -f $LIB/cheap-gate.jq < "$CTX" where $CTX is
the path.
npx claudepluginhub the01geek/devflow-autopilot --plugin devflowAnalyzes a non-clean PR from a pre-fetched context bundle and returns a retrospective entry as JSON. Used as a subagent in the devflow self-improving loop.
Generates weekly engineering retrospectives from git history with per-person contributions, code quality metrics, and trend tracking.
Analyzes git commit history, work patterns, and code quality metrics to produce engineering retrospectives with per-person breakdowns, shipping streaks, and trend tracking.