#!/usr/bin/env bash # ci/decisions-log.sh — the handoff log is a DIRECTORY, and the old single file is frozen. # # WHY THIS STAGE EXISTS # --------------------- # CLAUDE.md §7 tells every agent to record a handoff before finishing. Until 2026-09-29 that # meant appending to the tail of one 5,228-line file, DECISIONS-LOG.md. Every branch appended # to the SAME last hunk, so git could not auto-merge any two of them. Measured that day against # the live Gitea: four of the five open PRs were `mergeable: false`, and `git merge-tree` # against `main` named DECISIONS-LOG.md as the ONLY conflicting path in all four: # # #22 fix/crafty-oauth2-proxy-signin CONFLICT (content): DECISIONS-LOG.md # #20 feat/conformance-job-template CONFLICT (content): DECISIONS-LOG.md # #19 feat/conformance-trigger-kubeconfig CONFLICT (content): DECISIONS-LOG.md # #18 feat/conformance-runner-image CONFLICT (content): DECISIONS-LOG.md # #17 feat/ci-is-the-approver clean — it wrote no handoff entry # # Nothing was wrong with any of those changes. The log's storage format serialised work that # was otherwise parallel, and the cost grew with every concurrent branch (N branches -> N-1 # rebases, each invalidating the gate run before it). # # The fix is that handoffs now live one-per-file under decisions/, so concurrent branches touch # disjoint paths and there is nothing to merge. See decisions/README.md. # # WHY IT NEEDS A CHECK AND NOT JUST A DOCUMENT # -------------------------------------------- # The old habit is written into this repo in many places — the runbooks, the muscle memory of # every agent that has read the log, and (until this change) CLAUDE.md itself. An agent that # appends to DECISIONS-LOG.md anyway does not fail: it produces a perfectly good-looking commit # that quietly re-creates the conflict for every other open branch, and the damage is only # visible later, to somebody else, in a PR that is not theirs. That is the exact shape this # repo keeps getting burned by — a silent regression with a delayed, misattributed symptom — so # it gets a guard rather than a paragraph. # # WHAT IS ASSERTED # (1) DECISIONS-LOG.md is byte-identical to the SHA-256 pinned in decisions/ARCHIVE.sha256. # Re-pinning is a deliberate, reviewable line in a diff; appending is not. # (2) Every decisions/ entry is named YYYY-MM-DD-.md with a real calendar date, so the # directory sorts chronologically and two agents cannot collide on a filename. # (3) Every entry carries frontmatter (date, branch, title) whose `date` agrees with its # filename — a disagreement means a copied template nobody edited. # (4) Every entry has all three of CLAUDE.md §7's sections with a real body under each: # "What changed", "What I verified", "What I did NOT verify". This is the quality floor # that stops an entry degrading into "done ✅". The third heading is the load-bearing one: # this repo's recurring failure is a confident report of something nobody measured, and an # entry with nowhere to write "UNVERIFIED" does not get one written. # # EXIT CODES (ci/README.md contract): 0 pass, 1 BLOCKS the merge. This stage has NO skip path — # it reads only files that are in the tree it is run against, needs no tool beyond coreutils and # no cluster, so there is no environment in which it "could not run". Adding an rc 3 here would # add a fail-open surface to a guard whose whole job is to close one. set -uo pipefail CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "${CI_DIR}/lib.sh" set +e # aggregate every problem in one run; do not stop at the first. cd "$REPO_ROOT" ARCHIVE="DECISIONS-LOG.md" PIN="decisions/ARCHIVE.sha256" rc=0 # ---- (1) the archive is frozen ---------------------------------------------- log "Stage: decisions log (archive frozen + entries well-formed)" if [ ! -f "$ARCHIVE" ]; then err "${ARCHIVE} is missing. It is the frozen pre-2026-09-29 history and must stay in the tree." summary "- decisions-log: **FAILED** (\`${ARCHIVE}\` missing)" rc=1 elif [ ! -f "$PIN" ]; then err "${PIN} is missing, so the archive's integrity cannot be established." err " If you are re-pinning deliberately: sha256sum ${ARCHIVE} > ${PIN}" summary "- decisions-log: **FAILED** (\`${PIN}\` missing — archive unverifiable)" rc=1 else want="$(awk 'NR==1{print $1}' "$PIN")" got="$(sha256sum "$ARCHIVE" | awk '{print $1}')" if [ "$want" != "$got" ]; then err "${ARCHIVE} HAS CHANGED. It is a FROZEN ARCHIVE — do not append to it." err " pinned: ${want}" err " on disk: ${got}" err "" err " Every branch that appends to this file's tail conflicts with every other branch that" err " does, on the same hunk. That is what made four of five open PRs unmergeable on" err " 2026-09-29, and your commit would re-create it for branches that are not yours." err "" err " Write your handoff as its OWN FILE instead:" err " decisions/\$(date +%F)-.md (see decisions/README.md)" err " and restore the archive: git checkout -- ${ARCHIVE}" err "" err " If the archive genuinely must change (redacting a leaked credential, say), that is a" err " reviewed act: re-pin it with 'sha256sum ${ARCHIVE} > ${PIN}' and say why in your entry." summary "- decisions-log: **FAILED — the frozen archive \`${ARCHIVE}\` was modified** (append to \`decisions/\` instead)" rc=1 else ok "${ARCHIVE}: unchanged (sha256 matches ${PIN})" fi fi # ---- (2)(3)(4) every entry is well-formed ------------------------------------ shopt -s nullglob entries=( decisions/[0-9]*.md ) shopt -u nullglob # Zero entries is legitimate only until the first handoff lands; after that the glob always # matches. It is never an error here — a branch that changes no behaviour owes no handoff, and # CLAUDE.md, not this stage, is what asks for one. if [ "${#entries[@]}" -eq 0 ]; then log "no decisions/ entries yet" fi REQUIRED_SECTIONS=("What changed" "What I verified" "What I did NOT verify") for f in "${entries[@]}"; do base="${f#decisions/}" # (2) filename if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})-([a-z0-9][a-z0-9-]*)\.md$ ]]; then err "${f}: name must be decisions/YYYY-MM-DD-.md with a lowercase [a-z0-9-] slug." err " Use your branch's last segment as the slug — it is already unique per unit of work." summary "- decisions-log: **FAILED** (\`${f}\` misnamed)" rc=1 continue fi fname_date="${BASH_REMATCH[1]}" if ! date -d "$fname_date" +%F >/dev/null 2>&1 || [ "$(date -d "$fname_date" +%F)" != "$fname_date" ]; then err "${f}: '${fname_date}' is not a real calendar date." summary "- decisions-log: **FAILED** (\`${f}\` — impossible date)" rc=1 continue fi # (3) frontmatter if [ "$(head -1 "$f")" != "---" ]; then err "${f}: must open with a '---' YAML frontmatter block (date, branch, title)." err " See decisions/README.md for the template." summary "- decisions-log: **FAILED** (\`${f}\` — no frontmatter)" rc=1 continue fi fm="$(awk 'NR==1&&$0=="---"{inb=1;next} inb&&$0=="---"{exit} inb{print}' "$f")" missing_keys=() for k in date branch title; do grep -qE "^${k}:[[:space:]]*\S" <<<"$fm" || missing_keys+=("$k") done if [ "${#missing_keys[@]}" -gt 0 ]; then err "${f}: frontmatter is missing a non-empty ${missing_keys[*]}." summary "- decisions-log: **FAILED** (\`${f}\` — frontmatter missing \`${missing_keys[*]}\`)" rc=1 continue fi fm_date="$(grep -m1 -E '^date:' <<<"$fm" | sed -E 's/^date:[[:space:]]*//; s/[[:space:]"'\'']//g')" if [ "$fm_date" != "$fname_date" ]; then err "${f}: frontmatter date '${fm_date}' disagrees with the filename's '${fname_date}'." err " One of them was copied from a template and not edited; the log's ordering depends on both." summary "- decisions-log: **FAILED** (\`${f}\` — date disagrees with filename)" rc=1 continue fi # (4) the three required sections, each with a real body body_start="$(awk 'NR>1 && $0=="---" {print NR; exit}' "$f")" [ -n "$body_start" ] || body_start=1 for want_sec in "${REQUIRED_SECTIONS[@]}"; do hdr_line="$(awk -v s="$body_start" -v w="## ${want_sec}" 'NR>s && $0==w {print NR; exit}' "$f")" if [ -z "$hdr_line" ]; then err "${f}: missing the required section '## ${want_sec}'." err " CLAUDE.md §7 asks for what you changed, what you verified, and what you did NOT." err " All three are structure here, because the third is the one that gets dropped." summary "- decisions-log: **FAILED** (\`${f}\` — missing \`## ${want_sec}\`)" rc=1 continue fi # A real body: at least 20 non-blank, non-heading characters before the next '## '. nchars="$(awk -v s="$hdr_line" 'NR>s { if ($0 ~ /^## /) exit; print }' "$f" \ | tr -d '[:space:]' | wc -c)" if [ "$nchars" -lt 20 ]; then err "${f}: section '## ${want_sec}' is empty or a stub (${nchars} chars of content)." err " If nothing was verified, WRITE THAT — 'nothing was verified; the gate was not run'" err " is a valid and useful entry. A blank section is not." summary "- decisions-log: **FAILED** (\`${f}\` — \`## ${want_sec}\` is a stub)" rc=1 fi done done if [ "$rc" -eq 0 ]; then ok "decisions log: archive frozen, ${#entries[@]} entr$([ "${#entries[@]}" -eq 1 ] && echo y || echo ies) well-formed" summary "- decisions-log: **passed** (archive frozen; ${#entries[@]} entries well-formed)" else err "decisions log stage FAILED" fi exit "$rc"