From cf4e2f398d328be68e0d217021b448b094e78af5 Mon Sep 17 00:00:00 2001 From: Robert Blust Date: Sat, 5 Sep 2026 08:12:50 +0200 Subject: [PATCH] Conventions v1.3.2: an agent file, and a conventions job This repository had no AGENTS.md. `conventions-sync` created one from the shared block, pinned to robertblust/conventions@v1.3.2 in conventions.json, with a short own section naming the five-part shape a note takes and the two jobs the ruleset on main will require. CLAUDE.md is the four-line vendor adapter every member carries. The prose check found six hits, all the author's own words rather than quotes or product names: `## Licence`, `licence` and `artefact` in README.md, and `neighbourhood` and two instances of `honour(s)` in the ElevenLabs note, now spelled the American way the check expects. The README's Checking section gained one sentence naming what the new `conventions` job holds to its release and to WRITING.md. `.github/workflows/verify.yml` moves from `actions/checkout@v4` to `@v5`; v4 targets a Node version the runners now flag as deprecated, and nothing else in that file changes. Verified: verify.py, conventions-sync check and conventions-check all pass. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/conventions.yml | 8 ++ .github/workflows/verify.yml | 2 +- AGENTS.md | 30 +++++ CLAUDE.md | 4 + README.md | 10 +- conventions.json | 1 + conventions/AGENTS.md | 35 ++++++ conventions/REPOSITORIES.md | 49 ++++++++ conventions/WORKING.md | 99 ++++++++++++++++ conventions/WRITING.md | 141 +++++++++++++++++++++++ conventions/conventions-check | 88 ++++++++++++++ conventions/conventions-sync | 141 +++++++++++++++++++++++ conventions/manifest.json | 12 ++ notes/elevenlabs-clip-ends-on-a-click.md | 6 +- 14 files changed, 618 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/conventions.yml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 conventions.json create mode 100644 conventions/AGENTS.md create mode 100644 conventions/REPOSITORIES.md create mode 100644 conventions/WORKING.md create mode 100644 conventions/WRITING.md create mode 100755 conventions/conventions-check create mode 100755 conventions/conventions-sync create mode 100644 conventions/manifest.json diff --git a/.github/workflows/conventions.yml b/.github/workflows/conventions.yml new file mode 100644 index 0000000..5316214 --- /dev/null +++ b/.github/workflows/conventions.yml @@ -0,0 +1,8 @@ +name: conventions +on: + push: + branches: [main] + pull_request: +jobs: + conventions: + uses: robertblust/conventions/.github/workflows/check.yml@v1.3.2 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index bb22704..7461613 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -10,5 +10,5 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - run: python3 verify.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ec2e484 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ + +Shared conventions of the robertblust, guestgraph and companygraph organizations live in +`conventions/`, vendored from robertblust/conventions at the release `conventions.json` +names. Read them before writing or committing anything here. + +- `conventions/WRITING.md` — how we write: one voice, three registers, English and German. +- `conventions/WORKING.md` — how we work with git and GitHub. +- `conventions/REPOSITORIES.md` — the family: what each repository is and what pins what. + +Everything below this block is this repository's own. `sh conventions/conventions-sync check` +says whether the copy matches the release, `sync` brings it to the release the pin names, and +`sh conventions/conventions-check` holds this repository's own Markdown to `WRITING.md`. Edit +a shared file in robertblust/conventions, never here. + + +# robertblust/field-notes — working conventions + +Problems that took real work to understand, one file per problem, in the five-part shape the +README gives: symptom, what it was not, root cause, fix, how it was verified. The README is +the manual for writing one; this file is only what an agent needs before touching anything +here. + +## Checks + +Two jobs, both required by the ruleset on `main`: `verify`, which runs `python3 verify.py` and +resolves every relative link in every note, and `conventions`, called from +robertblust/conventions at the pinned tag and shown by GitHub as `conventions / conventions`. +External URLs are not fetched on purpose; the README says why. Everything about how to write +and how to work with git is in `conventions/`. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2af4fd7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ +@AGENTS.md +@conventions/WRITING.md +@conventions/WORKING.md +@conventions/REPOSITORIES.md diff --git a/README.md b/README.md index cd737d6..40278b9 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,12 @@ answer is obvious once you have it and the wrong turn is not. `python3 verify.py` resolves every relative link in every note. External URLs are deliberately not fetched — a checker that hits the network fails on someone else's outage, -and a suite that cries wolf is one people stop reading. CI runs the same command. +and a suite that cries wolf is one people stop reading. CI runs the same command. The +`conventions` job, called from robertblust/conventions at the pinned tag, holds the vendored +copy to its release and every Markdown file here to `conventions/WRITING.md`. -## Licence +## License -[CC BY 4.0](LICENSE). Use it, quote it, build on it; credit it. The prose is the artefact -here, which is why this is a content licence rather than the Apache 2.0 the code +[CC BY 4.0](LICENSE). Use it, quote it, build on it; credit it. The prose is the artifact +here, which is why this is a content license rather than the Apache 2.0 the code repositories carry. diff --git a/conventions.json b/conventions.json new file mode 100644 index 0000000..926ed73 --- /dev/null +++ b/conventions.json @@ -0,0 +1 @@ +{ "repo": "robertblust/conventions", "tag": "v1.3.2" } diff --git a/conventions/AGENTS.md b/conventions/AGENTS.md new file mode 100644 index 0000000..e0150c9 --- /dev/null +++ b/conventions/AGENTS.md @@ -0,0 +1,35 @@ + +Shared conventions of the robertblust, guestgraph and companygraph organizations live in +`conventions/`, vendored from robertblust/conventions at the release `conventions.json` +names. Read them before writing or committing anything here. + +- `conventions/WRITING.md` — how we write: one voice, three registers, English and German. +- `conventions/WORKING.md` — how we work with git and GitHub. +- `conventions/REPOSITORIES.md` — the family: what each repository is and what pins what. + +Everything below this block is this repository's own. `sh conventions/conventions-sync check` +says whether the copy matches the release, `sync` brings it to the release the pin names, and +`sh conventions/conventions-check` holds this repository's own Markdown to `WRITING.md`. Edit +a shared file in robertblust/conventions, never here. + + +# robertblust/conventions — working conventions + +This repository is the source of the block above. It mirrors the layout it vendors: the +shared files live under `conventions/` here exactly as they do in every member, so the block +reads the same in both. The block is plain words and names no agent vendor; `CLAUDE.md` is the +one vendor adapter, four lines that import the entry file and the three shared files in that +vendor's syntax, and a member carries the same four lines. The one file members receive that +does not sit under `conventions/` in the source is this `AGENTS.md`, which the script fetches +from the root and vendors as `conventions/AGENTS.md` so that `check` can compare a member's +block against the release without a network. + +Releasing is a tag and a GitHub Release with notes. Before tagging, set the version in the +first line of this file to the new tag: the script rewrites it to the pin on sync, so a stale +number here misleads only a reader of the source, but that reader is the one deciding whether +to release. + +The tests are `sh test/run.sh`, which runs both scripts against temporary members with this +checkout as the source, and `sh conventions/conventions-check` over this checkout itself, with +`docs/superpowers/` excluded because a spec or plan quotes the very list it scans for. +`.superpowers/` is excluded too, as tooling scratch that is not prose. diff --git a/conventions/REPOSITORIES.md b/conventions/REPOSITORIES.md new file mode 100644 index 0000000..1bd210d --- /dev/null +++ b/conventions/REPOSITORIES.md @@ -0,0 +1,49 @@ +# Repositories + +Three organizations, one family. `robertblust` holds the person and the shared machinery, +`guestgraph` the guest identity graph, `companygraph` the meta-model for operating a company. +Every repository below vendors this repository's `conventions/` at a pinned release and opens +its `AGENTS.md` with the same block; `CLAUDE.md` is the same four-line vendor adapter +everywhere. + +| Repository | Purpose | Default branch | Local path | +|---|---|---|---| +| robertblust/conventions | how the family writes and works, vendored by every member | main | ~/git/robertblust/conventions | +| robertblust/design | the design system shared by the three sites: tokens, chrome, page checks | main | ~/git/robertblust/design | +| robertblust/robertblust.github.io | blust.ch, the profile page and two talks | main | ~/git/robertblust/robertblust.github.io | +| robertblust/mental-model | Robert Blust described in CompanyGraph, the reference instance | main | ~/git/robertblust/mental-model | +| robertblust/field-notes | problems that took real work to understand, one file each | main | ~/git/robertblust/field-notes | +| guestgraph/guestgraph.github.io | guestgraph.io, the landing page and the intro talk | main | ~/git/guestgraph/guestgraph.github.io | +| guestgraph/engine | identity resolution, guest graph and REST API, the open core | main | ~/git/guestgraph/engine | +| guestgraph/.github | the organization profile GitHub shows, and nothing else | main | ~/git/guestgraph/.github | +| companygraph/companygraph.github.io | companygraph.io, the landing page, the model and example pages, the intro talk | main | ~/git/companygraph/companygraph.github.io | +| companygraph/meta-model | the meta-model: core vocabulary, packs and the conventions that make a graph of Markdown checkable | main | ~/git/companygraph/meta-model | +| companygraph/.github | the organization profile GitHub shows, and nothing else | main | ~/git/companygraph/.github | + +## The list is the scope + +What is listed here is the family; what is not listed is outside it. An agent working in a +member reads, links and reasons within this list, and does not reach for a repository, a +directory or a file outside it on its own — not for context, not for an example, not because +it sits beside a member on the same disk. When a task needs something outside the list, the +task says so, names it, and names the one purpose it serves; that reference belongs to that +task and does not bring the thing into the family. + +## What pins what + +The three sites pin `robertblust/design` by tag in `package.json`, and `npm run design` +writes the fenced copies. blust.ch pins `robertblust/mental-model` and companygraph.io pins +`companygraph/meta-model` by commit in `source.json`, and each builds its model pages from +that commit. blust.ch also depends on `companygraph/meta-model` by tag for the instance +parser. mental-model vendors meta-model's `core/` at a release named in its own manifest. +Every member pins this repository by tag in `conventions.json`. + +A pin is an editorial line, moved on purpose. Which release each member is on is read from +the pin, never from this file, so this file does not repeat versions. + +## Re-syncing after a release + +In this order, one pull request each: design, then the three sites, then mental-model and +meta-model, then the engine, then field-notes, then the two `.github` repositories. Design +first because a site's suite runs design's checks; the models before the engine because the +sites' model pages are built from them. Nothing here opens those pull requests for you. diff --git a/conventions/WORKING.md b/conventions/WORKING.md new file mode 100644 index 0000000..7f27f12 --- /dev/null +++ b/conventions/WORKING.md @@ -0,0 +1,99 @@ +# Working + +How the family acts with git and GitHub. Each rule carries its reason, because a rule +without its reason is the first thing a fresh clone drops. + +## Branches and commits + +One branch per change, named for what it does, branched from the default branch. Nothing is +committed on the default branch directly; it is protected in every repository that has a +suite, and a ruleset that forbids a push is the only kind that survives a hurried afternoon. + +A branch is deleted once its pull request is merged. The merge commit is its record; a branch +left standing is a question every reader of the branch list has to answer again. + +An agent commits when the owner asks, and not on its own initiative. It proposes the message +in the git register of `WRITING.md`. The author of the commit is the person. A tool that +co-authored the change is named in a `Co-Authored-By` trailer, whichever tool it was, so the +history says who and what wrote it. “Commit and open the pull request” is a request to do +exactly that; it is not approval to merge. + +## Pull requests + +Every change reaches the default branch through a pull request with one green status check. +The description is the commit body reread for a reviewer who has not seen the diff. + +**A pull request is merged with a merge commit, `gh pr merge --merge`, never squashed.** +GitHub re-authors a squash commit to the account that pressed the button, so a commit made +locally under the wrong identity would land on the default branch looking correct. A merge +commit preserves the author it was given, which is the point: a wrong identity surfaces +instead of being laundered. + +Merging is a decision the owner makes. An agent opens the pull request, reports the check, and +stops; it merges when told to, and the word for that is the owner's, not inferred from an +earlier one. + +## Identity + +A commit is authored by the person who made it, under the address they mean to be known by, +and the merge commit carries that address to the default branch unchanged. That is the whole +rule, and it holds for a contributor from outside exactly as it holds for the owner; nothing +here asks a contributor to be anyone but themselves. + +Nothing on GitHub enforces an address, and nothing should: the ruleset rule that could, an +author-email pattern, would shut out every outside contributor. So each person's identity is +their own `git config` to keep. The owner's is `robert.blust@flatland.ch` in all three +organizations, keyed by `includeIf` blocks in `~/.gitconfig` to the directories +`~/git/robertblust/`, `~/git/guestgraph/` and `~/git/companygraph/`; a clone made anywhere +else takes the global default and gives no warning. Before the first commit in a fresh clone, +whoever you are, run `git config user.email` and read the answer. + +## Releases and pins + +This holds for every repository in the family that another one takes from, whatever it +provides: a design system, a model, a parser, a set of shared files. + +A release is a tag and a GitHub Release with notes in the prose register: what changed for the +consumer, what breaks, how to take it. There is no publish step anywhere in the family; the tag +is the release. + +Everything one repository takes from another is pinned by a visible line in the taking +repository, in whatever form its tooling gives it — a tag in a package file, a commit in a +source file, a release in a vendoring manifest. Pins are editorial. They move when the owner +decides they move, in a commit that says why, and no bot proposes them; a pin that is behind is +intent until the owner says it is drift. + +A change to anything another repository vendors or builds from is at least a minor release, +because it makes every copy stale. A change that asks the taking repository to do anything +beyond re-syncing or re-pinning is a major. The notes say which. + +## Checks + +Verification is running the suite, not reading the diff. Nothing is called done, fixed or +passing until the command that proves it has run and its output has been read; a pipe into +`tail` hides an exit code, so the exit code is checked on its own. + +A branch ruleset requires a status check by its job id, not by the workflow's name. Renaming +the job leaves the ruleset requiring a name that will never report again: the branch looks +protected and is not. Each repository names its required job id in its own agent file; rename +one only together with its ruleset. + +Every member's ruleset requires the `conventions` job beside the job that runs its own +suite; a repository without a suite requires it alone. That job holds the vendored copy against +its release and the repository's own Markdown against `WRITING.md`, and it is the same job +everywhere because it is called from one place. + +CI never writes what the repository commits. Rendered cards, exported PDFs and generated +pages are built locally and committed; CI checks that the committed copy matches what would +be built. + +## Reviews + +A review finding is an input to the person who merges, never a verdict. One finding per +comment, with a severity and the line it sits on. Silence is a valid answer to a finding. + +## What is never written + +Closed-source predecessor projects are not mentioned — in code, documentation, commits, +pull requests, issues or release notes. Secrets are never printed, not to check them and not +in a debug line; a value that reaches a transcript has to be rotated. diff --git a/conventions/WRITING.md b/conventions/WRITING.md new file mode 100644 index 0000000..d51582c --- /dev/null +++ b/conventions/WRITING.md @@ -0,0 +1,141 @@ +# Writing + +One voice, three registers, two languages. The voice is the same wherever a text lands: it +says why before how, it claims what it can show, and it would rather be read twice than +skimmed once. The registers differ in length and shape, not in voice. The languages differ in +more than words, and the second half of this file is about that. + +## Languages + +Everything is written in English: pages, documentation, agent files, code and its comments, +commit messages, pull requests, release notes and what an agent says. English here is en-US; +the section on English below says what that means in spelling and marks. + +The pages carry a second language, Swiss German, de-CH. It is there because the person behind +the family is Swiss, and a reader in Zürich or Bern is a reader the pages are written for; it +is a second original, not a translation made for a market. So the German is written as +German, in the forms Switzerland uses, by someone who reads it, and it is never generated and +left unread. It appears only where a page carries it, in the value of an attribute whose name +ends in `-de`, and nowhere else: not in code, not in git, not in a reply. A third language is +not planned; adding one would mean writing this paragraph again with its own reason. + +## What every register shares + +Spelling is American English, in names and in prose: organization, modeling, license, color, +behavior. Proper nouns, quoted matter and names fixed by something outside the family — a +product, a standard, a file the ecosystem reads by name — stay as they are. A tripwire in this +repository holds the rule for what it ships, and every repository that vendors this file is +expected to hold it the same way for its own text. + +Cause before mechanism. A reader learns why a thing is the way it is before learning how it +works, because the why is what lets them decide whether the how still applies. A sentence that +states a mechanism with no cause is a sentence the next person will undo. + +Written forward. A page, a rule or an agent file says how things are and why, in the present; +it does not narrate how they came to be. The past is in git, where a commit message says what +changed and why, and in a spec, where a decision keeps the finding that led to it. A reader who +wants the story follows the history; a reader who wants the rule does not read the story first. + +Claim only what is measured or verifiable. A number is quoted after it was counted, not +estimated; a behavior is described after it was observed, not inferred from the source. Where +something was not checked, the text says so. + +No adjective that sells. Nothing here is powerful, seamless, robust or elegant. If it is any of +those, the sentence that shows it is better than the word. + +Closed-source predecessor projects are never mentioned, in code, in documentation, in commits +or in conversation. + +## The prose register + +For pages, README files, agent files, specs and release notes. + +Paragraphs by default. A list only for items that are genuinely parallel, and never a +paragraph wearing a bullet. A table only for data with more than one dimension, never for +sentences. Bold at most one sentence per section, and that sentence is the rule. Headers only +where a reader would jump to, never to segment a text that fits on a screen. + +Sentence case in headings and titles; a proper noun keeps its capitals. The first sentence of +a section carries the point; the rest is its support. + +Release notes are this register aimed at a consumer: what changed for them, what breaks and +how to take it, in that order. + +## The git register + +For commit messages and pull request descriptions. + +The subject is a sentence in plain words, under seventy characters where it can be, with no +type prefix and no trailing period. It says what is now true that was not before. + +The body is one to three short paragraphs, cause before mechanism. No headers, no bullets. A +table only for counts. It ends with one line beginning `Verified:` that names what ran and +passed, then the trailers. + +A pull request description is the commit body reread for a reviewer who has not seen the +diff, plus links to the sibling pull requests when there are any. + +One example, a commit message in full, then the trailers: + +``` +The list is the scope + +What REPOSITORIES.md names is the family, and what it does not name is out of reach: an +agent working in a member does not read, link or reason outside the list on its own. +When a task needs something outside it, the task names it and the one purpose it +serves, and that reference stays with the task. + +Verified: the spelling tripwire passes. +``` + +## The reply register + +For what an agent says in conversation, in a review comment and in a report. + +Outcome first, then what was found, then what is next. Short sentences with a verb. A list +only for parallel items, one or two sentences each. No headers under a page of text. A review +finding is one finding, with its severity and the line it sits on, and it is an input to the +person who merges, never a verdict. + +## English + +en-US, and the marks that go with it. Spelling and marks follow the Chicago form, with one +exception taken from AP style: the em-dash is spaced — like this — between clauses, the +newspaper form, because that is the form the family's texts are read in. Chicago's closed form +is not used. Curly quotes, “outside” and ‘inside’. No serial comma: vision, strategy and +processes. Dates read May 4, 2012, and months abbreviate to three letters without a period, +Oct 2012. A range takes a closed en-dash, May 2012–Oct 2016. Numbers group by comma, 16,000. + +## German + +de-CH, as the section on languages says, and not the German of Germany. The reader is Sie, +never du, except in a letter that matches its recipient. ss, never ß: Strasse, Massstab, +grösser. Guillemets, «aussen» and ‹innen›. The Gedankenstrich is a spaced en-dash – like this – and never an em-dash: German typography +has no em-dash, its dash is the Halbgeviertstrich, so the two languages share the spacing and +differ in the glyph, each following its own typography. Dates read +4. Mai 2012; abbreviated months carry their period where German abbreviates them, Jan., Febr., +März, Apr., Mai, Juni, Juli, Aug., Sept., Okt., Nov., Dez. A range takes the spaced en-dash, +Mai 2012 – Okt. 2016. Numbers group by the typographic apostrophe, 16’000, the character that +cannot end a single-quoted attribute. + +German is written as German, not as English syntax with German words: shorter sentences where +German would otherwise stack clauses, the verb where German puts it, and a noun where English +reached for a gerund. + +## The same paragraph, twice + +English, in the prose register: + +> The rule is one line long because the attributes were built to obey it: a page's own text +> is English, and the translation is what an attribute carries — not the other way round. +> Anything that rewrites text in bulk masks those attributes first; otherwise correct German +> becomes wrong German and nothing here notices, because every check reads the rendered page, +> and the rendered page is only ever one language. + +Deutsch, im selben Register: + +> Die Regel ist eine Zeile lang, weil die Attribute so gebaut wurden, dass sie ihr folgen: Der +> eigene Text einer Seite ist Englisch, die Übersetzung steht im Attribut – nicht umgekehrt. +> Wer Text in grosser Menge umschreibt, maskiert zuerst diese Attribute. Sonst wird aus +> richtigem Deutsch falsches, und keine Prüfung bemerkt es, denn jede Prüfung liest die +> gerenderte Seite, und die ist immer nur in einer Sprache. diff --git a/conventions/conventions-check b/conventions/conventions-check new file mode 100755 index 0000000..3b725a5 --- /dev/null +++ b/conventions/conventions-check @@ -0,0 +1,88 @@ +#!/bin/sh +# conventions-check — hold a member's own Markdown to WRITING.md: American English and the +# spaced em-dash. Fenced code and inline code are not prose and are dropped before the scan; +# folders under "exclude" in conventions.json are someone else's prose and are not read. +# +# sh conventions/conventions-check exit 1 with one ✗ line per hit, file:line: word +# +# Needs sh, awk, sed, grep and find. Nothing else. CONVENTIONS_ROOT overrides the directory +# scanned, which is how the tests point it at a fixture. +set -eu + +ROOT=${CONVENTIONS_ROOT:-.} +PIN=$ROOT/conventions.json + +# Stems, not words, so derived forms are caught; a stem is anchored wherever an American word +# contains it, and the list is edited when one is found. This is the list: WRITING.md forbids +# British spelling without quoting one. A stem that is also the start of an American word — +# fulfil in fulfill, enrol in enroll, grey in greyhound, analyse in analyses — ends with +# ([^a-z]|$) instead of \b, which BSD and GNU awk read alike; \b does not travel. organism, +# optimist, de minimis and initialism are American, so those four stems are anchored the same +# way but on the letter that follows, since British and American diverge right there. Case is +# folded by lowering the line before matching, not by the pattern, since IGNORECASE is a GNU +# extension awk on macOS does not have. +STEMS='organis(e|a|i)|colour|behaviour|centre|licence|recognis|modelling|catalogue|grey([^a-z]|$)|judgement|artefact|whilst|amongst|favour|honour|analys(e|ed|ing)([^a-z]|$)|optimis(e|a|i)|normalis|serialis|initialis(e|a|i)|visualis|minimis(e|a|i)|prioritis|customis|summaris|categoris|parameteris|rasteris|unrecognis|cancelled|travelled|labelled|aluminium|instalment|neighbour|totalled|defence|offence|programmes?([^a-z]|$)|fulfil([^a-z]|$)|enrol([^a-z]|$)|skilful|ageing|cosy([^a-z]|$)|focuss|cheque([^a-z]|$)|tyre([^a-z]|$)|kerb([^a-z]|$)|storey|mould|plough|draught|sceptic' +DASH='[^ ]—|—[^ ]' + +# The exclude list: every quoted string inside the "exclude": [ ... ] array, one per line, so +# an entry that carries a space stays one entry through the sed and the loop below. +excludes="" +if [ -f "$PIN" ]; then + excludes=$(tr -d '\n' < "$PIN" | sed -n 's/.*"exclude" *: *\[\([^]]*\)\].*/\1/p' | tr ',' '\n' | sed -n 's/^[^"]*"\([^"]*\)".*/\1/p') +fi + +# find, with .git and every excluded prefix pruned. The prune list is built as arguments; IFS +# is newline-only for the loop, so an entry with a space in it is one word, not two. +set -- "$ROOT" -path "$ROOT/.git" -prune -o +oldifs=$IFS +IFS=' +' +for e in $excludes; do + set -- "$@" -path "$ROOT/${e%/}" -prune -o +done +IFS=$oldifs +set -- "$@" -name '*.md' -type f -print + +# The files the walk visits. Listed once so an empty result — a root that does not exist +# included, since find then errors and prints nothing on stdout — is caught before the scan +# reports a bare ✓ for a tree it never opened. +files=$(find "$@" 2> /dev/null | LC_ALL=C sort || true) +if [ -z "$files" ]; then + echo "✗ conventions: no Markdown file was scanned under $ROOT" >&2 + exit 1 +fi + +hits=$(printf '%s\n' "$files" | while IFS= read -r f; do + # Drop fenced code and inline code, then scan what is left of each line for a stem or a + # closed dash; the match is against the line's own content, never the path awk read it from. + # A fence left open at end of file is a hit of its own, not a silent skip of the rest. + awk -v stems="$STEMS" -v dash="$DASH" -v root="$ROOT" ' + function relpath( p, prefix) { + p = FILENAME + prefix = root "/" + if (index(p, prefix) == 1) p = substr(p, length(prefix) + 1) + return p + } + /^```/ { fence = !fence; next } + fence { next } + { + line = $0 + sub(/\r$/, "", line) + gsub(/`[^`]*`/, "", line) + low = tolower(line) + if (match(low, stems)) { + w = substr(low, RSTART, RLENGTH) + sub(/[^a-z]$/, "", w) + printf "✗ %s:%d: %s\n", relpath(), FNR, w + } + if (match(line, dash)) printf "✗ %s:%d: closed em-dash\n", relpath(), FNR + } + END { if (fence) printf "✗ %s:%d: unclosed code fence\n", relpath(), FNR } + ' "$f" +done) + +if [ -n "$hits" ]; then + echo "$hits" + exit 1 +fi +echo "✓ every Markdown file follows WRITING.md" diff --git a/conventions/conventions-sync b/conventions/conventions-sync new file mode 100755 index 0000000..6e05229 --- /dev/null +++ b/conventions/conventions-sync @@ -0,0 +1,141 @@ +#!/bin/sh +# conventions-sync — vendor the family's shared conventions at a pinned release, and say +# whether the copy still matches it. +# +# sh conventions/conventions-sync sync write conventions/ and the AGENTS.md block from the pin +# sh conventions/conventions-sync check exit 1 with one ✗ line per thing that differs +# +# The pin is conventions.json in the member's root: {"repo": "robertblust/conventions", "tag": "v1.0.0"}. +# Files come from https://raw.githubusercontent.com//; CONVENTIONS_SOURCE overrides +# that with another base URL or a local directory, which is how the tests run offline. +# Needs sh, curl, awk, sed, and shasum or sha256sum. Nothing else. +set -eu + +usage() { echo "usage: sh conventions/conventions-sync sync|check" >&2; exit 2; } +[ $# -eq 1 ] || usage +cmd=$1 +case $cmd in sync|check) ;; *) usage ;; esac + +PIN=conventions.json +DIR=conventions +FILES="WRITING.md WORKING.md REPOSITORIES.md AGENTS.md conventions-sync conventions-check" +OPEN_RE='^$' +CLOSE='' + +[ -f "$PIN" ] || { echo "✗ conventions: no $PIN here — a member names the release it follows" >&2; exit 1; } +pin() { sed -n "s/.*\"$1\" *: *\"\([^\"]*\)\".*/\1/p" "$PIN" | head -1; } +REPO=$(pin repo) +TAG=$(pin tag) +if [ -z "$REPO" ] || [ -z "$TAG" ]; then echo "✗ conventions: $PIN needs \"repo\" and \"tag\"" >&2; exit 1; fi +SOURCE=${CONVENTIONS_SOURCE:-https://raw.githubusercontent.com/$REPO/$TAG} + +sha() { + if command -v sha256sum > /dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 + else shasum -a 256 "$1" | cut -d' ' -f1; fi +} + +# fetch +fetch() { + case "$SOURCE" in + http://*|https://*) curl -fsSL "$SOURCE/$1" -o "$2" || { echo "✗ conventions: could not fetch $SOURCE/$1" >&2; exit 1; } ;; + *) cp "$SOURCE/$1" "$2" ;; + esac +} + +# block_of : the fenced block, markers included, with its version rewritten to the pin. +# Comparing two blocks through this makes the version line irrelevant, which is what lets +# the source carry whatever release number it was last tagged at. +block_of() { + awk -v tag="$TAG" -v open_re="$OPEN_RE" -v endm="$CLOSE" ' + !inblock && $0 ~ open_re { inblock = 1; print ""; next } + inblock && $0 == endm { print; exit } + inblock { print }' "$1" +} + +# write_block: replace the block in AGENTS.md, or put one at the top of a file that has none. +write_block() { + new=$(block_of "$DIR/AGENTS.md") + [ -n "$new" ] || { echo "✗ conventions: $DIR/AGENTS.md carries no conventions block" >&2; exit 1; } + if [ -f AGENTS.md ] && grep -q "$OPEN_RE" AGENTS.md; then + # Line numbers rather than an awk variable: a multi-line -v value is rejected by the awk + # macOS ships, and the block is many lines. + start=$(grep -n "$OPEN_RE" AGENTS.md | head -1 | cut -d: -f1) + end=$(awk -v s="$start" -v endm="$CLOSE" 'NR > s && $0 == endm { print NR; exit }' AGENTS.md) + [ -n "$end" ] || { echo "✗ conventions: AGENTS.md opens a conventions block and never closes it" >&2; exit 1; } + { [ "$start" -gt 1 ] && head -n "$((start - 1))" AGENTS.md; printf '%s\n' "$new"; tail -n "+$((end + 1))" AGENTS.md; } > AGENTS.md.tmp + else + { printf '%s\n\n' "$new"; if [ -f AGENTS.md ]; then cat AGENTS.md; fi; } > AGENTS.md.tmp + fi + mv AGENTS.md.tmp AGENTS.md +} + +sync() { + mkdir -p "$DIR" + # A member running an older conventions-sync has an older FILES list, missing whatever file + # a later release added; that stale list would fetch this script over itself and stop, never + # picking up the new file. So the current release of conventions-sync is fetched first; when + # it differs from what is on disk, it is moved into place and re-run once, guarded against a + # second re-exec, so the rest of the sync happens under the release's own FILES list. + if [ "${CONVENTIONS_REEXEC:-}" != "1" ]; then + fetch "$DIR/conventions-sync" "$DIR/conventions-sync.new" + if cmp -s "$DIR/conventions-sync.new" "$DIR/conventions-sync" 2> /dev/null; then + rm -f "$DIR/conventions-sync.new" + else + mv "$DIR/conventions-sync.new" "$DIR/conventions-sync" + chmod +x "$DIR/conventions-sync" + CONVENTIONS_REEXEC=1 exec sh "$DIR/conventions-sync" sync + fi + fi + for f in $FILES; do + case $f in + AGENTS.md) fetch "AGENTS.md" "$DIR/$f.new" ;; + *) fetch "$DIR/$f" "$DIR/$f.new" ;; + esac + mv "$DIR/$f.new" "$DIR/$f" + done + chmod +x "$DIR/conventions-sync" + chmod +x "$DIR/conventions-check" + write_block + { + printf '{\n "repo": "%s",\n "tag": "%s",\n "files": {\n' "$REPO" "$TAG" + first=1 + for f in $FILES; do + [ "$first" -eq 1 ] || printf ',\n' + first=0 + printf ' "%s/%s": "sha256:%s"' "$DIR" "$f" "$(sha "$DIR/$f")" + done + printf '\n }\n}\n' + } > "$DIR/manifest.json" + echo "✓ conventions: $DIR/ and the AGENTS.md block are at $REPO@$TAG" +} + +check() { + fail=0 + [ -f "$DIR/manifest.json" ] || { echo "✗ conventions: no $DIR/manifest.json — run sync" >&2; exit 1; } + mtag=$(sed -n 's/.*"tag" *: *"\([^"]*\)".*/\1/p' "$DIR/manifest.json" | head -1) + if [ "$mtag" != "$TAG" ]; then + echo "✗ conventions: $PIN names $TAG, the copy is $mtag — run sync"; fail=1 + fi + for f in $FILES; do + want=$(sed -n "s|.*\"$DIR/$f\" *: *\"sha256:\([0-9a-f]*\)\".*|\1|p" "$DIR/manifest.json") + if [ ! -f "$DIR/$f" ]; then + echo "✗ conventions: $DIR/$f is missing — run sync"; fail=1 + elif [ "$(sha "$DIR/$f")" != "$want" ]; then + echo "✗ conventions: $DIR/$f differs from $REPO@$TAG — edit it there, not here, or run sync"; fail=1 + fi + done + have="" + [ -f AGENTS.md ] && have=$(block_of AGENTS.md) + want=$(block_of "$DIR/AGENTS.md") + if [ "$have" != "$want" ]; then + echo "✗ conventions: the block in AGENTS.md is not the release's — run sync"; fail=1 + fi + [ "$fail" -eq 0 ] && echo "✓ conventions: $DIR/ and the AGENTS.md block match $REPO@$TAG" + exit "$fail" +} + +case $cmd in + sync) sync ;; + check) check ;; + *) usage ;; +esac diff --git a/conventions/manifest.json b/conventions/manifest.json new file mode 100644 index 0000000..70528c3 --- /dev/null +++ b/conventions/manifest.json @@ -0,0 +1,12 @@ +{ + "repo": "robertblust/conventions", + "tag": "v1.3.2", + "files": { + "conventions/WRITING.md": "sha256:b490bc45be749d431aa32ecb9acc070111de95fe244764b4546da1eb8745296c", + "conventions/WORKING.md": "sha256:b3aecf1cb82608a72004e1e2e10324a78df991ec147678d4bd04a66ec5b9ad46", + "conventions/REPOSITORIES.md": "sha256:b85d4a80144d46b432ee569eacee79644275f146d186c2cec2990b0a3e7be58f", + "conventions/AGENTS.md": "sha256:315c5a7802004aa0a67ee429c918e62ba94d053c1c935ed9ee865a4f560c2379", + "conventions/conventions-sync": "sha256:e062c0ed043ba9bd1e3246170b042c6e6665822dafd51c7ca90e19fd4ad6942a", + "conventions/conventions-check": "sha256:a620a0f3ce013884adaa68b41ee1158822791ce6f3d3b5be7e71037ff533cc3f" + } +} diff --git a/notes/elevenlabs-clip-ends-on-a-click.md b/notes/elevenlabs-clip-ends-on-a-click.md index 975ecb6..89c301c 100644 --- a/notes/elevenlabs-clip-ends-on-a-click.md +++ b/notes/elevenlabs-clip-ends-on-a-click.md @@ -50,7 +50,7 @@ answer. A discontinuity scan flagged a "burst of near-full-scale noise erupting out of silence" mid-clip, at 267× the local median. It looked damning. -It was ordinary speech. The scan compared each point against its *local* neighbourhood, and +It was ordinary speech. The scan compared each point against its *local* neighborhood, and a consonant after a pause always wins that comparison. Measured against the **whole file**, where speech routinely peaked at 20000–29000, the burst was unremarkable. A ratio needs a denominator you chose on purpose. @@ -123,7 +123,7 @@ Across three repositories, 86 clips: | detached burst | 17 (up to 195 ms) | 9, all under 80 ms and attenuated 6–9 dB | Every clip was measured under **two independent decoders**, which matters more than it -sounds: `lame` honours the MP3 gapless tag and `afconvert` does not, so they disagreed +sounds: `lame` honors the MP3 gapless tag and `afconvert` does not, so they disagreed about where a file ends. On two clips that disagreement was the difference between "defective" and "fine" — an early count of 3 affected files was really 5. A single-decoder result was decoder-dependent and I had stated it as fact. @@ -150,5 +150,5 @@ right up until you fix the other. every repair, stop repairing the output and change the request. The fix belongs at the level the cause lives at, which is not always the level the symptom appears at. -**Decoders disagree about the ends of files.** Gapless metadata is optional and honoured +**Decoders disagree about the ends of files.** Gapless metadata is optional and honored inconsistently. Verify under more than one.