-
Notifications
You must be signed in to change notification settings - Fork 1
tools/lint: no-directives-otto-prose advisory lint (Amara round-7 lexeme guard, diff-based) #825
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
1d86e0c
tools/lint: no-directives-otto-prose advisory lint (Amara round-7 lex…
AceHack 5df5171
fix(no-directives-lint): add SCOPE=worktree mode + test fixtures + po…
AceHack 43b0139
fix(no-directives-lint): 6 reviewer fixes — diff-hunk scoping, mktemp…
AceHack b30d5d0
fix(no-directives-lint): round-12 — diff-parser rewrite (FILE<TAB>CON…
AceHack File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
AceHack marked this conversation as resolved.
|
||
| # | ||
| # 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" | ||
|
AceHack marked this conversation as resolved.
|
||
|
|
||
| # 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)" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| ``` | ||
|
AceHack marked this conversation as resolved.
|
||
|
|
||
| ```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 | ||
| <!-- paired-edit: PR #N CURRENT-aaron §32 home-maker role + QoL self-care directive 2026-04-29 --> | ||
| ``` | ||
|
|
||
| (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. | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.