Repository navigation
Add AGENTS.md, the desk run procedure, four skills and a guard hook - #498
Merged
Merged
Conversation
drop_redundant_link_fields already removed a cfp_link, sponsor or finaid that repeats the main link. It now also removes one that is only the bare root of the same site: an empty or "/" path with no query or fragment, on the same host as link (ignoring "www.") or on a parent domain of it. "cfp_link: https://pycon.de/" next to "link: https://2027.pycon.de/" says nothing about the CfP, so it goes. Sub-pages, #anchors and query strings on the parent domain stay, as do links to other sites and a year subdomain next to a series link, which is more specific than the link itself. The current data has no such links, so the sort leaves _data unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
check_link_availability treated any redirect that ended on a 200 as a healthy link, even when it landed on another site: pythoncamp.de now redirects to barcamps.eu and pytennessee.org to a casino site. When the final URL is on a different site, the original URL is now returned as it is (no Wayback lookup or save, no replacement), the redirect is logged, and a record is kept for review. Different site means the hosts differ after dropping "www.", neither is a subdomain of the other, and their registrable domains differ. The registrable domain is the last two labels, or three under a known two-level public suffix such as co.uk or org.au, so ug.pycon.org to africa.pycon.org stays the same site. Same-site redirects behave as before. Records go to the module-level REDIRECT_RECORDS list and, when the DESK_QUEUE_OUT environment variable names a file, are appended to it as JSON lines shaped like a "link-check" "redirect" queue item: source, kind, received, title, url, year and a payload with conference, year, field, final_url and status. sort_yaml.check_links passes the conference, year and field as keyword arguments, which default to None, so other callers keep working. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
Conference entries can now carry "coc", a URL to the conference's Code of Conduct. It is validated and serialised like finaid and sponsor (an http or https URL, written back as a plain string) and sits right after finaid in key order: in utils/schema.yml, which order_keywords follows, and in the pydantic model, whose field order the YAML writer follows. It is also listed with the other optional columns in tidy_conf/validation.py and documented in README.md and CONTRIBUTING.md. No entry uses it yet, so the sort leaves _data unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
When the accumulator branch could not be rebased or merged onto main cleanly, the enrich-tba-conferences and canonical-source-update jobs retried with "git merge origin/main -X theirs", which resolves every conflict in favour of main and silently throws away the data changes already waiting on the branch. Both jobs now collect the conflicted files, abort the merge, report them as "::error title=Branch Sync Conflict::<files>" and exit 1, so a person resolves the conflict. check-conference-update.yml has the same fallback and is left alone here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
registrable_domain only keeps three labels under a suffix listed in TWO_LEVEL_PUBLIC_SUFFIXES. PyCon Tanzania (pycon.or.tz) and PyCon Israel (pycon.org.il, cfp.pycon.org.il) use suffixes that were not listed, so their registrable domain collapsed to the bare suffix and a redirect to any other site under it, such as a parked page on another .org.il domain, counted as the same site and went unrecorded. or.tz and org.il are the two-level suffixes current entries use that were missing; co.il is added with org.il. Regression cases cover both hosts in TestIsDifferentSite and TestRegistrableDomain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
Every forms.gle link redirects to docs.google.com, so the cross-site check recorded the three forms.gle cfp_links in conferences.yml (and the ones on recent archive entries) as different-site redirects on every link check. A person would review the same known-good items again after each run, and upcoming forms.gle links lost the Wayback save that any other healthy link gets. is_expected_redirect now covers links that redirect to another site by design. EXPECTED_REDIRECT_TARGETS lists hosts with a known target: forms.gle must land on google.com and lu.ma on luma.com, so a forms.gle link that lands anywhere else is still recorded. URL_SHORTENERS lists general shorteners (bit.ly, goo.gl, tinyurl.com), whose links may land anywhere. Such a redirect is not recorded and the link is checked as it was before the cross-site check: on a 200 the URL is kept and, for an upcoming conference, saved to the Wayback Machine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
The record_redirect docstring and the earlier commit called each record a queue item, but a queue item also carries an "id" (a sha1 of source, kind, url and payload), which the records leave out. The docstring and the comment on REDIRECT_RECORDS_ENV now say that the JSON lines are not complete items: the queue writer assigns the id, and the series key of the file the item goes in, when it adds a line, so both are defined in one place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
.claude/settings.json registers .claude/hooks/desk_guard.py for Bash, file edits, WebFetch and claude-code-remote tools. It blocks the call, with exit code 2 and a one-line reason on stderr: - in any session, a git push that targets main or master (an explicit refspec, --all or --mirror, or a push without refspec from main); - on auto/conference-updates or with DESK_ROUTINE=1, Edit, Write, MultiEdit and NotebookEdit under .github/; - with DESK_ROUTINE=1, every claude-code-remote tool; - when ~/.desk-guard.json exists, the regular expressions it lists for Bash commands, tool names, file paths and WebFetch URLs. The hook reads the current branch from HEAD, so it needs only the standard library and starts no git process. tests/test_desk_guard.py runs it as a subprocess with crafted stdin, environment and temporary git repositories, a worktree included. .gitignore now tracks the shared .claude/ setup (settings.json, hooks, routines, skills) and keeps everything else under .claude/ local. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
utils/tidy_conf/data/denylist.yml lists domains the critic checks every proposed new series against. The reject list holds twelve domains, each with the facts observed on 2026-10-14 (registration date from RDAP, DNS, what the home page shows). The review list holds pyconf.org, registered 2026-10-08 while claiming a CfP that closed 2026-08-31, which goes to the maintainer instead of being rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
AGENTS.md holds the domain knowledge every agent changing the data needs: which file holds what, what gets listed (Python-focused events of any size, adjacent events only with their own Python track and CfP), how series are named and checked for duplicates, the field rules (cfp versus cfp_ext, timezone only when stated, TBA for vague dates, links that land on the page they name, coc), complete data for past editions, primary-source evidence, the change-set shape subagents return, and the sort mechanics. It points at the desk procedure, the skills and the guard hook. There is no CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
.claude/routines/desk.md is what the daily desk Routine follows: orient, get on auto/conference-updates (merging main, stopping on conflict), open desk-queue in a worktree, build the worklist, read maintainer feedback (acting only on user id 2620316, checked over REST), research each item with one subagent, send every proposal through the critic, commit and push one conference at a time, keep the rolling PR's evidence table and candidates ledger current (or ledger/pending.md while no PR can exist), clean the consumed queue items and report. Every step ends with a completion criterion. The skills under .claude/skills/ each take one item from the desk: enricher (upcoming editions, extensions, import questions, redirects and drift), new-year-checker (series with only past editions), scouter (series the data does not track) and critic (re-verification of every change and a legitimacy screen for every new series, using utils/tidy_conf/data/denylist.yml). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
The guard read the first word after assignments and a few wrappers as
the program, so a push to main inside a for, if, until or while body,
after "!" or "{", or behind "timeout 120" or "sudo -u bob" got through.
The retry loop desk.md asks for is such a loop. command_start now steps
over shell reserved words, "function NAME", assignments and wrappers
(timeout, flock, xargs, sudo, env, nice and others) with their options
that take a value and their operands; env -C and sudo -D move the
directory a bare push is judged in.
Refspecs: a destination glob that covers main (refs/heads/*:refs/heads/*,
*:*), the matching refspec ":" and the shorthand heads/main now count as
targeting main. A push of --tags alone, a --dry-run push and the word
after a "tag" positional send no branch and pass.
.github/ edits: the path is resolved and judged against the root of the
repository that holds it, so a vendored package's .github, a file named
.github, or a .github outside any repository no longer blocks; docs/..
no longer hides the real one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
AGENTS.md called a find tracked when its link's domain appeared in the data, so a new PyData chapter on pydata.org, or any series on events.linuxfoundation.org, pretalx.com, sessionize.com or web.archive.org, would be dismissed as already listed. A find is now tracked only when an entry is the same series: the same name after normalising, or the same link once years are taken out of host and path. On hosts that carry many series only the path decides. The scouter's untracked check follows that rule and names the entry it matched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
The critic restated the timezone rule as "the IANA name for the event's location", which drifted from the data and from AGENTS.md: it would fail a correct "timezone: UTC" (so the deadline fell back to AoE, 12 hours late) and give an AoE deadline a local zone (ending the countdown 13 to 14 hours early). AGENTS.md "Field rules" now gives the mapping: the zone the source states, as its IANA name; UTC or GMT is UTC; a regional abbreviation such as CET is the zone of the event's location; AoE or no stated zone means no timezone. The critic checks each value against those rules instead of restating them, and the new-year checker points at them too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
Step 5 lists comments with --paginate and per_page=100, reads from the
cut-off the previous run recorded when it ran step 5 ("Feedback read
to" in the PR body) rather than from its start time, uses since= on the
two comment endpoints and filters reviews by submitted_at. The desk's
own replies start with "Desk:" and are passed over. A maintainer data
edit goes to the skill that owns the case: the enricher for an edition
in any data file (its step 1 now reads all three), the new-year checker
for a new edition, the scouter for an untracked series.
Step 8 also removes queue items deferred as needs-maintainer or legacy
suspect, since the PR holds the question and the answer comes back
through step 5; leads and failed subagents stay queued. "Pushing" says
how the queue commit replays. The scout item gets one ledger row per
find, and the scouter and new-year checker return steps get a
completion criterion. Scout searches use this year and next from
date -u instead of a fixed 2027.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
vividglobalsummits.com: the old reason quoted a commented-out page title naming one of the organiser's own events. It now records what the home page lists: energy, optics, nanotechnology, food science and public health summits, three in Bern on 15-17 September 2025, and no Python content. codeuk.net: the old reason described the challenge page served to one client, which the critic treats as needs-review, not grounds to reject. The site now renders: a deals and voucher site whose "Python Forum 2025" page names no speakers or organiser and shows shop brands as partners. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
JesperDramsch
added this pull request to stack #502
October 14, 2026 14:19
JesperDramsch
marked this pull request as ready for review
October 14, 2026 14:21
JesperDramsch
marked this pull request as draft
October 14, 2026 23:30
JesperDramsch
marked this pull request as ready for review
October 14, 2026 23:30
Owner
Author
|
Routine review 1. RequestThere is no linked issue, so the PR body is the request. It asks for documents and a hook only, with no
The diff against 2. Checked against
3. Needs your decision
4. FindingsImportant: None. Nits:
5. Tests
6. Bounds
Generated by Claude Code |
JesperDramsch
pushed a commit
that referenced
this pull request
Oct 15, 2026
The routine review of #498 found that the hook raises at import on Python 3.9 and older, exits 1, and Claude Code lets the call through, since only exit code 2 blocks. desk_guard.py now imports annotations from __future__, which lets it run on 3.8 and 3.9, and the settings command turns any failure of the hook into exit code 2 when DESK_ROUTINE=1, so a crash, a syntax error or a missing interpreter blocks the call in a desk run. Other sessions keep the hook's own exit code. A test runs the command with a hook that passes, blocks, raises and does not parse, inside and outside a desk run. The review also noted that a push to main whose branch comes from a command substitution or an outside variable passes in ordinary sessions. AGENTS.md now says the guard blocks the pushes to main it can read from the command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
JesperDramsch
pushed a commit
that referenced
this pull request
Oct 15, 2026
The routine review of #498 found that the hook raises at import on Python 3.9 and older, exits 1, and Claude Code lets the call through, since only exit code 2 blocks. desk_guard.py now imports annotations from __future__, which lets it run on 3.8 and 3.9, and the settings command turns any failure of the hook into exit code 2 when DESK_ROUTINE=1, so a crash, a syntax error or a missing interpreter blocks the call in a desk run. Other sessions keep the hook's own exit code. A test runs the command with a hook that passes, blocks, raises and does not parse, inside and outside a desk run. The review also noted that a push to main whose branch comes from a command substitution or an outside variable passes in ordinary sessions. AGENTS.md now says the guard blocks the pushes to main it can read from the command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr
JesperDramsch
added a commit
that referenced
this pull request
Oct 15, 2026
) * Add desk_queue.py, the reader and writer of the desk queue The desk queue lives on an orphan desk-queue branch: one JSON file per series under queue/, keyed by the slug of the series name, plus a README that init writes from the module so the format is documented in one place. GitHub Actions jobs add records with "add", from JSON lines or from options; the desk removes handled items with "remove". Each item gets a stable id from its source, kind, URL and payload, so the same finding queued twice is one item. A file keeps its 50 newest items and long payload strings are cut at 20,000 characters, flagged with "truncated". The module uses only the standard library, so any job can run it without the data tooling. clean_watch_title and the name lookup move here from desk_worklist.py, which now imports them, so the queue file key and the worklist's series matching read names the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Send changedetection signals straight to the desk queue check-conference-update.yml ran a paid-API triage on every changed page, then two agent jobs that edited the data. The triage passed 26% of events at 38% precision and 58% recall, and never saw the data, so it flagged dates already recorded and dropped extensions and moved dates. The workflow now validates the payload and writes it to the desk-queue branch unchanged; the desk judges it on its next run with the data and the live site in hand. The payload is read from the event file rather than interpolated or passed through the environment, so page text cannot reach the shell and a long diff cannot exceed the environment size limit. The new composite action desk-queue-write is the one place that writes to the queue: it creates the orphan branch when missing, adds records with utils/desk_queue.py, and retries a rejected push from a fresh copy of the branch, never with force. Ids are stable, so a retry adds nothing twice. scheduled-extension-checker.yml goes: the desk's worklist checks every deadline 1, 3 and 7 days after it passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Match cfp_candidates series on normalised names and alt_names A series counted as tracked only when an upcoming entry had exactly the same conference string, so a respelt upcoming edition left the archived series listed as a candidate: archive "Xtreme Python" against an upcoming "XtremePython", and archive "PyHEP.dev" (alt_name PyHEP) against an upcoming "PyHEP". Names are now compared through normalize_conference_name with the titles.yml mappings, ignoring case, accents and whitespace, and a series is tracked when its name or any alt_name of its archived editions matches the name or alt_name of an entry in conferences.yml. Editions are grouped by the same key, so "PyCon Mini Tokai" and "PyCon mini Tokai" are one series. The logic moves into find_candidates() so other scripts can call it; the CLI and its JSON output are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Retire the API-backed full mode of the TBA enrichment The TBA enrichment keeps its deterministic link extraction: social accounts, sponsor pages and financial aid pages found on each conference's homepage. Deadlines, dates and time zones are now researched by the desk, so the full mode, its prompt and its API calls are removed, together with the --level option and the confidence handling only the API results needed. The workflow always runs the deterministic enrichment and no longer needs the API key. The canonical source workflow triggers it without a level input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Add desk_worklist.py, the deadline desk's deterministic worklist build_worklist(data, queue_items, today) turns the conference data and the desk-queue items into one JSON list of work, each item routed to a skill: - enricher: the series has an entry in conferences.yml, or the queue item is a question, redirect or drift. - new-year-checker: the series is only in archive.yml or legacy.yml, or an importer candidate is MISSING_EDITION. - scouter: no series matches, or the item is a NEW_SERIES or MAYBE candidate or a registry change. Queue items are matched on payload names, then the watch title with its "[Conference] " prefix and " YYYY - Main" or " YYYY - CFP" suffix removed, then the queue file's series, using the same keys as cfp_candidates.py. When a name matches several series the item keeps its route and lists the matches in ambiguous_matches. Deadline checks fire 1, 3 and 7 days after the effective deadline (cfp_ext, else cfp), counted from its calendar date in the entry's timezone, AoE when unset. TBA entries that have not started and stale series from cfp_candidates.find_candidates() each come up on one weekday in seven, picked by a SHA-1 of the name. One scouting item closes the list unless --no-scout is given. Items for the same skill, conference and year are merged, and the order is fixed: deadline checks, queue items, rotations, scout. The CLI takes --queue-dir, --base, --today and --no-scout, prints the JSON on stdout and never touches the network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Queue the canonical import's unanswered merge questions for the desk The import asks a person to settle conflicts it cannot resolve: which of two values to keep, whether a later deadline is an extension, whether two similar names are one series. In CI no one is at a terminal, so query_yes_no took the default and the question was lost. query_yes_no now takes an optional context. Without a terminal it still returns the default, so the import stays deterministic, and it also records the question with the conference, year, field, both values and the source. With DESK_QUEUE_OUT set, the record is written as a JSON line, and canonical-source-update.yml adds the file to the desk queue, where the enricher answers it from the live site. The JSON-lines writer moves into tidy_conf/queue_out.py, shared with the link checker's redirect records. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Bind desk queue items to the year of their entry A changedetection watch is titled after the edition it was created for, and later editions reuse the watch, so its signals keep carrying that old year. A signal for "PyCon US 2026" was bound to the PyCon US 2027 entry but kept year 2026: the item contradicted its entry and did not merge with the deadline check or TBA rotation for 2027, so the desk got the same entry twice. A queue item bound to an upcoming entry now takes that entry's year. When no entry matches the queue year, or the item has none, it is bound to the next edition that has not ended rather than the newest one, so a signal for a conference starting next week no longer goes to the TBA entry for the year after. A differing queue year is kept in context["queue_year"]. For the new-year-checker, a queue year at or before the latest known edition cannot be the edition it is looking for. It moves to context["queue_year"] and the item merges with the stale-series item for the series. The module docstring now says how names are matched and that the script needs the same packages as sort_yaml.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Scan links weekly for redirects to another site The link check now records a link that ends on a different site instead of counting a 200 there as healthy, but nothing ran it on a schedule. redirect-scan.yml runs it every Sunday with DESK_QUEUE_OUT set and adds the records to the desk queue. The desk decides each one: a series that moved gets its new link, a lapsed or hijacked domain an archived copy. The scan is report-only. The link check's other edits stay with the writers that commit data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Document cfp_candidates' dependencies and alt_name trade-off Since it normalises names through tidy_conf, cfp_candidates.py needs the same packages as sort_yaml.py, not PyYAML alone. The docstring now says so. A comment and a test name the known cost of counting the alt_names of every archived edition: an edition whose alt_name is its host conference hides the whole series while the host has an entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Remember handled queue items and keep writers going past a broken file The canonical import asks the same standing questions on every run (an end date one day apart, "Brasil" and "Brazil"), and the redirect scan finds the same redirects every week. Ids only deduplicated while an item was queued, so once the desk had handled one, the next run queued it again. desk_queue.py remove now records each id in its file's "handled" list, the last 500 per file, and add reports a handled id instead of queueing it. A changed finding has a new id and is queued as usual. For a redirect the id leaves out the status and the final URL's query and fragment, which can change between checks. A queue file that is not valid JSON stopped every writer bound for it, and under set -e the canonical merge was never committed. add now moves such a file to <key>.json.bad and starts a new one; remove and list skip it. The composite action turns these messages and rejected records into workflow warnings, and the canonical workflow's queue step can no longer hold back the merge. Also from review: - the follow-up "Is this an extension?" question is no longer queued: the cfp question before it carries both deadlines, while its record gave the YAML cfp as the cfp_ext value; - the redirect scan's link check has a step timeout below the job's, so a slow scan still queues what it found; - canonical-source-update gets actions: write, which gh workflow run needs to start the TBA enrichment; - the action matches refs/heads/desk-queue exactly. desk.md step 8 now removes items only through desk_queue.py remove. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Keep duplicates, fakes and link-less rows out of the canonical import Run against the live sources on today's data, the import added: - "XtremePython 2026", a second copy of Xtreme Python 2026, whose alt_name is XtremePython; - "Python Nordeste (PyNE )", a second copy of Python Nordeste 2026: the calendar's "(PyNE 2026)" lost its year and kept the space; - "Python E-Commerce Forum 2026", on a domain the denylist rejects; and it stopped with a validation error on two sheet rows without a website (PyDay Chile Santiago and Rancagua), so nothing was written. Each now has a deterministic fix: - fuzzy_match gives a remote row named by a YAML entry's alt_name that entry's name, unless the alt_name is shared or is itself a YAML name; - normalize_conference_name tidies the brackets year removal leaves; - both importers drop rows on denylisted domains (tidy_conf/denylist.py, the list the critic uses); a row on a review domain is queued for the desk as a candidate instead; - the organizers import leaves out merged rows that still have no link. With these, the full pipeline on today's data exits 0 and adds nothing but the sheet's tutorial deadline for one archived edition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Run the canonical import daily The import only ran when an outside webhook fired, and the calendar's webhook has been quiet since 19 July: PyCon Ireland's move to 21 November (in the calendar since 9 September) and PyCon SK 2027 (since 23 September) never reached the data until they were added by hand. The workflow now also runs every day at 05:37 UTC for all sources. Moved dates come back as merge questions in the desk queue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Add lead importers for pretalx, developers.events and the PyCon registries utils/desk_leads.py writes queue records for the desk from three sources: - pretalx, daily: a drift record when an upcoming entry's deadline is not the instant its pretalx CfP page states (AoE when the entry has no timezone), and a MISSING_EDITION candidate when the next edition's CfP page, guessed from the latest slug, exists. - developers-events, weekly: upcoming Python events the data does not track, as MISSING_EDITION, MAYBE or NEW_SERIES candidates. - registry, weekly: names on the PSF PyCon trademark list and events in the pycon.org registry that no data file tracks. Only the public /<slug>/cfp pages of pretalx are read, a second apart, because its robots.txt disallows /api/. developers.events data is CC BY-NC 4.0, so its records carry only the event's name, year and link and are flagged lead-only. A find is tracked by the rules in AGENTS.md "Names", across all three data files; a shared host such as pydata.org or pretalx.com proves nothing on its own. The desk-leads workflow adds the records to the desk-queue branch. Nothing reaches the data without the desk checking it on the primary source. The queue drops the callingallpapers and psf-ics-drift sources, which are no longer planned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Count a developers.events event as Python by its name only A live run gave 23 leads, 22 of them from the "python" tag alone: DevFests, EnvoyCon, SQLSaturday, an Odoo hackathon. These are general events with a Python talk, which AGENTS.md leaves out, and each would cost the desk a scouter visit. The name match stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Fix what review found in the import safeguards and lead importers - Candidate ids leave out "matched": it says which file holds the matched entry, so a lead got a new id, and was queued again, when the sort moved that entry to the archive. - A pretalx drift stores the deadline and zone instead of the sentence, which turns from "close" to "closed" once the deadline passes, and compares to the minute, as the page shows: 23:59:59 is its 23:59. - Names match Python words inside longer words: the live feed has EuroPython, EuroSciPy and DjangoCon US, which \bpython\b missed. - apply_alt_names learns the series names of every year. A remote name that is a series of its own is renamed only when it starts on the same day as the entry with that alt_name: the sheet's "PyHEP" row of 7 September 2026 is PyHEP.dev 2026, a PyHEP workshop on other dates is not. - Sheet rows left out for want of a website go to the desk queue as candidates, so they are looked at instead of dropped unseen. - The denylist reads links without a scheme, and a review candidate names the link that matched. - A pretalx page answering 401, 403 or 451 counts as a failure, so a blocked job fails as a whole; desk-leads turns red when a source fails, after queueing what the others found. - canonical-source-update and enrich-tba merge main into the rolling branch instead of rebasing it, and push without force: the desk pushes there too, and a daily rebase would rewrite its commits. The full pipeline on today's data still exits 0 and adds or removes no entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Fix five faults in the daily canonical import - Country names were replaced inside every field the merge compared, on every row, matched or not. A Google Form id with "GB" in it became "UK", and the alt_name "PyCon Korea" became "PyCon South Korea", then "PyCon South South Korea" the next day. merge_conferences now normalises country names in the place field only, and the unused COUNTRY_NORMALIZATION import is gone. - merge_duplicates keyed on the raw place. The importers write a copy of each current-year edition with its place normalised, so an archived edition stored with "London, United Kingdom" kept a second entry, and the duplicate stopped the next importer. The key now uses normalize_place, and the first entry keeps its stored place, so no stored data is rewritten. - An upcoming entry with two or more extra_places or location items stopped both importers: merge_conferences passed a list to pd.isnull and tested the result for truth. Lists and mappings are now merged before the null checks. The YAML value is kept unless it is empty, and lists are compared by equality. write_conference_yaml had the same fault with pd.notnull and failed next, so it is fixed as well. - In January both importers rebuilt conferences.yml from this year's editions only, so last year's editions that the sort had not yet archived vanished and check_data_loss failed the job. Both importers now carry over the earlier editions of conferences.yml unchanged, and the sort archives them on schedule. - check_links never checked coc. It does now, coc is in REDUNDANT_LINK_FIELDS, since a homepage is not a Code of Conduct page, and the docstrings that list the link fields name it. tests/test_import_daily.py reproduces each fault. The link checking test in test_integration_comprehensive.py and the URL format test in test_yaml_integrity.py now include coc. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Snooze queue leads, requeue manual checks and route candidates by match Fix three defects in the desk queue and worklist. Leads stayed queued and were researched again every day. desk_queue.py gains a snooze command and snooze_items(), which set next_check on the named items and report unknown ids on stderr. next_check is not part of the id, so adding the same finding again keeps the item and its date. build_worklist leaves out queue items whose next_check is after today; a missing or invalid date means the item is due. The queue README documents next_check. A repeated manual "check this conference" run was taken as already handled, because its payload was the same each time. The workflow now adds the run id as "requested" to the payload of a workflow_dispatch run only, and the README table lists it. Candidate routing overrode the worklist's own match: MISSING_EDITION always went to the new-year-checker and MAYBE always to the scouter, even for a tracked series. A candidate whose names match one series now goes to the enricher with the edition for its year when any data file holds it, past editions included, else to the new-year-checker, which keeps the year when it is later than the latest edition. A candidate that matches nothing goes to the scouter. Only an ambiguous match still follows the classification. The module docstring and the tests, including the leads round trip, follow the new routing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Merge the rolling branch's data entry by entry and retry its pushes Fixes these defects in the rolling-branch tooling: 1. Main and auto/conference-updates sort the data independently, so once both sides have sorted an entry the rolling branch edited, every `git merge origin/main` conflicts in _data. New utils/merge_data.py runs while the merge is stopped. It exits 2 when a conflicted path is outside the three data files, keys every entry on (conference, year) across the files at the merge base, HEAD and MERGE_HEAD, and merges them field by field. It stops with each conflict on stderr when both sides changed a field differently, and otherwise writes the result, runs sort_data(skip_links=True), checks that no key was lost and stages the files. The old pairs the base already repeats (PyCon China held in several cities in one year, RuPy 2012) are told apart by place, since the real data has them; any other repeated key is an error. 2. Both writers fall back to merge_data.py when `git merge origin/main` fails, and still abort and report the conflicted files when it cannot resolve them. Setup Pixi now runs before the branch setup. 3. Both writers share the concurrency group auto-conference-updates and retry a rejected push up to five times: fetch, merge the remote branch (with the same fallback) and push again, never forcing. When the branch existed at setup and is gone, or no longer holds the commit the run started from, the rolling PR was merged and the job stops instead of pushing old history. The PR row keeps linking the data commit. 4. enrich-tba-conferences.yml fails the step when the enrich command fails. 5. link-check.yml keys its concurrency group by ref, so pushes to the rolling branch no longer cancel the weekly run on main. 6. The desk's PR body update lost rows the writers appended between its read and its PATCH. New utils/desk_pr_body.py reads the body, splices the desk's part in front of everything after the first DESK_END line, byte for byte, and PATCHes it in one command. It refuses a desk file that holds the marker, and --print shows the result without writing. Tests cover the merge on in-memory lists, the command line in temporary git repositories (including the reported sort conflict end to end), the splice and the PR body command line with subprocess mocked. ruff still reports S404 and S603 for the one subprocess call in each new module. They need the noqa comments git_parser.py uses, left for review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Tighten the desk guard for tools, pushes, gh, subagents and the deny list The cross-stack review found five gaps in .claude/hooks/desk_guard.py and the matchers that send calls to it. This fixes each one and reuses the existing command parser for the new Bash rules. 1. Tools that never reached the hook. settings.json sent only Bash, the file-edit tools, the claude-code-remote tools and WebFetch, so GitHub MCP tools and CronCreate, CronDelete, RemoteTrigger and ScheduleWakeup ran unchecked. The remote-session matcher is now the regular expression "mcp__.*|Cron.*|Remote.*|Schedule.*". In a desk run the hook blocks every mcp__ tool and CronCreate, CronDelete, CronList, RemoteTrigger and ScheduleWakeup. Outside desk runs they pass as before. 2. The deny list could be switched off. A missing ~/.desk-guard.json (deleted, moved or a dangling link) now blocks every desk call with a clear reason, as a malformed or unreadable one does. deny_paths are matched against the path as given and against the absolute path it names from cwd, with ~ expanded and ., .. and repeated slashes removed, so .claude//hooks/x and .claude/./hooks/x no longer slip past. 3. Subagents had the desk's write powers. When agent_id is in the hook input of a desk run, git runs only its read-only subcommands (and config --get), gh only as a gh api read, and commands that run desk_queue.py add, remove, snooze or init, desk_pr_body.py, merge_data.py or sort_yaml.py (also through pixi run sort or links) are blocked. Edit, Write, MultiEdit and NotebookEdit are blocked inside the repository that holds cwd or the path, symlinks resolved; paths such as /tmp stay open. 4. The main agent's pushes and gh calls were wider than desk.md allows. In a desk run git push must name origin and refspecs that write auto/conference-updates or desk-queue, also through git -C. Force, force-with-lease, -f in any bundle, delete, mirror, all, branches, tags, follow-tags and prune are blocked, as are refspecs that start with + or :. gh runs only as gh api, and its writes are limited to POST pulls, PATCH pulls/<n>, POST issues/<n>/comments and POST issues/<n>/labels on repos/JesperDramsch/python-deadlines, with or without a leading slash or the https://api.github.com/ prefix. Outside desk runs only the old rules apply: no pushes to main or master, and no .github/ edits on the rolling branch. 5. The module docstring now lists exactly what is enforced, for every session, for desk runs and for subagents in desk runs. The tests cover each new block, every command desk.md gives the desk, the desk scripts in the main agent and in a subagent, the missing deny list, path normalisation, the subagent rules, and the settings matchers read the way Claude Code reads them. HOME in the tests now holds an empty deny list, since a desk run without one blocks every call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * docs: fix what review found in the desk procedure and its skills desk.md: - Feedback cut-off: this run's section carries the previous run's "Feedback read to" until step 7, which records this run's cut-off only when every maintainer-request item is actioned, dismissed or deferred on a needs-maintainer result or verdict. Maintainer requests go to the front of the worklist, so an interrupted run or a failed subagent no longer loses them. - Identity: step 5 acts only on entries with user id 2620316 posted through no GitHub App. Issue comments carry performed_via_github_app, which must be null; review comments and reviews carry no such field (checked with gh api), so the body check decides there. Bodies that start with "Desk:" or "<!-- claude-routine-review -->", or carry the Claude Code attribution footer, are passed over. - Step 2 no longer checks out an old rolling branch, which removed .claude/ and the guard from the working tree. When the branch lacks main, it builds from main's side and merges the branch in, still a fast-forward, and checks that the guard exists afterwards. - Merges in step 2 and "Pushing" resolve data conflicts with utils/merge_data.py, and abort and report on any other exit. - Every comment and PR body, decline replies included, is posted from a scratch file with -F body=@<file>, never inline. - Without a desk-queue branch, steps 7 and 8 leave the run's section in the final report; the writers create the branch on their next run. - Step 8 snoozes the queue items behind leads for seven days with desk_queue.py snooze. Failed and unreached items stay without one. - "Pushing" checks before each push that the rolling branch still holds what this run last pushed. When it is gone or was opened again from main, it starts again from step 2 and cherry-picks only this run's unpushed conference commits. - PR body updates go through utils/desk_pr_body.py, so the read and the write happen in one command. - The ground rules and the AGENTS.md hook line list the guard's wider coverage in desk runs. Skills: - new-year-checker returns no-change only when the data holds the edition for the item's year, or an edition that has not ended when the year is null, and searches from the queue items first. - enricher: a question on "conference" returns needs-maintainer naming the entry the import wrote and the titles.yml variation to add, or no-change. - enricher: drift comes from pretalx, the platform that enforces the deadline; a later date goes in cfp_ext, and the conference's own prose stating another deadline is flagged sources-disagree. - The enricher and new-year-checker cite only the maintainer-request comment the desk passed through step 5. - The scouter and new-year-checker explain the lead-only, no-website and denylist-review candidate flags, and the critic returns needs-maintainer for a denylist-review flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Mark the subprocess calls in merge_data.py and desk_pr_body.py Both run git or gh from an absolute path found with shutil.which and a fixed argument list, the same pattern utils/git_parser.py marks with noqa: S404 and S603. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Make name normalisation idempotent and treat shared-host tenants as sites - normalize_conference_name tidied brackets before "+" became a space and before whitespace was collapsed, so "0+)" gave "0 )" and then "0)". The bracket tidy now runs after the "+" replacement, the Conf expansion and the whitespace collapse, just before the final strip, and collapses whitespace again. It removes emptied brackets until none are left, so "X (( ))" gives "X" in one pass. The bracket cases in test_import_safeguards.py keep their results, and new tests check that "0+)", "X ( 2026 )", "A+(B)" and "X (( ))" are stable on a second pass. - The merge_duplicates docstring said the first entry keeps its stored place and no stored data is rewritten. The first entry is usually the importer's copy from conferences.yml, so its normalised spelling replaces the archive's. The docstring now says so. Behaviour is the same. - test_interactive_merge_uses_country_normalization claimed that interactive_merge uses COUNTRY_NORMALIZATION, which it no longer does. It is now TestCountryNormalizationMapping and test_long_country_names_map_to_short_forms, with a docstring that says what it checks. - The URL format smoke test now checks coc, as test_yaml_integrity.py does. - github.io, gitlab.io, netlify.app, pages.dev, vercel.app, readthedocs.io and org.pl are in TWO_LEVEL_PUBLIC_SUFFIXES, so tenants of those hosts are different sites: pyconx.github.io and squatter.github.io, or pycon.org.pl and casino.org.pl. test_link_redirects.py covers each suffix and a redirect from one github.io tenant to another. No current data link changes behaviour: only three legacy entries use github.io, and the sort leaves _data unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Keep snoozed queue items through the cap and list next_check - A re-found finding keeps its first "received", so a lead snoozed for long is among the oldest items in its file. Once the file went over 50 items, trimming dropped it, the next add queued it again with no next_check, and it was due again. add_items now keeps items whose next_check is after today ahead of the rest, then the newest by received. It takes a today argument, defaulting to the current UTC date. Tests cover a kept lead, a lapsed snooze treated as due, and the UTC default. - desk_queue.py list now prints next_check, null when unset. The list test checks a full row. The README in desk_queue.py says that snoozed items survive the cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Keep CRLF in the PR body and key repeated pairs on any side desk_pr_body.py: - Run gh without text mode and decode its output as UTF-8 here, so the CRLF and lone CR of a body edited on GitHub reach the PATCH file byte for byte. stderr is decoded with errors="replace" for messages, output that is not UTF-8 is a gh failure, and --print writes bytes too. A new test puts a gh script on PATH that prints a CRLF body and checks the file it is sent. - Refuse a desk part holding <!-- END_UPDATES --> or <!-- REVIEW_ITEMS -->, as well as <!-- DESK_END -->. The writers add rows at the first of those two, so the next splice would drop them. merge_data.py: - Tell apart by place the pairs that base, ours or theirs repeats, not only those the base repeats, and key places with normalize_place. Places that differ only in the country's spelling are one key, so a place repeated on one side after normalising is still an error. The check after the sort compares the same keys, with normalised places, since the sort folds entries whose places differ only that way. canonical-source-update.yml and enrich-tba-conferences.yml: - Drop unstaged changes to tracked files with git checkout -- . after the data commit and before the push retries. pixi run merge can change titles.yml and rejections.yml without staging them, and a retry merge refuses to run when the fetched branch changed them too. - Note in canonical-source-update.yml that the shared concurrency group keeps one pending run, so a later canonical run can replace a queued enrich run. The daily enrich cron limits the loss. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Read option abbreviations, aliases and dashed git in the desk guard Fixes the round 1 review findings on .claude/hooks/desk_guard.py. 1. Abbreviated push options. git accepts any unambiguous prefix of a long option, so --force-w, --force-with, --del, --pru, --tag, --follow-t, --mirr and --al passed the push rules. A long flag now counts as an option when it is a prefix of it, with or without =value, in the desk push rule and in the every-session rule. An abbreviated option that takes a value (--push-o) now takes it, and a later --no-dry-run or --no-tags ends those exemptions. A new test runs each blocked spelling with real git against a local bare remote and checks the harm it does. 2. git config --get. A subagent's git config counts as a read only when --get comes before the first word that is not an option, since "git config user.name --get" writes "--get". 3. Aliases and dashed programs. A program named git-<sub> in any folder now counts as git <sub>. Desk runs block -c and --config-env settings of alias keys and git config calls that write one. Outside desk runs an alias set with -c is expanded before the push rule, shell aliases included; an alias named push is left alone, as git ignores it. 4. Docstring. Rule 12 says Bash commands that write into the repository are not inspected. The closing paragraph covers aliases, commands git runs from its config, and programs other than git and gh, such as curl. 5. Subagent scripts. The rule now catches python -mutils.sort_yaml and other attached -m forms, utils/main.py (only in a folder named utils) and pixi run merge. 6. --recurse-submodules, with any value, is forbidden in desk pushes, and the push parser reads its separate value word. 7. A subagent's git fetch is blocked when a refspec after the remote contains ':' or starts with '+', and so are --refmap and --stdin, which set refspecs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * docs: fix round 2 review findings in the desk procedure and skills desk.md: - Pushing, merged PR: a failed fetch of a deleted rolling branch keeps the old origin/auto/conference-updates ref, so step 2 merged the squashed history back in. The desk now deletes that ref with git update-ref -d before going back to step 2, and decides whether the branch has been on origin with git rev-parse -q --verify. Step 1 deletes the old ref of any branch whose fetch fails, so that check means "on origin in this run", and step 2's done-when uses it. Walked in a scratch repo with a bare remote, a squash merge and a branch deletion, and with a branch a writer opened again from main. - Step 7: a maintainer request settles when its ledger row is actioned, dismissed, or deferred for any reason but a failed subagent. Only failed or unreached requests hold the "Feedback read to" cut-off back. - Step 5: reviews and review comments carry no app attribution in REST, so for them only the user id and the body checks apply. enricher: - Covers an edition the data holds in any of the three files, past editions included (description, intro and step 1). - New candidate reason: the payload's fields and evidence_url are leads for this edition, its flags mean what the scouter's step 1 says, and denylist-review is copied into the change set's flags. - Drift: when cfp is TBA the pretalx deadline is the cfp, a later date goes in cfp_ext, and an earlier one corrects cfp only when a source states it as the original deadline, otherwise sources-disagree. The pretalx URL is the queue item's url. new-year-checker: - Step 1 looks for the edition context.queue_year names when year is null and the data lacks that edition, since past editions are recorded too. AGENTS.md: - The change-set flags comment lists denylist-review, copied from the candidate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Fix round 3 review findings in the skills, workflows and helpers - new-year-checker: a missing-past-edition candidate merged with the series' stale-series item now looks for the edition after latest_edition as well and returns an outcome for each. Step 3 decides and checks the outcome of each edition looked for, not one per series. - new-year-checker: the year variants bullet uses the year of the edition looked for in place of the latest one's, which may be an earlier year. - desk.md step 1: the done-when names branches on origin and not on origin instead of fetches that succeeded, and a fetch that fails for another reason, such as the network, keeps its ref. - enricher: the drift opening says a later pretalx date wins over the homepage, in line with the bullet on earlier pretalx dates. - desk_pr_body.py: the docstring and the RESERVED comment give the real reason for refusing the writers' markers. The writers add rows before the first END_UPDATES and the enrich writer replaces the section from the first REVIEW_ITEMS to the next END_REVIEW_ITEMS, so either marker in the desk's part puts their writes above DESK_END or deletes it. Behaviour is unchanged. - canonical-source-update.yml and enrich-tba-conferences.yml: the concurrency comments say a newer run of either workflow replaces the pending one, so the enrich run canonical starts at its end can replace a pending canonical run, and that both daily crons limit the loss. - desk_queue.py add_items: the kept ids skip items that are not objects, so a stray item in a queue file no longer makes add raise after writing. Tested. - test_desk_queue.py: rename test_trimming_reads_today_as_the_utc_date to test_trimming_defaults_today_to_the_current_date, since frozen time cannot tell UTC from local. - sort_yaml.py merge_duplicates: a later duplicate's empty value (None or "") for a key the first entry lacks raised KeyError. It now adds no field; every other case merges as before. Tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Look up denylist domains by key in the loader test CodeQL reads `"codehut.co.uk" in denylist[...]` as a URL substring check. The lists are mappings from domain to reason, so index them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Route each missing new-year-checker edition to its own item - desk_worklist.py: a new-year-checker queue year at or before the latest edition that names an edition no data file holds now stays the item's year. Before, it became year null with context.queue_year, and _merge folded every null-year item of a series into one, keeping the first queue_year only. Two candidates for missing 2023 and 2022 gave one item naming 2023; a watch signal for held 2025 received before a candidate for missing 2023 gave one item with queue_year 2025, so 2023 was never looked for; and a missing-year candidate merged with the stale-series item asked for two outcomes from one item, which has one Candidates row and whose queue items step 8 removes or snoozes together. Now year is the edition the data lacks, and null means the next one. A queue year naming an edition the data holds still gives year null with context.queue_year and merges with the stale-series item. The module and _route_queue_item docstrings say so. Tests cover the three cases. test_missing_year_before_the_latest_edition_is_moved_to_context becomes test_missing_year_before_the_latest_edition_is_kept: it asserted the old behaviour (year null, queue_year 2024) for a 2024 candidate the data lacks, which was the bug. - new-year-checker SKILL.md: back to one edition per item. Step 1 takes the item's year, past editions included, else the edition after latest_edition; the queue_year case and the stale-series sentence are gone, and the intro, the step 2 bullets, step 3 and step 5 name one edition. The description names the year the worklist item names. - desk.md step 1: a fetch that fails for another reason is run again up to five tries, then the run stops and reports, as "Pushing" does. Nothing else in desk.md assumes two outcomes per item. - sort_yaml.py merge_duplicates: a later None counted as the string "None" and replaced any shorter value, so a blank timezone on an archive copy replaced UTC and the deadline read as AoE. An empty later value (None or "") now replaces nothing in either case, and any later value replaces an existing empty one. The longer-value rule and place handling stay. Tested with timezone UTC and sub PY. - desk_pr_body.py: the docstring and the RESERVED comment add the third outcome of a reserved marker: with REVIEW_ITEMS in the desk's part and no review section in the writers' part, the enrich writer writes no review section, since it skips the insert and the replace finds no END_REVIEW_ITEMS after the marker. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Start a conference's next item only after its earlier one is pushed On a series' stale-series rotation day, a candidate for the next edition's year and the stale-series item are two worklist items that look for the same edition. Researched at once, both could return a new-edition set for it. desk.md step 6 now starts an item only once every earlier item of its conference has finished step 4, so the second subagent sees the first one's entry and returns no-change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Block git config changes in desk runs and close guard gaps 1. Desk runs make no git config changes. The guard now blocks every -c and --config-env setting on a git command, every git config call that is not a pure read (--get, --get-all, --get-regexp, --list or -l before the first word that is not an option or an option's value), and every word that assigns a GIT_CONFIG* environment variable: a leading assignment (NAME=value or NAME+=value), an env operand, or an export, declare, typeset, local or readonly argument. This replaces the alias-only blocks and closes the alias, include.path, includeIf, help.autocorrect, push.*, remote.* and url.* routes. config_reads skips the value of --file, -f and the other options that take one, since git config --file --get name value writes. builtin now counts as a wrapper. No command in desk.md uses git -c, git config or GIT_CONFIG*, and a test now checks that. 2. Subagents get the same config blocks, and git fetch <remote> tag <name>, which writes a local tag, is blocked for them. 3. Outside desk runs the alias map is read again after each expansion, so an alias set by a -c inside another alias's value is expanded too. Expansion stops at push, at a name already expanded and at an unknown name. git -c 'alias.p=-c alias.q=push q' p origin HEAD:main is now blocked and is in the main-push tests. 4. -m counts as a pixi run option with a value, so pixi run -m pixi.toml sort reads as the sort task. utils/import_python_official.py and utils/import_python_organizers.py, which write _data, join the scripts subagents may not run. The docstring rule says so. 5. The docstring's closing paragraph says that, outside desk runs, config that sets push targets or options is not read, an autocorrected subcommand is not inspected and aliases from config files are not expanded, and that desk runs block all of these by refusing config changes. The rules are renumbered for the new config rule. 6. The desk.md commands git update-ref -d refs/remotes/origin/auto/ conference-updates and git rev-parse -q --verify origin/auto/ conference-updates are in the main agent's allowed list, and update-ref -d is in the subagent's blocked list. The nested alias push, the tag fetch and the git config --file --get write are also run with real git against a local bare remote in the tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Tighten the desk guard after review and state its shell limits A follow-up to the git config fix. It covers the two review findings and two gaps found while checking them. 1. The docstring did not say which ways of setting a GIT_CONFIG* variable go unseen, so rule 8 read as if it covered every route. The closing paragraph now names read, printf -v and set -a. It also says the string env -S takes is not split, so a command or assignment inside it is not inspected. Rule 8 now says that git config get and git config list, the read subcommands of newer git, are blocked. A test checks that the docstring names each route and that the hook lets it through, and the newer subcommands join the desk config changes in the tests. 2. A subagent's -c, --config-env or GIT_CONFIG* assignment got the message for desk runs. It now says "for subagents in desk runs", as git config writes already did. The config change test checks the wording for both the desk and its subagents. 3. In desk runs, an export, declare, typeset, local or readonly argument that names a GIT_CONFIG* variable without a value is now blocked. printf -v or read sets the value and export NAME hands it to git, so git p origin HEAD:main pushed main. The tests run that route with real bash and git against a local bare remote. 4. takes_value read a short option cluster by its last letter. An attached value that ended in the letter of an option with a value took the program as that value, so sudo -uroot git push origin main, timeout -k5s 120 git push origin main and env -uS git push origin main all passed. The first option in the cluster that takes a value now decides. The three commands join the main-push tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Harden the desk guard against env split-string and chdir clusters - env split-string (-S, --split-string, -S inside an option cluster, and the --split-string=STRING form): the hook did not split the string, so a push hidden inside it passed in every session. Desk runs now block the option outright, since the desk procedure never uses it. Outside desk runs the string is split the way GNU env does (whitespace-separated words, single and double quotes, backslash escapes with a backslash-underscore separator and a backslash-c end, a ${VAR} left as an unresolved word) and its words are read as the rest of the command line, so the push rule sees them. A string that cannot be split, or that holds a variable where the program or subcommand would stand, is blocked when any word could be git or push. - env chdir options bundled into an option cluster (env -iC dir, env -C dir, env --chdir=dir) now move the directory the hook resolves git -C and paths against, as the separate -C and --chdir forms already did. - AGENTS.md now records that desk runs block git config changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Guard send-pack and http-push, and resolve inline push refspec variables Testers found two ways past the push rules that keyed on the single subcommand "push". - git send-pack and git http-push write remote refs with the same remote and refspecs as push, but git_reason only routed "push" into the push rules, so send-pack (and the dashed git-send-pack) and http-push reached main or master in ordinary and desk runs, forced or not. They now go through the same push and desk-push rules via a PUSH_SUBCOMMANDS set, so rule 1 catches a protected destination and rule 7 catches a non-origin remote or a force in desk runs. Subagents already blocked both. - A push refspec carried by a shell variable (BR=x:main; git push origin $BR) slipped through because the token "$BR" was read as a literal branch name. A $NAME or ${NAME} built from a literal NAME=value assignment earlier in the same command line is now resolved before the push rules run. A name set outside the command line stays unresolved, as the docstring states, so dynamic feature-branch pushes keep working. Rule 1, rule 7 and the module note record what the push rules now cover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Guard subagent repository writes in desk runs Subagents in a desk run were meant to be read-only, but a Bash command's redirections and file commands were not inspected, so a redirect or a file-writing program could change the checkout or the desk-queue worktree, the hook file included. - desk_guard.py: add rule 14. For a subagent in a desk run, a Bash command may not write a path inside a repository, by the same "inside a repository" test rule 13 uses for the file tools. It reads output redirections (>, >>, >|, &>, &>>, n>, n>>, <>) and the file-writing programs tee, cp, mv, rm, rmdir, touch, mkdir, ln, chmod, chown, chgrp, truncate, dd (of=), install, rsync, patch, shred, split, unlink, sponge, sed -i/--in-place, perl -i, unzip and tar extraction (-x). /dev/null and the standard streams are allowed, cd and wrapper chdir move the base a relative target resolves against, and an unresolvable target (variable, glob, brace, command substitution, here-string) fails closed. split_commands now decomposes each run of shell punctuation into its operators and carries the write-redirect targets of every simple command. - desk_guard.py: update rule 13 and the closing paragraph; state the interpreter-inline limit (python -c, a here-document, node -e, perl -e are not parsed), covered by the desk's own checks. - AGENTS.md: name what a subagent is blocked from writing, in place of "every repository write from a subagent". - test_desk_guard.py: a row per write form blocked inside the checkout and allowed under /tmp; /dev and unresolvable-target rows; cd, wrapper chdir and inline-variable tracking; research reads kept passing; and real-shell tests showing a redirect and a cp into the checkout do write when run directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Add tamper, stray-write and queue checks to the desk run The guard cannot read every write a subagent's shell commands make, such as one from inline interpreter code, so desk.md now checks the working trees itself. - Tamper check (steps 6.1, 6.2 and 6.3): after each research subagent and each critic returns, and before each data commit, `git status --porcelain -- .claude AGENTS.md .github utils pixi.toml` must print nothing. Anything it prints stops the run with nothing more committed, pushed or posted, and the paths go in the final report. - Stray-write check (step 6.3): before each data commit, `git status --porcelain` and `git diff` may show only this conference's accepted entries and fields and the sort's own work. Anything else is restored with `git checkout -- <path>` or `git clean -fd -- <path>` and noted as "stray write: <paths>" in the item's ledger row and the final report. A stray write in a data file restores all three data files, and the edits and the sort are redone, up to five tries. - Queue check (step 7): `git -C QUEUE status --porcelain` must print nothing before step 8 writes the queue. Anything it prints is restored with `git -C QUEUE checkout -- .` and `git -C QUEUE clean -fd` and noted as "stray write in the queue: <paths>" in the final report. Skipped without a desk-queue branch. Step 9 and the Ledger reference name the new notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Close gaps in the desk's tamper, stray-write and queue checks Review fixes to the checks added in 51e7597, all in desk.md. - Staged stray writes: the stray-write check compares `git diff HEAD`, not `git diff`, and restores with `git checkout HEAD -- <path>` and `git checkout HEAD -- _data/conferences.yml _data/archive.yml _data/legacy.yml`, so a change staged through an interpreter shows and is undone. Step 7 restores QUEUE with `git -C QUEUE checkout HEAD -- .`. - Sort's own work: it is named as the fields the sort drops or tidies and the `sub` and `location` it adds inside this conference's entries, and moves and reorders elsewhere. A change that comes back after a restore is a stray write still being made, so the five-try limit applies. - Ignored files: the tamper check now reads `git status --porcelain --ignored --untracked-files=normal` over the guard and procedure paths and every CLAUDE*.md. Step 4 saves its output to <scratch>/tamper.txt before any subagent starts; later lines not in that file, apart from the two __pycache__ folders the sort creates, are tampering. - Check before the sort: step 6.3 starts with the tamper check and `git status --porcelain --untracked-files=normal`, which must print nothing before the desk edits. REVIEW.md joins the tamper paths. - Commits made by a subagent: the desk notes HEAD at the end of step 2 and after each commit, rebase, reset or cherry-pick, and QUEUE's HEAD at the end of step 3. The tamper check and the queue check compare HEAD with the noted commit and stop the run on a mismatch. - Git directory: step 3 saves sha256 hashes of the shared git directory's config, hooks and info files. Every tamper check starts by comparing them, before any other git command. - After the commit: `git show HEAD` must hold only this conference's accepted edits and the sort's own work, or the run stops unpushed. Later stray writes are restored without applying the edits again. The done-when names the check. - Values: changes must carry the values the accepted sets give. - Steps 7 and 8: step 7 runs the tamper check with the queue check. Step 8 compares `git -C QUEUE status --porcelain` and `git -C QUEUE diff HEAD` with its own changes before the commit, and `git -C QUEUE show HEAD` before the push. The tamper check's parts now live in a "Tamper check" reference section, which steps 6.1, 6.2, 6.3 and 7 name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Split the desk's apply step into ordered sub-steps Step 6.3 had grown into one paragraph holding the checks before the edit, the edit, the stray-write check, the restore, the commit and the check after it. It is now six numbered sub-steps with its done-when below them. The wording of each check is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Start desk subagents as a desk-research type with no shell or file tools - .claude/agents/desk-research.md (new): the subagent type for every desk researcher and critic. Its tools are Read, Grep, Glob, WebFetch and WebSearch, so it has no Agent, Bash, Edit, Write or MCP tools. Its short prompt says it reads AGENTS.md and the skill its task names, treats what it fetches as data, writes nothing and returns its result as text. The file is force-added, since .gitignore ignores .claude/* apart from the folders it lists. - desk.md: steps 6.1 and 6.2 start each research subagent and each critic with the Agent tool and subagent_type desk-research, and the ground rules say so. The guard bullet names the new blocks, and the tamper and stray-write checks stay, to cover a failure of the type restriction. Step 6.1 also passes the worklist's today date to the subagent. - scouter skill: take this year from the worklist's today date instead of running date -u, which a subagent without Bash cannot do. - desk_guard.py: new rule 10 blocks an Agent or Task call in a desk run whose subagent_type is not in DESK_AGENT_TYPES ({"desk-research"}), or that has none, and names the type to use. New rule 11 blocks every call from a subagent whose agent_type is not in DESK_AGENT_TYPES or is missing. The subagent rules on git, gh, the desk scripts and repository writes become rules 12 to 16, a second layer that matters only if the type restriction fails. Outside desk runs, Agent and Task calls pass without any check, so nothing changes there. - settings.json: a new "Agent|Task" matcher sends those calls to the hook, in the exact-name form the other entries use. Every tool sent before is still sent. - tests: Agent and Task calls with desk-research, another type and none, in and outside desk runs; calls from desk-research, general-purpose and other subagents and with agent_type missing; the deny list on Agent calls; the agent file's frontmatter and tools; every subagent_type desk.md names; and the Agent and Task routing in settings.json. The existing subagent tests now run as a desk-research subagent, so they test the second layer. - AGENTS.md: one line for .claude/agents/desk-research.md, and the desk_guard.py line names the new blocks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Track .claude/agents and pin the desk-research frontmatter keys .gitignore ignores .claude/* apart from the folders the desk setup uses, and .claude/agents/ was not among them, so a new agent file would not be picked up by git add. The frontmatter test now also checks that desk-research.md sets only name, description and tools, so a field added later is noticed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Fail closed when the guard itself fails in a desk run The routine review of #498 found that the hook raises at import on Python 3.9 and older, exits 1, and Claude Code lets the call through, since only exit code 2 blocks. desk_guard.py now imports annotations from __future__, which lets it run on 3.8 and 3.9, and the settings command turns any failure of the hook into exit code 2 when DESK_ROUTINE=1, so a crash, a syntax error or a missing interpreter blocks the call in a desk run. Other sessions keep the hook's own exit code. A test runs the command with a hook that passes, blocks, raises and does not parse, inside and outside a desk run. The review also noted that a push to main whose branch comes from a command substitution or an outside variable passes in ordinary sessions. AGENTS.md now says the guard blocks the pushes to main it can read from the command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Rotate only TBA deadlines and count deadline checks from the UTC date From the routine review of #499: - tba_rotation compared cfp with sort_yaml.TBA_WORDS, which also holds "none" and "cancelled". AGENTS.md defines cfp: None as an edition with no public CfP and Cancelled as a cancelled edition, so four upcoming editions (PyCon mini Tokyo, PyLadiesCon, FastAPI Conference and PyCon Davao 2026) got a weekly enricher item asking for a deadline that does not exist. Only TBA and TBD rotate now. Over the week of 12 to 18 October on the real data, those four items are the only change. - deadline_checks counted days from the deadline's calendar date in the entry's timezone, while today is the UTC date. 23:59 AoE on D is 11:59 UTC on D+1, so the desk's run at 02:17 UTC on D+1 sent a 1-day check while the CfP was still open, and an extension announced in the last hours waited for the 3-day check. Days now count from the UTC date the deadline falls on, so every check comes after the deadline has passed, whatever time of day the desk runs. - A question, redirect or drift always goes to the enricher, and for a series with no upcoming entry the item had entry null and no word on why. It is now bound to the edition for its year in any data file, and otherwise context["no_entry"] says that no data file holds the edition or the series. Each new test fails on the previous code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Leave cancelled editions out of the TBA link enrichment find_tba_conferences picked every entry whose cfp is in TBA_WORDS, which also holds "cancelled", so the job visited the homepage of a cancelled edition and could fill its sponsor or finaid link. It now leaves cancelled editions out. Entries with cfp None stay in: since the API mode is gone, the job only fills Bluesky, YouTube, Mastodon, sponsor and finaid links, and an edition with no public CfP still happens. On the current data the only change is PyCon Estonia 2026. The new tests fail on the previous code for the cancelled cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Add desk_queue.py, the reader and writer of the desk queue The desk queue lives on an orphan desk-queue branch: one JSON file per series under queue/, keyed by the slug of the series name, plus a README that init writes from the module so the format is documented in one place. GitHub Actions jobs add records with "add", from JSON lines or from options; the desk removes handled items with "remove". Each item gets a stable id from its source, kind, URL and payload, so the same finding queued twice is one item. A file keeps its 50 newest items and long payload strings are cut at 20,000 characters, flagged with "truncated". The module uses only the standard library, so any job can run it without the data tooling. clean_watch_title and the name lookup move here from desk_worklist.py, which now imports them, so the queue file key and the worklist's series matching read names the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Send changedetection signals straight to the desk queue check-conference-update.yml ran a paid-API triage on every changed page, then two agent jobs that edited the data. The triage passed 26% of events at 38% precision and 58% recall, and never saw the data, so it flagged dates already recorded and dropped extensions and moved dates. The workflow now validates the payload and writes it to the desk-queue branch unchanged; the desk judges it on its next run with the data and the live site in hand. The payload is read from the event file rather than interpolated or passed through the environment, so page text cannot reach the shell and a long diff cannot exceed the environment size limit. The new composite action desk-queue-write is the one place that writes to the queue: it creates the orphan branch when missing, adds records with utils/desk_queue.py, and retries a rejected push from a fresh copy of the branch, never with force. Ids are stable, so a retry adds nothing twice. scheduled-extension-checker.yml goes: the desk's worklist checks every deadline 1, 3 and 7 days after it passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr * Retire the API-backed full mode of the TBA enrichment The TBA enrichment keeps its deterministic link extraction: social accounts, sponsor pages and financial aid pages found on each conference's homepage. Deadlines, dates and time zones are now researched by the desk, so the full mode, …
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Stacked on JesperDramsch/python-deadlines#497. This PR adds documents and a hook only; no
utils/code or_data/changes.What's here
AGENTS.md: the domain knowledge every agent working on the data needs:cfpversuscfp_ext, a timezone only when the source states one (otherwise AoE), date-only deadlines are 23:59, sub-links must land on the page they name, andcoc;.claude/routines/desk.md: the daily desk run, in nine steps, each ending with a checkable "Done when":auto/conference-updates, open thedesk-queueworktree and build the worklist;.claude/skills/:enricher: upcoming entries, extensions, import questions, cross-site redirects and drift;new-year-checker: series that have only past editions;scouter: series the data doesn't track yet;critic: verifies every change, screens new series for fake or pay-to-present signs, and flags a missing CoC.utils/tidy_conf/data/denylist.yml: domains the critic rejects, each with a factual reason, plus a review list holdingpyconf.org..claude/settings.jsonand.claude/hooks/desk_guard.py: aPreToolUsehook.git pushtomainormasterthat it can read from the command, including compound commands, wrappers liketimeoutorenv -C, and glob refspecs. A branch name that comes from a command substitution or an outside variable gets through, so a ruleset onmainis the control that holds.auto/conference-updates, or in desk runs (DESK_ROUTINE=1), it blocks edits under the repository's.github/..gitignore:.claude/stays ignored except the shared desk files:settings.json,hooks/,routines/andskills/.#501 extends the hook considerably and carries the fixes from a review of the whole stack.
Needs your call
desk.mdcallsutils/desk_worklist.py(the next PR) andutils/desk_queue.py(the PR after that), and thecocfield comes from Link rules, an optional coc field, and fail-loud branch sync #497. Keep the desk Routine pointed at its current prompt until the whole stack has merged.CLAUDE.md.CLAUDE.mdis gitignored here, and Claude Code reads aCLAUDE.mdinstead ofAGENTS.mdwhen one exists. If you keep a localCLAUDE.md, add@AGENTS.mdas its first line. A fresh cloud clone has noCLAUDE.md, so the desk readsAGENTS.mddirectly..claude/files. Shared.claude/files are now tracked. If you have an untracked.claude/settings.jsonor.claude/skills/<x>/locally, move it aside before pulling. Personal settings belong in.claude/settings.local.json, which stays ignored.Review
An adversarial review found three blocking issues, and all three are fixed:
pydata.org;timezoneto the event's location, which would have moved AoE deadlines.It also raised 11 nits. Nine were applied. The other two are the merge-order and
.gitignorenotes above.The routine review's two nits (the push wording above, and the hook exiting 1 at import on Python 3.9 and older, which lets a call through) are fixed on the top of the stack in #501, commit 6607f91: the hook's tests now pass on Python 3.9, and in desk runs the settings command turns any failure of the hook into a block.
Checks
uvx ruff@0.4.4 check: all checks passed.sort_yaml.py --skip_linksleaves_data/unchanged.🤖 Generated with Claude Code
https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr