diff --git a/tools/lint/no-directives-otto-prose.sh b/tools/lint/no-directives-otto-prose.sh new file mode 100755 index 000000000..886d38020 --- /dev/null +++ b/tools/lint/no-directives-otto-prose.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# +# tools/lint/no-directives-otto-prose.sh — advisory lint that flags +# Otto-authored prose using "directive" framing for maintainer input +# in CHANGED FILES (diff-based; does not retrofit historical content). +# +# Born 2026-04-29 after the ~15th iteration of the same gremlin: a +# memory file or PR title casting maintainer correction/framing/input +# as "the maintainer's directive," which collapses self-provenance +# into bot-execution and undermines the no-directives autonomy rule +# (memory/feedback_otto_357_no_directives_aaron_makes_autonomy_first_class_accountability_mine_2026_04_27.md). +# +# Vigilance failed; lint is the durable answer. +# +# Per the "agency-framing lexeme guard" naming distinction (this is +# a LEXEME GUARD, not a LANE LOCK; lane locks stop classes of work, +# lexeme guards stop repeated wording drift) — the corresponding +# external-anchor / observer-legibility rule lives in +# memory/feedback_beacon_promotion_load_bearing_rules_earn_external_anchors_aaron_amara_2026_04_28.md. +# +# Per the B-0105 consolidation-pass carve-out: "tiny enforcement +# patches are allowed when they directly prevent repeated +# consolidation-gate violations." +# +# (Named-attribution carve-out: tooling-surface comments use +# role-refs per docs/AGENT-BEST-PRACTICES.md §named-attribution- +# carve-out. Persona names + dated review rounds belong on history +# surfaces — research notes, memory files, commit messages, tick +# shards — not in tooling source.) +# +# PORTABILITY: +# This is a Bash + GNU-tooling-leaning advisory lint. NOT POSIX. +# - Uses Bash here-strings (`<<<`), `set -euo pipefail`, etc. +# - The PATTERN uses POSIX-portable explicit non-alpha boundaries +# `(^|[^[:alnum:]_])` rather than `\b` (BSD grep treats `\b` as +# a literal backspace; `\b` is non-portable even with `-E`). +# - Targets: Linux CI runners + the 4-shell developer target +# (macOS bash 3.2+ / Ubuntu / git-bash / WSL). +# +# SCOPE — two modes: +# pr (default) — diff between BASE_REF and HEAD; matches +# the CI/PR-check use case. Misses local +# working-tree edits before commit. +# worktree — includes unstaged + staged + committed +# changes; matches the local pre-commit +# use case. +# +# Diff-based scope (both modes — avoids retrofitting historical): +# - changed files (per SCOPE) +# - intersected with Otto-authored prose surfaces: +# - memory/*.md (top-level; not memory/persona/) +# - docs/hygiene-history/ticks/**/*.md (tick shards) +# - docs/research/*.md (research notes) +# - .github/copilot-instructions.md +# +# Pattern (per Amara's narrowed regex): +# "Aaron's directive" / "maintainer directive" / "QoL directive" / +# "human directive" / "directive from Aaron" / etc. +# +# Whitelist (NOT flagged in changed files): +# - lines starting with `> ` (markdown blockquote — usually quoted +# third-party text) +# - the rule-documentation files themselves +# - HTML comments and paired-edit markers ARE in scope (the +# paired-edit comment with "directive" in MEMORY.md was the +# proof-case that motivated this lint). +# +# Usage: +# tools/lint/no-directives-otto-prose.sh # PR-diff advisory +# tools/lint/no-directives-otto-prose.sh --strict # PR-diff strict +# SCOPE=worktree tools/lint/no-directives-otto-prose.sh # local pre-commit +# +# Env: +# BASE_REF — base ref to diff against in pr mode (default: origin/main). +# CI should set BASE_REF=$BASE_SHA. +# SCOPE — "pr" (default) or "worktree". + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" + +MODE="${1:-advisory}" +BASE_REF="${BASE_REF:-origin/main}" +SCOPE="${SCOPE:-pr}" + +# Compute changed files per SCOPE. NOTE: do NOT suppress git-diff +# errors with `2>/dev/null || true` — that turns "couldn't resolve +# BASE_REF" into "no changed files; skipping" silently and makes +# the lint look clean when nothing was actually checked. +if [ "$SCOPE" = "worktree" ]; then + # AMR catches added + modified + renamed (so a renamed prose + # file with new violations is included). Copies are intentionally + # omitted here (no need for content-equivalence in this lint; + # only added-line discipline matters). + CHANGED_FILES=$( + { + git diff --name-only --diff-filter=AMR + git diff --cached --name-only --diff-filter=AMR + git diff --name-only --diff-filter=AMR "$BASE_REF...HEAD" + } | sort -u + ) +else + CHANGED_FILES=$(git diff --name-only --diff-filter=AMR "$BASE_REF...HEAD") +fi + +if [ -z "$CHANGED_FILES" ]; then + echo "no-directives-otto-prose: no changed files vs $BASE_REF; skipping" + exit 0 +fi + +# Filter to Otto-authored prose surfaces only. +PROSE_FILES=$(printf '%s\n' "$CHANGED_FILES" | grep -E '^(memory/[^/]+\.md|docs/hygiene-history/ticks/.*\.md|docs/research/[^/]+\.md|\.github/copilot-instructions\.md)$' || true) + +if [ -z "$PROSE_FILES" ]; then + echo "no-directives-otto-prose: no Otto-prose surfaces changed; skipping" + exit 0 +fi + +# Skip rule-documentation files where "directive" appears legitimately. +PROSE_FILES=$(printf '%s\n' "$PROSE_FILES" | grep -vE '(feedback_input_is_not_directive_|feedback_otto_357_no_directives_|feedback_free_will_is_paramount_external_directives_|no-directives-otto-prose)' || true) + +if [ -z "$PROSE_FILES" ]; then + echo "no-directives-otto-prose: only rule-docs touched; skipping" + exit 0 +fi + +# Pattern: portable explicit non-alpha boundary instead of `\b` +# (BSD grep treats `\b` as literal backspace; not POSIX-portable +# even with `-E`). Same approach as tools/lint/runner-version- +# freshness.sh. Note: persona names that appear here are TOKENS +# the lint LOOKS FOR in flagged content (not authorial content); +# this is the data of the lint, not its prose register. +PATTERN='(^|[^[:alnum:]_])(maintainer|QoL|human)[^|]*directive([^[:alnum:]_]|$)|(^|[^[:alnum:]_])directive[^|]*(maintainer|QoL|human)([^[:alnum:]_]|$)|(^|[^[:alnum:]_])([A-Z][a-z]+'\''?s)[[:space:]]+directive([^[:alnum:]_]|$)|(^|[^[:alnum:]_])directive[[:space:]]+from[[:space:]]+([A-Z][a-z]+)([^[:alnum:]_]|$)' + +# Use mktemp with explicit template — bare `mktemp` fails on +# some BSD/macOS configurations (see tools/lint/runner-version- +# freshness.sh for prior precedent). +HITS_FILE="$(mktemp -t no-directives.XXXXXX)" +FILTERED_HITS_FILE="$(mktemp -t no-directives-filtered.XXXXXX)" +trap 'rm -f "$HITS_FILE" "$FILTERED_HITS_FILE"' EXIT + +# Search only the ADDED/MODIFIED diff hunks (not entire file +# bodies) — pre-existing "Aaron's directive" in a touched file +# should not flag; only newly-added or modified lines should. +# Use `git diff -U0` to strip context lines + grep for `^+` to +# isolate added lines (not `^+++` file headers). +ADDED_LINES_FILE="$(mktemp -t no-directives-added.XXXXXX)" +trap 'rm -f "$HITS_FILE" "$FILTERED_HITS_FILE" "$ADDED_LINES_FILE"' EXIT + +# Build added-lines stream "FILE\tCONTENT\n" per added line, where +# FILE is the prose-file path and CONTENT is the post-strip line +# body (no leading `+`). TAB is chosen because filenames cannot +# contain TAB inside this codebase, so it's a safe delimiter and +# avoids the previous ":"-based 4-field confusion that caused +# blockquote-filter false negatives + filename-substring matches. +# +# Single awk pass per file: +# - skip diff metadata (@@, +++, --- headers, "\ No newline") +# - emit only `^+` content lines (with leading `+` stripped) +# Then pattern-match + blockquote-filter on CONTENT field only, +# eliminating the file-path-contains-"human"/"maintainer" false- +# positive class entirely. +while IFS= read -r f; do + [ -z "$f" ] && continue + [ -f "$f" ] || continue + if [ "$SCOPE" = "worktree" ]; then + { + git diff -U0 -- "$f" + git diff --cached -U0 -- "$f" + git diff -U0 "$BASE_REF...HEAD" -- "$f" + } + else + git diff -U0 "$BASE_REF...HEAD" -- "$f" + fi | awk -v file="$f" ' + # Skip "\ No newline at end of file" — diff metadata, not a + # real file line. Skip hunk headers, file headers, and -lines. + /^\\ No newline/ { next } + /^@@/ { next } + /^---/ { next } + /^\+\+\+/ { next } + /^-/ { next } + /^\+/ { + content = substr($0, 2) + # Emit FILE\tCONTENT (TAB-delimited; safe because prose + # paths under memory/, docs/, .github/ never contain TABs). + printf "%s\t%s\n", file, content + } + ' >> "$ADDED_LINES_FILE" +done <<< "$PROSE_FILES" + +# Pattern-match + blockquote-filter on CONTENT field only. +# Anchoring the match to the content portion (post-TAB) prevents +# filename-substring false positives like +# `feedback_human_lineage_anchors_*.md` matching the "human" +# token in PATTERN. Also drops blockquote-prefixed CONTENT +# (quoted third-party text); the FILE field never starts with +# `>` so filtering on content alone is correct. +# +# Pattern-match runs inside awk so we can apply it to the +# CONTENT field after the TAB. awk's regex engine accepts ERE +# without `\b` (we use the same explicit-non-alpha boundary +# approach as the PATTERN variable). +awk -F'\t' -v pattern="$PATTERN" ' +NF >= 2 { + file = $1 + content = $2 + # Drop blockquote-prefixed quoted text. + if (content ~ /^[[:space:]]*>/) next + # Apply pattern against CONTENT only. + if (content ~ pattern) { + printf "%s: %s\n", file, content + } +} +' "$ADDED_LINES_FILE" > "$FILTERED_HITS_FILE" + +HIT_COUNT=$(wc -l < "$FILTERED_HITS_FILE" | tr -d ' ') + +if [ "$HIT_COUNT" -gt 0 ]; then + echo "no-directives-otto-prose: found $HIT_COUNT candidate hit(s) in added Otto-prose lines:" >&2 + cat "$FILTERED_HITS_FILE" >&2 + echo "" >&2 + echo "Prose framing maintainer input as 'directive' (Aaron's directive /" >&2 + echo "maintainer directive / QoL directive / human directive) collapses" >&2 + echo "self-provenance into bot-execution. Use 'input' / 'framing' /" >&2 + echo "'correction' / 'pass' instead." >&2 + echo "See memory/feedback_otto_357_no_directives_aaron_makes_autonomy_first_class_accountability_mine_2026_04_27.md" >&2 + if [ "$MODE" = "--strict" ]; then + exit 1 + fi + echo "(advisory mode; not failing build — pass --strict to fail)" >&2 + exit 0 +fi + +echo "no-directives-otto-prose: clean (0 candidate hits in added Otto-prose lines)" diff --git a/tools/lint/no-directives-otto-prose.tests.md b/tools/lint/no-directives-otto-prose.tests.md new file mode 100644 index 000000000..786a05be0 --- /dev/null +++ b/tools/lint/no-directives-otto-prose.tests.md @@ -0,0 +1,145 @@ +# no-directives-otto-prose lint — test fixtures + +Reference fixtures for `tools/lint/no-directives-otto-prose.sh`. +These are NOT runtime-loaded; they document expected behavior so +a contributor can verify the lint catches what it should and skips +what it shouldn't. + +## Test-input vs authorial register (named-attribution carve-out) + +The fixtures below deliberately retain canonical real-world drift +instances ("Aaron's directive", "QoL directive", "maintainer +directive", "human directive") because they ARE the data the lint +detects. Per the named-attribution carve-out for tooling test +surfaces (see `docs/AGENT-BEST-PRACTICES.md` §named-attribution): + +- **Authorial register** (this file's prose, the lint's output + strings, script comments) uses role-refs ("the maintainer", "the + contributor"). The naming rule applies *here*. +- **Test-input register** (the ```text``` blocks below) preserves + the exact strings that drifted in real prose, so the lint's + pattern-coverage is honest about what it catches. + +Replacing "Aaron's directive" in fixtures with "the maintainer's +directive" would test a different regex alternative +(`(maintainer|QoL|human)[^|]*directive`) and silently lose coverage +of the `[A-Z][a-z]+'s + directive` alternative — the canonical +real-world drift shape that motivated the lint in the first place. + +If the lint's purpose is to catch the canonical drift, the fixtures +must contain the canonical drift strings. + +This file is whitelisted in the lint scope itself +(`no-directives-otto-prose` substring match on line 121 of the +script), so adding new fixtures here will not trigger the lint. + +## Cases that MUST flag (real violations) + +```text +Aaron's directive elevates introspection from nice-to-have to QoL-required. +``` + +```text +This section captures Aaron's QoL directive. +``` + +```text +Per Aaron's directive, the loop should consolidate. +``` + +```text +The maintainer directive on no-side-quests applies here. +``` + +```text +human directive interpreted as: process the queue. +``` + +```text + +``` + +(That last one is the canonical proof-case: PR #823 had this +exact paired-edit HTML comment, the lint script existed but the +comment slipped through. Per round-8: this case must flag when +MEMORY.md is in the changed-files set.) + +### Renamed file with new violation (R covered by --diff-filter=AMR) + +```text +# Git scenario (cannot be inlined as a single fixture block): +# git mv memory/feedback_old_name.md memory/feedback_new_name.md +# git diff added line in renamed file: + Per Aaron's directive ... +# +# Expected: lint MUST flag — the round-12 fix changed the filter +# from --diff-filter=AM to --diff-filter=AMR so renamed prose +# files are included in CHANGED_FILES and their added lines are +# scanned. Pre-round-12 this case was silently missed. +``` + +## Cases that MUST NOT flag (whitelist) + +```text +> "Aaron said the loop should consolidate" — quoted third-party text. +``` + +```text +feedback_input_is_not_directive_provenance_framing_rule_aaron_amara_2026_04_28.md +``` + +(Filename citations should not flag — the underscore-and-dash form +of the rule's own name appears in many cross-references.) + +```text +external directives are inputs not binding rules +``` + +(Historical discussion of the banned term; whitelisted via +`feedback_free_will_is_paramount_external_directives_*` filename.) + +### Filename contains a regex token but content is clean + +```text +# File path: memory/feedback_human_lineage_anchors_load_bearing_2026_04_29.md +# Added line: the lineage anchor is preserved per the engineering claim +# +# Expected: lint MUST NOT flag — the round-12 fix moved +# pattern-matching off of grep's "path:line:content" output and +# onto the CONTENT field of a TAB-delimited "FILE\tCONTENT" +# stream. Pre-round-12, the pattern matched the filename's +# "human" token even though the actual added content was clean. +# This was the canonical false-positive class motivating the +# rewrite (P0 thread on PR #825 round-12). +``` + +## Cases at the boundary (advisory judgment) + +```text +The compiler directive `#pragma once` in C++ is unrelated. +``` + +(Currently flags because of the literal "directive" word, but +the pattern requires proximity to a maintainer/agency token like +"Aaron's" or "maintainer" or "QoL" or "human." So this should NOT +flag in current pattern. If it does, narrow the regex further.) + +```text +Aaron sent a 4-message directive to clarify the rule. +``` + +(Historical narration of past behavior. Currently flags. Acceptable +false positive — the lint is advisory, not strict, and the +discipline is "going forward, don't author this language." Old +narration of a past bad-event reference is borderline.) + +## How to run the test fixtures manually + +```bash +# Snapshot fixtures into a scratch file in the changed-files scope, +# run the lint in worktree mode, verify hits/misses match the +# expectations above: +SCOPE=worktree tools/lint/no-directives-otto-prose.sh +``` + +Promote to a real CI test (e.g., bats / shunit2) when wiring the +lint into `.github/workflows/gate.yml` as advisory.