Skip to content

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-flags marks Other cities for chapter review. A submitted Other city 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; --write installs 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), and install-colors never 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, or Status=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.py and create_series.py sent drive.files.create and drive.files.copy at 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.py already passed retries=1 at 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.py had no retry handling at all — a bare subprocess.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 dropped internalError/Internal error, a bare 500 or 504, HTTP request failed, temporarily, userRateLimit and Connection refused/aborted: a badge sync died on those where a sibling engine rode the same sick API out.
  • create_series.py still matched a bare 500 as 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. gws dumps its environment on some failures and the scrub deliberately KEEPS GOOGLE_WORKSPACE_CLI_* because gws needs 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.py carried a fifth copy — inside the shared package, under a comment saying it mirrored create_chapter.py's. It did, bare "500" and all, and its files.copy was unguarded: a retried copy orphans a second TEMP - render_slide_png beside 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's files.create, slides_export.py's files.copy, and migrate_resource_columns.py's two insertDimension batches and its addSheet (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_env was narrower than the ten private copies it replaced. AAIF_SLACK_\w*_TOKEN requires a middle segment, so a bare AAIF_SLACK_TOKEN — which startswith/endswith matched — survived into every gws child. Nothing sets that spelling today.
  • A gws failure now names the verb and never the payload. The message was gws failed (1): ... out of scripts that issue a dozen calls across two spreadsheets, and it folded in the child's stdout — where a values.get that streams half its rows and then exits nonzero puts those rows. It says gws sheets spreadsheets batchUpdate failed (1): ... now, from stderr only.
  • install_ops_notes.py treated 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 second appendDimension. 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 loaded SKILL.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.py and install_ops_notes.py call aaif_events.gws instead of their own wrappers. All three sit in skills already on the README's not-zippable list — a sibling script in the same folder imports lib — so the standalone-ness those copies were defending had already been spent, and the only thing left was the drift.
  • check_portable_skills.py reads imports, not substrings. Its own docstring said to tighten it the first time a file merely mentioned aaif_events without importing it. deck_estate.py (explaining that it is not the library) and sync.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.json shared by Codex and Cursor, plus cross-client capability and authorization guidance in PORTABILITY.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), because retries=NO_RETRY at 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 its ALLOW, with a reason.

[0.6.0] — 2026-09-17

Fixed

  • A worked example in aaif-community-pulse had 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.
  • gws calls 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 claiming A500:K500 exceeds grid limits was handled. It was not — that 500 is preceded by A and 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 --apply wrote 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_chapters on 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), so header_index grew a per-column first_of opt-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_chapters had 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.py keeps it single.
  • lib/aaif_events/gws.py and lib/aaif_events/sheets.py — one gws client and one header-name lookup for the skills already coupled to lib. 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.py and audit_activity.py, which had none between them despite feeding a leadership-facing report.
  • scripts/check_portable_skills.py — a skill's lib coupling 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.py covers 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 a SKILL.md does 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 in validate.yml, which holds no credential because it runs on fork PRs; run it locally before editing any description:.

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-chapters moves 300 lines of naming history and applied-migration prose into references/, and its four completed one-shots move from scripts/ to migrations/ — kept, not deleted, because a live engine names one of them as the fix when a sheet is missing its columns. aaif-audit-slack moves 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 PROJECTS roster 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 the OPEN / BY DEFAULT stat 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 navy 1E2761 in 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.py shipped a light palette plus an inverted twin under prefers-color-scheme and [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 print block pinned --accent:#5A3D8C and 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-3 teal, not the invented 14964A. A you-are-here dot is exactly what the system reserves the spectrum for. MARKER_FILLS still 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.xml is 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, and audit() 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 of report_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 use 1e2761 as 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 muted LOGO 1, LOGO 2, … placeholders rather than the misleading MEMBER 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.py is where the estate walk, the per-file download/rewrite/upload contract and the slide-XML helpers now live. They were written inside backfill_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.py applies 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; --write applies, 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 the PROJECTS tuple at the top of the script: edit it and sweep again when the list changes.
  • skills/aaif-create-chapter/scripts/backfill_host_footer.py applies that rework to templates that already exist — all chapters, the online series, and the shared Templates folder. Read-only by default; --write applies, 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 package lib/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 pytest job in validate.yml executes the lib/aaif_events/tests suite and every skills/*/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 from LUMA_API_KEY or the luma-api-key keychain item; Luma Plus required) with pure, unit-tested payload builders. All live writes sit behind explicit --create/--apply flags 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 (from aaif-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; --apply pushes only the changed fields (Luma's guest notifications are suppressed unless --notify-guests is 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-chapters skill — sync organizer decisions from the Intake Ops sheet into the Chapters List: merge Accepted / 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 atomic batchUpdate on approval, idempotent, with unresolved-city and near-miss-city guardrails. Unit tests for the pure merge/slug/near-miss logic.

Fixed

  • gws JSON parsing (gws_json in the sync, create-chapter, and create-online-series engines) now splits output on \n only — Python's splitlines() also splits on U+2028 (line separator) inside string values, which corrupted the JSON when rejoined (hit by a real intake row).
  • aaif-clean-data treats any Other… city placeholder as unresolved — the form emits both Other and Other (PLEASE TELL US WHERE IN NEXT QUESTION), and the exact-string match left long-variant rows unflagged (24 on live data) and wrongly painted their City (Existing) green. The green rule now checks the Other prefix; retired formulas are tracked in LEGACY_COLOR_FORMULAS so install-colors replaces old rules instead of stacking duplicates.

[0.2.0]

Added

  • aaif-create-online-series skill — 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 of aaif-create-chapter.
  • Repo hardening: $schema references on both manifests, .pre-commit-config.yaml, Ruff config (pyproject.toml), and a validate CI 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.