Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,32 @@ jobs:
# "administration permission").
run: actionlint -color -ignore 'unknown permission scope "administration"'

lint-tick-history-order:
# Validates that docs/hygiene-history/loop-tick-history.md
# rows appear in non-decreasing chronological order
# (specifically: the LAST row in the file is the latest
# timestamp). This catches the recurring row-ordering bug
# where the Edit tool's old_string=existing-line pattern
# inserts the new row BEFORE the matched line, producing
# reverse-chronological order. Aaron 2026-04-26 asked for
# structural prevention after I caught this bug at least
# three times across recent ticks. The check is the
# "fail-fast at commit/push time" mechanism that doesn't
# rely on each agent's vigilance — Otto-339 anywhere-means-
# anywhere applied to discipline-enforcement: enforce at
Comment on lines +328 to +333
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 (codebase convention): This workflow comment includes direct contributor name attribution ("Aaron …"). Repo operational rule is to avoid names on current-state surfaces like workflows/scripts and use role references (e.g., "human maintainer"), reserving names for the explicitly enumerated history surfaces (docs/AGENT-BEST-PRACTICES.md:284-347). Please rewrite these attributions here (and in the new hygiene scripts) to role-refs.

Copilot uses AI. Check for mistakes.
# the layer that catches all paths, not at the input-tool
# layer.
name: lint (tick-history order)
timeout-minutes: 2
runs-on: ubuntu-22.04

steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Run check-tick-history-order
run: tools/hygiene/check-tick-history-order.sh

lint-no-empty-dirs:
# Fail if a committed directory has no files — almost always a
# forgotten artefact (an agent-created skill folder without a
Expand Down
81 changes: 81 additions & 0 deletions tools/hygiene/append-tick-history-row.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
#
# tools/hygiene/append-tick-history-row.sh — appends a row to
# docs/hygiene-history/loop-tick-history.md using bash heredoc
# (which naturally produces chronological tail-append).
#
# Why this exists (Aaron 2026-04-26):
# The Edit-tool default with old_string=existing-line tends to
# insert NEW content BEFORE the matched line, producing
# reverse-chronological order. This script wraps the correct
# pattern (`cat >> file`) so the bug shape can't occur via this
# entrypoint.
#
# Usage:
# tools/hygiene/append-tick-history-row.sh "FULL_ROW_TEXT"
#
# The argument is the entire row including leading `| ` and
# trailing `|`. Caller is responsible for row content; this
# script is dumb-pipe.
#
# What this validates:
# - Argument starts with `| YYYY-MM-DDTHH:MM:SSZ (`
# - The timestamp is >= the latest existing row timestamp
# (otherwise reject — chronological discipline)
#
# What this does NOT do:
# - Does NOT format the row for you. The caller decides
# content (this is signal-in-signal-out per
# memory/feedback_signal_in_signal_out_clean_or_better_dsp_discipline.md)
# - Does NOT commit. The caller stages + commits.
#
# Composes with:
# - tools/hygiene/check-tick-history-order.sh (CI gate
# that catches violations from any append path, not
# just this one)

set -euo pipefail

if [[ $# -ne 1 ]]; then
echo "usage: $0 \"<full row text including leading | and trailing |>\"" >&2
exit 2
fi

ROW="$1"
REPO_ROOT="$(git rev-parse --show-toplevel)"
TICK_FILE="${REPO_ROOT}/docs/hygiene-history/loop-tick-history.md"

# Extract candidate timestamp from row
if [[ ! "$ROW" =~ ^\|\ ([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z) ]]; then
echo "ERROR: row must start with '| YYYY-MM-DDTHH:MM:SSZ '" >&2
echo "got: ${ROW:0:80}..." >&2
exit 1
fi
NEW_TS="${BASH_REMATCH[1]}"

# Find latest existing timestamp
LATEST_TS=$(
grep -oE '^\| [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z' "$TICK_FILE" \
| sed 's/^| //' \
| sort \
| tail -1
Comment on lines +57 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle empty timestamp history before computing latest row

The helper exits before appending when loop-tick-history.md contains zero ISO timestamp rows (for example, a freshly bootstrapped file or a post-archive reset with only headers). With set -euo pipefail, the grep in the LATEST_TS pipeline returns status 1 on no matches, which aborts the script before cat >> runs, so the tool cannot append the first valid row at all.

Useful? React with 👍 / 👎.

)

if [[ -n "$LATEST_TS" && "$NEW_TS" < "$LATEST_TS" ]]; then
echo "ERROR: new row timestamp $NEW_TS is BEFORE latest existing $LATEST_TS" >&2
echo "" >&2
echo "Tick-history is append-only with non-decreasing timestamps." >&2
echo "If your row is for a past tick, you have to either:" >&2
echo " (a) update the timestamp to current UTC (preferred)," >&2
echo " (b) file an ADR explaining the back-dated correction" >&2
echo " and use a correction-row pattern per Otto-229." >&2
exit 1
fi

# Append using heredoc (the whole point of this script — bash
# heredoc is the canonical chronological-tail-append pattern)
cat >> "$TICK_FILE" << EOF
$ROW
EOF
Comment on lines +75 to +79
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 (security): The heredoc uses an unquoted delimiter (<< EOF), so bash will perform expansion (incl. command substitution) on the here-doc body. If the provided row text ever contains $(...) / backticks / $var, it could execute or mutate content unexpectedly. Prefer appending via printf '%s\n' "$ROW" >> "$TICK_FILE", or use a single-quoted heredoc delimiter (<<'EOF') and emit the row without additional expansion.

Suggested change
# Append using heredoc (the whole point of this script — bash
# heredoc is the canonical chronological-tail-append pattern)
cat >> "$TICK_FILE" << EOF
$ROW
EOF
# Append the row literally so caller-provided content is treated
# as data, not shell syntax.
printf '%s\n' "$ROW" >> "$TICK_FILE"

Copilot uses AI. Check for mistakes.

echo "OK: appended row at $NEW_TS"
179 changes: 179 additions & 0 deletions tools/hygiene/check-tick-history-order.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
#!/usr/bin/env bash
#
# tools/hygiene/check-tick-history-order.sh — validates that
# docs/hygiene-history/loop-tick-history.md rows appear in
# non-decreasing chronological order (ISO-8601 UTC timestamps).
#
# Why this exists (Aaron 2026-04-26):
# The Edit tool's natural pattern (old_string=existing-line)
# tends to insert NEW content BEFORE the matched line, which
# produces reverse-chronological order when appending to the
# end of a tick-history table. I caught this bug at least three
# times across recent ticks and patched each occurrence by
# hand. Aaron asked: "anything we can do to prevent it in the
# first place?" The honest structural answer is a CI check that
# makes the bug fail fast at commit/push time instead of
# relying on each agent's vigilance.
#
# What this checks:
# - Every row matching the ISO-8601 timestamp prefix
# `| YYYY-MM-DDTHH:MM:SSZ (...)` is extracted in file order
# - Timestamps must be non-decreasing (allows duplicates from
# close ticks; forbids out-of-order)
# - Reports first violation with surrounding context and
# exits non-zero on failure
#
Comment on lines +18 to +25
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 (documentation): The file header and mid-file comments claim this script "validates" full non-decreasing order and "exits non-zero on failure", but the implementation only gates CI on the PRIMARY check (last row is the latest timestamp). Strict-order violations are advisory (and only printed when --strict is set). Please align the comments at the top and around the two-tier section with the actual behavior so readers don’t assume CI enforces full ordering.

Copilot uses AI. Check for mistakes.
# What this does NOT do:
# - Does NOT re-order rows automatically. The fix is the
# committer's responsibility (revert + re-append correctly).
# Auto-reordering would silently rewrite history; this check
# is intentionally advisory-with-teeth.
# - Does NOT validate row content beyond the timestamp.
# Markdownlint and other lints handle table structure.
# - Does NOT enforce a strict-increasing rule. Two ticks at
# the same UTC second are rare but possible and not an error.
#
# Composes with:
# - tools/hygiene/audit-tick-history-bounded-growth.sh
# (line-count threshold; this script is the order check
# that complements that one)
# - .github/workflows/gate.yml (wired as a lint job)
#
# Self-test:
# $ tools/hygiene/check-tick-history-order.sh
# → exit 0 if order is fine
# → exit 1 with diagnostic if any row is out of order

set -euo pipefail

# --strict: also report (advisory) historical strict-order violations
# anywhere in the file. Default is quiet because Otto-229 forbids
# editing prior rows so historical disorder cannot be repaired —
# reporting it on every CI run is noise. Aaron 2026-04-26: "we
# might should allow this one override if it exists a lot."
Comment on lines +49 to +53
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (documentation/behavior mismatch): This comment says the default mode is "quiet" on historical strict-order violations to avoid CI noise, but later the script still prints an advisory summary when violations > 0 even without --strict. Either change the behavior to be fully quiet in default mode, or adjust this comment to match the actual output contract.

Suggested change
# --strict: also report (advisory) historical strict-order violations
# anywhere in the file. Default is quiet because Otto-229 forbids
# editing prior rows so historical disorder cannot be repaired —
# reporting it on every CI run is noise. Aaron 2026-04-26: "we
# might should allow this one override if it exists a lot."
# --strict: report (advisory) historical strict-order violations
# anywhere in the file with full detail. Default mode does not
# fail on that historical disorder, but it may still print a brief
# advisory summary when such violations exist. Otto-229 forbids
# editing prior rows, so repeated historical findings are mostly
# noise unless explicitly requested. Aaron 2026-04-26: "we might
# should allow this one override if it exists a lot."

Copilot uses AI. Check for mistakes.
STRICT=0
ARGS=()
for arg in "$@"; do
case "$arg" in
--strict) STRICT=1 ;;
*) ARGS+=("$arg") ;;
esac
done

REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
TICK_FILE="${ARGS[0]:-${REPO_ROOT}/docs/hygiene-history/loop-tick-history.md}"

if [[ ! -f "$TICK_FILE" ]]; then
echo "ERROR: tick-history file not found at $TICK_FILE" >&2
exit 2
fi

# Extract row line-numbers + timestamps. Match table rows that
# start with `| YYYY-MM-DDTHH:MM:SSZ`. The ISO-8601 timestamps
# are lex-sortable, which is the whole point of this format.
# Use `while read` instead of `mapfile -t` for bash-3 (macOS).
# Avoid `awk -F:` because ISO timestamps contain `:` themselves
# — extract the timestamp via the line number then a sed pass.
rows=()
while IFS= read -r line_num; do
ts=$(sed -n "${line_num}p" "$TICK_FILE" \
| grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z' \
| head -1)
rows+=("${line_num}|${ts}")
done < <(
grep -nE '^\| [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z' "$TICK_FILE" \
| cut -d: -f1
)

if [[ ${#rows[@]} -lt 2 ]]; then
echo "OK: tick-history has ${#rows[@]} row(s); nothing to check"
exit 0
fi

prev_ts=""
prev_line=""
violations=0
for entry in "${rows[@]}"; do
line_num="${entry%%|*}"
ts="${entry##*|}"
if [[ -n "$prev_ts" ]]; then
# ISO-8601 UTC timestamps sort lexically — string comparison
# is the correct chronological comparison.
if [[ "$ts" < "$prev_ts" ]]; then
violations=$((violations + 1))
if [[ $STRICT -eq 1 ]]; then
echo "VIOLATION: row at line $line_num has timestamp $ts" >&2
echo " but previous row at line $prev_line has timestamp $prev_ts" >&2
echo " (timestamps must be non-decreasing in file order)" >&2
echo "" >&2
echo " context — offending row tail:" >&2
sed -n "${line_num}p" "$TICK_FILE" | cut -c 1-200 | sed 's/^/ /' >&2
echo "" >&2
echo " context — preceding row tail:" >&2
sed -n "${prev_line}p" "$TICK_FILE" | cut -c 1-200 | sed 's/^/ /' >&2
echo "" >&2
fi
fi
fi
prev_ts="$ts"
prev_line="$line_num"
done

# Two-tier check:
# STRICT — full chronological order (reports historical
# violations; advisory; not gating because Otto-229
# forbids editing prior rows so we can't fix history)
# PRIMARY — last row in file must be latest timestamp.
# This catches the specific bug pattern: "Edit tool
# inserts new row BEFORE last row" — exactly the one
# we're trying to prevent.
#
# We always report STRICT violations for visibility but only
# fail the build on the PRIMARY check. The PRIMARY check is
# strong enough to prevent the bug without requiring
# history-rewrite (which Otto-229 forbids anyway).

last_entry="${rows[$((${#rows[@]} - 1))]}"
last_line="${last_entry%%|*}"
last_ts="${last_entry##*|}"

# Find the latest timestamp ANYWHERE in the file
latest_ts=""
for entry in "${rows[@]}"; do
ts="${entry##*|}"
if [[ -z "$latest_ts" || "$ts" > "$latest_ts" ]]; then
latest_ts="$ts"
fi
done

if [[ "$last_ts" != "$latest_ts" ]]; then
echo "" >&2
echo "FAIL: last row in tick-history is NOT the latest timestamp" >&2
echo " last row (line $last_line): $last_ts" >&2
echo " latest timestamp in file: $latest_ts" >&2
echo "" >&2
echo "This is the row-ordering bug pattern: a new row was inserted" >&2
echo "BEFORE the previous last row instead of appended at end-of-file." >&2
echo "" >&2
echo "How to fix:" >&2
echo " 1. Revert the offending append (git restore on the file)" >&2
echo " 2. Re-append using bash heredoc (cat >> file << EOF) which" >&2
echo " naturally produces chronological-tail-append, not Edit" >&2
echo " tool with old_string=earlier-row (which prepends)" >&2
echo " 3. Or use tools/hygiene/append-tick-history-row.sh which" >&2
echo " wraps the correct pattern in a one-liner" >&2
if [[ $violations -gt 0 ]]; then
echo "" >&2
echo "Note: $violations historical strict-order violation(s) also exist" >&2
echo " (advisory only — Otto-229 forbids editing prior rows)" >&2
fi
exit 1
fi

if [[ $violations -gt 0 ]]; then
echo "OK: last row IS latest timestamp ($last_ts at line $last_line)"
echo " — but $violations historical strict-order violation(s) exist (advisory)"
else
echo "OK: ${#rows[@]} tick-history rows in non-decreasing chronological order"
fi
exit 0
Loading