Skip to content

Add the desk worklist and fix cfp_candidates series matching - #499

Merged
JesperDramsch merged 5 commits into
mainfrom
ai-agent/exciting-tesla-g7fn0r-3
Oct 15, 2026
Merged

JesperDramsch merged 5 commits into
mainfrom
ai-agent/exciting-tesla-g7fn0r-3

Conversation

@JesperDramsch

@JesperDramsch JesperDramsch commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Third PR in the desk stack, now based on main (#498 is merged). It adds the deterministic worklist the desk run builds in step 4 of .claude/routines/desk.md, and fixes a series-matching bug in utils/cfp_candidates.py that the worklist depends on.

utils/desk_worklist.py

A pure core, build_worklist(data, queue_items, today), with a thin CLI (--queue-dir, --base, --today, --no-scout). It never touches the network, and the same data, queue and date always give the same JSON.

Every item is routed to one skill: enricher when the series has an entry in conferences.yml, new-year-checker when it is known only from archive.yml or legacy.yml, scouter otherwise. Work comes from four places, in this order:

  1. Deadline checks. Entries whose effective deadline (cfp_ext, else cfp) fell 1, 3 or 7 days before today. The deadline is read in the entry's timezone, or AoE (UTC-12) when it has none, and counted from the UTC date it falls on, so a check never comes before the deadline has passed: 23:59 AoE on the 12th is 11:59 UTC on the 13th, and its 1-day check is on the 14th.
  2. Queue items from a checkout of the desk-queue branch (the branch and its writer arrive in the next PR). Titles are cleaned of the changedetection decoration, then matched the way cfp_candidates.py matches. A matched item takes the year of the entry it binds to: the entry for its own year, else the next edition that has not ended. A differing queue year is kept in context.queue_year. Ambiguous matches keep the item and list the candidates in ambiguous_matches. A question, redirect or drift always goes to the enricher; for a series with no upcoming entry it binds the edition for its year from any data file, or context.no_entry says why there is none.
  3. Rotations. Entries whose cfp is TBA or TBD, and stale series, each picked on one weekday in seven by a hash of its name, so every one gets a look each week without a daily flood. cfp: None (no public CfP) and Cancelled are settled and not rotated.
  4. One scout item, unless --no-scout.

Items for the same skill, conference and year merge by unioning reasons and queue refs. No cap.

Later routing changes from the review of the whole stack (candidates routed by match, each missing edition its own item, snoozed leads) are in #501.

utils/cfp_candidates.py

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 stale: "Xtreme Python" against an upcoming "XtremePython", and "PyHEP.dev" (alt_name PyHEP) against an upcoming "PyHEP". Names now go through normalize_conference_name and the titles.yml mappings, ignoring case, accents and whitespace, and alt_name counts on both sides. The logic moves into find_candidates() so the worklist can call it; the CLI and its JSON output are unchanged.

Review

  • The first review's blocking finding (queue items bound to the queue's own year even when the series' live entry was a different year) is fixed in 5585e03. Its two nits, the alt_name matching trade-off and the pandas dependency inherited through tidy_conf, are documented in the module docstrings in ced6bbc.
  • The routine review is addressed in d8f8d96:
    • Important: the TBA rotation compared cfp with sort_yaml.TBA_WORDS, which also holds "none" and "cancelled". Four upcoming editions with cfp: None (PyCon mini Tokyo, PyLadiesCon, FastAPI Conference and PyCon Davao 2026) would have got a weekly check for a deadline that does not exist. Over 12 to 18 October on the real data, those four items are the only change.
    • Decision on the 1-day check: the desk runs at 02:17 UTC, so following the review's recommendation for an early run, days now count from the deadline's UTC date rather than its local calendar date.
    • Nit: enricher items without an upcoming entry are bound to their archived edition or carry context.no_entry.
    • Each new test fails on the previous code.
  • utils/enrich_tba.py has its own TBA check. It is fixed in Queue work for the desk and retire the paid API jobs #500, which rewrites that file: the remaining link extraction leaves cancelled editions out and keeps cfp: None ones, since it only fills links.

Checks

On this branch: the pinned uv test run gives 1102 passed, 0 failed; uvx ruff@0.4.4 check utils tests is clean; pre-commit run passes on the changed files; python utils/sort_yaml.py --skip_links leaves _data/ unchanged. The fix is merged up into #500 and #501.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr

@github-actions github-actions Bot added the Tests label Oct 14, 2026
@JesperDramsch
JesperDramsch added this pull request to stack #502 October 14, 2026 14:19
Base automatically changed from ai-agent/exciting-tesla-g7fn0r-2 to main October 15, 2026 07:12
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
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
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
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
@JesperDramsch
JesperDramsch force-pushed the ai-agent/exciting-tesla-g7fn0r-3 branch from b6351b7 to ced6bbc Compare October 15, 2026 07:12
@JesperDramsch
JesperDramsch marked this pull request as ready for review October 15, 2026 07:12

Copy link
Copy Markdown
Owner Author

Routine review

1. Request

The request is to add utils/desk_worklist.py, the worklist that step 4 of .claude/routines/desk.md runs. That step requires output that parses as JSON and holds exactly one scout item. The PR body names four sources for the worklist: deadline checks, queue items, TBA/stale rotations and the scout item. The request also covers fixing series matching in utils/cfp_candidates.py: the PR body's "Xtreme Python" / "XtremePython" and "PyHEP.dev" / "PyHEP" cases, moved into find_candidates() with the CLI output unchanged.

2. Checked against

  • date: Thu Oct 15 07:13:32 UTC 2026.
  • No external facts are used in this review.

3. Needs your decision

  1. When the 1-day deadline check fires for AoE deadlines. The check counts from the deadline's calendar date in the entry's timezone (utils/desk_worklist.py:310), but today is the UTC date. Take an AoE deadline of D 23:59, which is D+1 11:59 UTC. A desk run before noon UTC on D+1 gets a "1 day after" item while the CfP is still open, so an extension announced in the last hours is missed until the 3-day check. The PR documents this as intended. My recommendation: keep it if the desk runs after 12:00 UTC; otherwise count days_since from the deadline moment.

4. Findings

Important

  • utils/desk_worklist.py:351: tba_rotation treats cfp: None and cfp: Cancelled as TBA.
    • Evidence: the line is if str(entry.get("cfp", "")).lower().strip() not in TBA_WORDS:. TBA_WORDS (utils/sort_yaml.py:48) is ["tba", "tbd", "cancelled", "none", "na", "n/a", "nan", "n.a."].
    • Why it is wrong: AGENTS.md "Field rules" defines cfp: None as "the edition has no public CfP" and cfp: Cancelled as a cancelled edition. The enricher reads the tba-rotation reason as "cfp is TBA; a published deadline answers it" (.claude/skills/enricher/SKILL.md:25).
    • Input that goes wrong: tba_rotation([{"conference": "X", "year": 2027, "cfp": "None", "start": "2027-05-01"}], date(2026, 10, 12)) returns a tba-rotation item on the same weekday a TBA entry would. "Cancelled" and a YAML null cfp do the same. In today's _data/conferences.yml, four upcoming cfp: None editions get a weekly enricher item asking for a deadline the data says does not exist: PyCon mini Tokyo 2026, PyLadiesCon 2026, FastAPI Conference 2026 and PyCon Davao 2026.
    • Smallest fix: compare against ("tba", "tbd") here instead of TBA_WORDS, and add a test that None and Cancelled entries are never rotated.

Nits

  • utils/desk_worklist.py:510-511: a question, redirect or drift queue item is always forced to enricher. If it matches only an archived series, or nothing, the item has entry: null and the enricher gets no edition. desk.md does route maintainer requests about archived editions to the enricher, so this may be intended; a context note would tell the subagent why entry is empty.

5. Tests

Request invariant. tests/test_cfp_candidates.py covers both named spellings in both directions: test_xtreme_python_spelling_is_tracked / _reversed and test_pyhep_dev_alt_name_is_tracked / test_pyhep_tracked_through_upcoming_alt_name. It also has negatives (test_punctuation_is_not_folded, test_different_series). tests/test_desk_worklist.py covers the routing and merge rules. No test covers TBA rotation of None/Cancelled entries; that is the gap behind the Important finding.

Fails on base, passes on change. With base utils/cfp_candidates.py, tests/test_cfp_candidates.py cannot be collected (the find_candidates import fails), so the test file alone does not show the old behaviour was wrong. I checked the base CLI directly instead. Conferences XtremePython 2026 and PyHEP 2026; archive Xtreme Python 2025 and PyHEP.dev 2025 (alt_name PyHEP):

base:   ['PyHEP.dev', 'Xtreme Python']
change: []

Change, with the pinned command from REVIEW.md (uv run --python 3.11 --with "pandas<2.3",… pytest tests/ -q -p no:cacheprovider):

1092 passed, 5 skipped, 3 xfailed, 2 xpassed, 153 warnings in 60.48s (0:01:00)

Changed test files only, on the change:

148 passed in 3.41s

Base utils/cfp_candidates.py with tests/test_cfp_candidates.py:

ERROR tests/test_cfp_candidates.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.22s

uvx ruff@0.4.4 check utils tests:

All checks passed!

Smoke run of python utils/desk_worklist.py --base .. --today 2026-10-15 on the real data: 15 items (10 stale-series, 4 tba-rotation, 1 scout). The output is valid JSON with exactly one scout item, as desk.md step 4 requires.

6. Bounds

  • How I got the change: cloned the repository and fetched refs/pull/499/head. rev-parse matches ced6bbcc43a951510dbe87d59363e5d0e66d07ba. I read the PR and diff over the REST API.
  • Read in full: utils/desk_worklist.py, the utils/cfp_candidates.py diff, and the normalize_conference_name and fold_name helpers.
  • Skimmed: both test files.
  • Not run:
    • The sort_yaml.py data check: the PR does not touch _data/.
    • pre-commit: REVIEW.md defers it to the pre-commit.ci - pr status, which I did not read.
    • Queue-item handling against a real desk-queue checkout: the branch and its writer arrive in the next PR, so queue routing is checked only by the PR's unit tests.
  • Pre-existing:

Generated by Claude Code

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
@JesperDramsch
JesperDramsch merged commit 3c7bdd8 into main Oct 15, 2026
11 checks passed
@JesperDramsch
JesperDramsch deleted the ai-agent/exciting-tesla-g7fn0r-3 branch October 15, 2026 10:28
JesperDramsch pushed a commit that referenced this pull request Oct 15, 2026
…ting-tesla-g7fn0r-5

GitHub rebased the base branch onto main when #499 was merged. Its
content is unchanged and this branch already holds it, so the merge
keeps this branch's tree and only records the new base as a parent.

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, …
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants