aaif/aaif-events
v0.6.0MIT
Skills for AAIF community event content, chapter operations, organizer workflows, Slack audits, and estate synchronization.
Changelog
All notable changes to the AAIF Community Events Toolkit plugin are documented here.
The format follows Keep a Changelog, and the
plugin version is mirrored in root plugin.json and .claude-plugin/plugin.json.
[Unreleased]
Added
clean.py chapter-flagsmarksOthercities for chapter review. A submittedOthercity cell turns red on Form Responses and every role tab, whatever the person's Status and whether or not a city was extracted — it asks for a chapter decision, it does not grant one. Preview by default;--writeinstalls the cell-scoped rule and reads it back. Formatting only: no value changes and no chapter is created. The preview also flags a rule whose stored range stops short of the tab (Sheets fixes it at the row count at install time), andinstall-colorsnever seats its rules above the flag.
Fixed
- Chapter sync never clears a chapter row. A live feed row with no
resolved qualifying organizer city is listed as a retirement candidate;
retiring it is a human
Status=Deprecated, orStatus=Merged+Merged Into. Ambiguous or incomplete organizer city data holds the question instead. Candidates are report-only: they leave the exit code alone and appear on the run page as decisions waiting for a person. - Two Drive cloners retried a write that may already have landed.
create_chapter.pyandcreate_series.pysentdrive.files.createanddrive.files.copyat the default five attempts. A create that succeeded server-side but answered like a timeout is indistinguishable here from one that never landed, so the retry made a second folder — or a second copy of a template file — under the same name, and nothing downstream detects that duplicate: the next listing simply reports a subtree holding everything twice, and the rebrand walks both.sync_badges.pyalready passedretries=1at its two write sites; the other two now do the same, the rule has one definition (aaif_events.gws.NO_RETRY), and every call site that carries the guard has a test pinning the keyword. install_ops_notes.pyhad no retry handling at all — a baresubprocess.run, so a single intermittent 503 that every other engine rides out failed the run. It goes through the shared client now, and its one non-idempotent request (appendDimension, which on a retry adds a second column and strands the header in the first of two) is sent once.sync_badges.py's retry table carried five substrings against the shared table's eleven plus its boundary-matched statuses. It genuinely droppedinternalError/Internal error, a bare 500 or 504,HTTP request failed,temporarily,userRateLimitandConnection refused/aborted: a badge sync died on those where a sibling engine rode the same sick API out.create_series.pystill matched a bare500as a substring, the permanent-error-burns-the-backoff bug fixed in 0.6.0 for the shared client — and, worse, interpolated the child's raw stdout+stderr into its exceptions.gwsdumps its environment on some failures and the scrub deliberately KEEPSGOOGLE_WORKSPACE_CLI_*becausegwsneeds it, so an OAuth client secret and refresh token are exactly what such a dump holds; this is also the zippable skill, the one most likely to be run by someone who pastes the traceback into a public issue. It kept a private copy to stay zippable and the copy fell two fixes behind, so the copy is gone: the skill takes the lib coupling and is on the README's standalone-zip list.lib/aaif_events/slides_export.pycarried a fifth copy — inside the shared package, under a comment saying it mirroredcreate_chapter.py's. It did, bare"500"and all, and itsfiles.copywas unguarded: a retried copy orphans a secondTEMP - render_slide_pngbeside the source, which against TemplateCity is then cloned into every chapter made afterwards.- Four non-replayable calls elsewhere in the tree were on the retrying
budget:
upload_agents.py'sfiles.create,slides_export.py'sfiles.copy, andmigrate_resource_columns.py's twoinsertDimensionbatches and itsaddSheet(Sheets does not refuse a duplicate tab title — it renames the second one, which the exists-check then never matches again). aaif_events.slack.scrubbed_envwas narrower than the ten private copies it replaced.AAIF_SLACK_\w*_TOKENrequires a middle segment, so a bareAAIF_SLACK_TOKEN— whichstartswith/endswithmatched — survived into everygwschild. Nothing sets that spelling today.- A
gwsfailure now names the verb and never the payload. The message wasgws failed (1): ...out of scripts that issue a dozen calls across two spreadsheets, and it folded in the child's stdout — where avalues.getthat streams half its rows and then exits nonzero puts those rows. It saysgws sheets spreadsheets batchUpdate failed (1): ...now, from stderr only. install_ops_notes.pytreated a read with no JSON in it as an empty sheet. That became "no tab titled X — a rename, not an empty sheet", "X has no header row", or "wrote Ops Notes but a fresh read does not show it" after a write that landed — and re-running on that last one issues a secondappendDimension. Only the two writes tolerate silence now.
Changed
- All 23 skills now declare compatibility, keep Claude's argument hint as
namespaced string metadata, and use a client-neutral
<skill-root>path placeholder resolved from the loadedSKILL.md. - The sync run's report splits a step's findings into Drift and Synced tabs when it has both, and shows warn/info as a glyph; print/PDF shows both panels.
create_chapter.py,sync_badges.pyandinstall_ops_notes.pycallaaif_events.gwsinstead of their own wrappers. All three sit in skills already on the README's not-zippable list — a sibling script in the same folder importslib— so the standalone-ness those copies were defending had already been spent, and the only thing left was the drift.check_portable_skills.pyreads imports, not substrings. Its own docstring said to tighten it the first time a file merely mentionedaaif_eventswithout importing it.deck_estate.py(explaining that it is not the library) andsync.py(driving every engine by subprocess) are those files. It is an AST pass over the import statements now, and a file that does not parse is a hard failure rather than "not coupled".
Added
- A root Agent Plugins v1
plugin.jsonshared by Codex and Cursor, plus cross-client capability and authorization guidance inPORTABILITY.md. - Repository validators for portable manifests, complete Agent Skills frontmatter, and client-neutral resource/tool wording.
scripts/check_non_idempotent_retries.py(pre-commit + CI), becauseretries=NO_RETRYat the call site is opt-in and opt-in was not enough: the rule shipped with four counterexamples already in the tree. Two tiers — exact for Drive, where the verb is the first three arguments (in either the positional or the argv-list shape the file helpers use), and module-level for Sheets, where the request key is built in a different function from the call that sends it. An exception is argued in itsALLOW, with a reason.
[0.6.0] — 2026-09-17
Fixed
- A worked example in
aaif-community-pulsehad been pasted from a real posted update, naming real organizers in a public repo. Replaced with the synthetic cast the other content skills use. The Luma links in it are public pages and stay. gwscalls retried on a permanent error. Both copies of the transient-failure test excluded only an adjacent digit from the HTTP-status match, under a comment claimingA500:K500 exceeds grid limitswas handled. It was not — that500is preceded byAand followed by:, so a permanent grid-limit error burned the full 20-second backoff before failing anyway.- Seven sheet reads had a hardcoded right edge (
A:U,A:BB,A:CO), which truncates the newest column first — the one someone has just added — so the layout check then aborted naming a column visibly present on the sheet. All now read wide and resolve by header name. resolve_slack_ids.py --applywrote to the sheet without--write, so whether an invocation writes could not be answered by reading the command line. It now refuses and prints the corrected command.- The new duplicate-header abort would have broken
sync_chapterson every live run. Verified against the live intake sheet 2026-09-17: one column there carries the same header twice, and the sync engine resolves that header. Both copies predate this branch and sit inside the range the engines have always read, so the abort — not the widened ranges — is what surfaced it. That column is read-only (its value is printed in a report, never written back), soheader_indexgrew a per-columnfirst_ofopt-out: it resolves to the first match and warns, naming both columns. Every other column stays fatal on a duplicate, which is the case that would land a write in the wrong column. All eleven live header lookups in the repo were then re-run and resolve. sync_chaptershad no duplicate-header guard. Its header lookup resolved silently to the first of two identically named columns; the audit skill's copy had aborted on this all along. Both now use the shared lookup, which aborts.- Three skills were missing from the README's standalone-zip caveat, so
zipping one of them for claude.ai would have failed at import. Also corrected:
the skill count (18 → 20), the legal-footer note (three skills named, four
carry it), and two
gws-dependent skills filed under "no setup required".
Added
lib/aaif_events/redact.py— one redaction surface. Ten scripts carried their own copy of--redact's flag and helpers, and they had already drifted apart — one had lost its address masker entirely.scripts/check_no_local_redaction.pykeeps it single.lib/aaif_events/gws.pyandlib/aaif_events/sheets.py— onegwsclient and one header-name lookup for the skills already coupled tolib. The previous copies had drifted into different retry tables and different safety guards, so which script you were in decided whether a 503 was survived. Two wrappers that had no retry handling at all now have it.- Tests for
audit_members.pyandaudit_activity.py, which had none between them despite feeding a leadership-facing report. scripts/check_portable_skills.py— a skill'slibcoupling must match the README's list, so giving up portability stays a decision someone makes on purpose rather than an accident one import at a time.- The public-copy rule and the attendee legal footer are now enforced
banners. They existed as nine and four hand-written variants;
check_tooling_banner.pycovers all three shared banners instead of one. - An eval suite (
evals/) — the first check here that asks whether the right skill fires, and whether an agent reading aSKILL.mddoes what it says. Every other check is deterministic and stays green through a description edit that stops a skill triggering. Each case runs with and without the plugin and reports the delta, so a grader that passes either way is visibly testing the base model rather than this plugin. It needs model access and so cannot run invalidate.yml, which holds no credential because it runs on fork PRs; run it locally before editing anydescription:.
Changed
- The content skills fetch their own inputs. All eight labelled their fields
"from the event tracker" with no way to reach one, so the honest outcome was
to ask for a paste and stop. Each now points at
aaif-event-status. - The two big ops skills separate runbook from record.
aaif-sync-chaptersmoves 300 lines of naming history and applied-migration prose intoreferences/, and its four completed one-shots move fromscripts/tomigrations/— kept, not deleted, because a live engine names one of them as the fix when a sheet is missing its columns.aaif-audit-slackmoves its sheet-schema spec the same way. Both frontmatter descriptions were over 800 characters on one line, which is the field that decides whether a skill activates at all.
Changed
- The About slide's
THE PROJECTSroster names all six hosted projects. The decks were drawn when AAIF hosted four (MCP · goose · AGENTS.md · agentgateway); aaif.io/projects now lists six, with A2A and Agent Router added, and a roster an audience can check against the website mid-talk is one that has to be right. The line stays one line: its box is widened to hold the two extra names, stopping short of theOPEN / BY DEFAULTstat beside it. The templates live in Drive, so a chapter sees this only once the backfill below has swept it, and new chapters only once the sweep has reached TemplateCity. - Every deck, tracker and CRM in the Drive estate now follows the AAIF design
system. The HTML/PDF side already read
design/aaif-tokens.css; the OOXML side never had a seam, so the brand lived in those files as literal font names and hex values and had drifted a long way from it — Space Grotesk and Manrope as display faces, Arial in every theme, Office's stock colour scheme underneath, a warm-grey ramp half a shade off--line-2, and a navy1E2761in the trackers. JetBrains Mono is deliberately not on that list: the trackers' 205 mono runs are field labels, table headers, dates, statuses and phase eyebrows — metadata, which is exactly what the system reserves mono for — so they are left alone. The face is declared but no longer embedded (Google Docs resolves it, the same way it already resolves Instrument Sans), and the orphaned Manrope/Space Grotesk embeds are pruned around it: a tracker goes from ~385KB to ~205KB, of which ~186KB is the metric fallback — the one face still embedded, because it exists for the reader who lacks the brand face. (An earlier revision of this sweep read those 205 runs as body prose and rewrote them to the sans; that was wrong and was reverted before release.) - The audit reports have no dark mode.
report_style.pyshipped a light palette plus an inverted twin underprefers-color-schemeand[data-theme]. AAIF is a two-surface system where the designer chooses white editorial or black plate per component, not a palette the viewer's OS flips; the page is white and black now appears only where something is deliberately drawn on it. The warm off-white is no longer used as a surface. - PDFs stop printing in a pre-AAIF palette.
report_style.py's@media printblock pinned--accent:#5A3D8Cand friends, and because it redefined the design system's own token names rather than the report vocabulary, every PDF came out in the old purple brand while the screen rendered correctly. - The map dot is
--spec-3teal, not the invented14964A. A you-are-here dot is exactly what the system reserves the spectrum for.MARKER_FILLSstill recognises the legacy value so a deck the sweep has not reached stays findable.
Fixed
- Chapter CRMs were never actually restyled, although the docs and the
sweep's own "0 off-system" result claimed they were.
xl/styles.xmlis SpreadsheetML —<font><name val="Calibri"/>,<patternFill><fgColor rgb="FF1E2761"/>, ARGB colours — and it was being handed to the DrawingML pass, which found nothing to change and reported success. Workbooks now have their own pass, andaudit()can see them. - The contrast checker read a shape's outline as its fill. PowerPoint writes
<a:ln><a:noFill/></a:ln>on almost every filled shape; taking that as "the shape has no fill" scored text on a filled card against the slide behind it. On a dark slide that reads as a failure, and the repair would then whiten readable black-on-white text into invisibility. Verified against all 352 repaired decks: no run went from passing to failing, so nothing needed restoring.
Added
lib/aaif_events/ooxml_style.py— the OOXML sibling ofreport_style.py, and the seam that ends the drift above: skill scripts never write a font name or a colour of their own. Its colour map is role-aware, because it has to be — the trackers use1e2761as a cell fill and, forty characters later in the same run of XML, as a cell border, and those go to different tokens. The rewrite is minimal-diff, touching only the attribute and copying every other byte, so embedded fonts and relationship ids survive and "did the bytes change" is a correct upload test.lib/aaif_events/agent_art.py— six background plates per aspect for the hero decks, drawn from the tokens rather than by hand, plus the agent motif generated from the design system's own 48-unit spec. Includes a stdlib PNG reader and GIF89a encoder: three plates animate, and the quantiser refuses anything that would band across a full-bleed background (the flat plates move 0.002% of pixels, the gradient plates 7.5%).skills/aaif-create-chapter/scripts/restyle_design_system.py— the estate sweep. Read-only by default, archives every pre-change file to./backups/restyle-<UTC>/before uploading, leaves organizers' per-event copies alone and says how many it skipped, and asserts it reached TemplateCity, TemplateSeries and the shared Templates folder — the three that mint everything else.
Changed
- The event templates' "HOSTED BY / WITH" logo footer is no longer boxed.
Each logo slot used to be a bordered, filled rounded-rect button holding
centred bold text, which read as a control and fought the flat rule-and-type
language of the rest of the deck. The boxes are gone, the host slot now
carries the AAIF lockup (AAIF hosts these events; the slot used to say
HOST VENUE CO.), and unfilled slots are mutedLOGO 1,LOGO 2, … placeholders rather than the misleadingMEMBER LOGO. The lockup is drawn from the mark image each slide already embeds for its own header — identified by name, not by being the first picture on the slide — so it cannot drift from it. The templates live in Drive, so a chapter sees this only once the backfill below has swept it, and new chapters only once the sweep has reached TemplateCity.
Added
skills/aaif-create-chapter/scripts/deck_estate.pyis where the estate walk, the per-file download/rewrite/upload contract and the slide-XML helpers now live. They were written insidebackfill_host_footer.py, which made a script named after a footer the home of the whole estate's Drive id and of every later sweep's primitives; a sweep can now be retired without taking the next one with it. Both backfills import it, and the estate-coverage checks — the lines that stop a renamed folder reading as a clean estate — exist once.skills/aaif-create-chapter/scripts/backfill_projects.pyapplies that roster to templates that already exist — all chapters, the online series and the shared Templates folder, a set that includes TemplateCity. Read-only by default;--writeapplies, a deck already naming the six is not re-uploaded, and a chapter that wrote its own roster is skipped and reported rather than overwritten. The roster itself is thePROJECTStuple at the top of the script: edit it and sweep again when the list changes.skills/aaif-create-chapter/scripts/backfill_host_footer.pyapplies that rework to templates that already exist — all chapters, the online series, and the shared Templates folder. Read-only by default;--writeapplies, and a file already reworked has no chips left to find, so re-running is a no-op.
[0.5.0]
Changed
- Renamed "meetup" → "event" across the plugin. Legal flagged that "Meetup"
is a Meetup.com trademark, so all forward-facing wording now uses the AAIF
Community Events brand. Breaking: plugin id
aaif-meetups→aaif-events, Python packagelib/aaif_meetups→lib/aaif_events(all imports updated), and repo URL →github.com/aaif/events. Install as/plugin install aaif-events@aaif. Skill invocation ids (/aaif-<skill>) are unchanged; only descriptions/copy moved.
Added
- CI now runs the Python test suite — a
pytestjob invalidate.ymlexecutes thelib/aaif_events/testssuite and everyskills/*/scripts/test_*.py, so import breakage is caught in CI rather than only locally.
[0.4.0]
Added
- Luma API integration (
lib/aaif_events/luma.py): stdlib client for the Luma public API (public-api.luma.com, per-calendar key fromLUMA_API_KEYor theluma-api-keykeychain item; Luma Plus required) with pure, unit-tested payload builders. All live writes sit behind explicit--create/--applyflags that the agent only runs after the user approves the printed proposal. Every script detects whether Luma is connected: when no key is configured it degrades gracefully — the push prints the proposal as manual-creation details, the sync prints the desired values as a manual checklist, and the stats step is skipped with a note — instead of erroring. aaif-create-event→scripts/luma_push.py: create the live Luma event page from the tracker entry — times from DATE & TIME + IANA timezone, venue as a manual address, capacity, description markdown (fromaaif-luma-description), banner PNG uploaded as the cover, hosts (manager / check-in) — then write the event URL back into the tracker's LUMA URL field. Aborts if already pushed.aaif-update-event→scripts/luma_sync.py: field-by-field diff of the tracker vs the live event;--applypushes only the changed fields (Luma's guest notifications are suppressed unless--notify-guestsis passed) and re-verifies. Cancellation deliberately not automated.aaif-event-status→scripts/luma_stats.py: read-only guest counts (going / pending / waitlist / invited / declined / checked-in) and registration state for pushed events; feeds day-of slides and recap numbers. Luma data is never written back into the Intake Ops sheet.
[0.3.0]
Added
aaif-sync-chaptersskill — sync organizer decisions from the Intake Ops sheet into the Chapters List: mergeAccepted/Existing (from MLOps)organizers into each city row's Organizers column and append rows for net-new cities (with their Luma link). Report-and-propose by default, one atomicbatchUpdateon approval, idempotent, with unresolved-city and near-miss-city guardrails. Unit tests for the pure merge/slug/near-miss logic.
Fixed
gwsJSON parsing (gws_jsonin the sync, create-chapter, and create-online-series engines) now splits output on\nonly — Python'ssplitlines()also splits on U+2028 (line separator) inside string values, which corrupted the JSON when rejoined (hit by a real intake row).aaif-clean-datatreats anyOther…city placeholder as unresolved — the form emits bothOtherandOther (PLEASE TELL US WHERE IN NEXT QUESTION), and the exact-string match left long-variant rows unflagged (24 on live data) and wrongly painted theirCity (Existing)green. The green rule now checks theOtherprefix; retired formulas are tracked inLEGACY_COLOR_FORMULASsoinstall-colorsreplaces old rules instead of stacking duplicates.
[0.2.0]
Added
aaif-create-online-seriesskill — clone the TemplateSeries folder under the top-level Online Drive folder and rebrand it for a new online event series (reading group, paper club, webinar). The online sibling ofaaif-create-chapter.- Repo hardening:
$schemareferences on both manifests,.pre-commit-config.yaml, Ruff config (pyproject.toml), and avalidateCI workflow (pre-commit +claude plugin validate).
Changed
- Manifest descriptions and tags now cover online meetups/series, not just in-person chapters.
[0.1.0]
Added
- Initial release: 11 skills for running AAIF in-person meetup chapters — content
writing (announcement, carousel, Luma description, speaker invite/bio, day-of
slides, attendee reminder, recap) and chapter ops (
aaif-create-chapter,aaif-triage-intake,aaif-clean-data). - One-plugin marketplace (
aaif) packaging the toolkit for/plugin install.