Skip to content

Add AGENTS.md, the desk run procedure, four skills and a guard hook - #498

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

JesperDramsch merged 17 commits into
mainfrom
ai-agent/exciting-tesla-g7fn0r-2

Conversation

@JesperDramsch

@JesperDramsch JesperDramsch commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

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:
    • where entries go;
    • the listing policy (in, adjacent with a Python track, out);
    • naming and how to tell series apart, including when a find already counts as tracked;
    • field rules: cfp versus cfp_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, and coc;
    • complete data, recorded when visiting rather than through archive sweeps;
    • evidence and sources;
    • the change-set format;
    • data mechanics: run the sort, stage all three files.
  • .claude/routines/desk.md: the daily desk run, in nine steps, each ending with a checkable "Done when":
    • get onto auto/conference-updates, open the desk-queue worktree and build the worklist;
    • read maintainer feedback on the rolling PR, acting only on comments whose REST-checked user id is 2620316;
    • research every item with a subagent, put every change through the critic, then commit and push one conference at a time;
    • update the rolling PR's header, evidence table and Candidates ledger;
    • clean the queue and report.
  • Skills in .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 holding pyconf.org.
  • .claude/settings.json and .claude/hooks/desk_guard.py: a PreToolUse hook.
    • It blocks a git push to main or master that it can read from the command, including compound commands, wrappers like timeout or env -C, and glob refspecs. A branch name that comes from a command substitution or an outside variable gets through, so a ruleset on main is the control that holds.
    • On auto/conference-updates, or in desk runs (DESK_ROUTINE=1), it blocks edits under the repository's .github/.
    • In desk runs it blocks the remote-session tools.
    • It also applies an optional local deny-list file kept outside the repo.
    • Pushes to other branches and edits elsewhere pass. 145 tests cover it.
  • .gitignore: .claude/ stays ignored except the shared desk files: settings.json, hooks/, routines/ and skills/.

#501 extends the hook considerably and carries the fixes from a review of the whole stack.

Needs your call

  • Merge order. desk.md calls utils/desk_worklist.py (the next PR) and utils/desk_queue.py (the PR after that), and the coc field 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.
  • Local CLAUDE.md. CLAUDE.md is gitignored here, and Claude Code reads a CLAUDE.md instead of AGENTS.md when one exists. If you keep a local CLAUDE.md, add @AGENTS.md as its first line. A fresh cloud clone has no CLAUDE.md, so the desk reads AGENTS.md directly.
  • Local .claude/ files. Shared .claude/ files are now tracked. If you have an untracked .claude/settings.json or .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:

  • the hook missed pushes inside compound commands and behind wrappers;
  • a shared link domain was treated as proof that a series is already tracked, which would have dismissed new events on hosts like pydata.org;
  • the critic tied timezone to 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 .gitignore notes 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

  • Pinned pytest: 872 passed, 0 failed on this branch, including the 145 hook tests.
  • uvx ruff@0.4.4 check: all checks passed.
  • pre-commit on the changed files: every hook passed.
  • sort_yaml.py --skip_links leaves _data/ unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QzyF2ciB5jhvcTVpj4ErMr

claude added 16 commits October 14, 2026 00:21
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
JesperDramsch added this pull request to stack #502 October 14, 2026 14:19
Base automatically changed from ai-agent/exciting-tesla-g7fn0r to main October 14, 2026 14:20
@JesperDramsch
JesperDramsch marked this pull request as ready for review October 14, 2026 14:21
@JesperDramsch
JesperDramsch marked this pull request as draft October 14, 2026 23:30
@JesperDramsch
JesperDramsch marked this pull request as ready for review October 14, 2026 23:30

Copy link
Copy Markdown
Owner Author

Routine review

1. Request

There is no linked issue, so the PR body is the request. It asks for documents and a hook only, with no utils/ code and no _data/ changes. The instances are:

  • AGENTS.md
  • .claude/routines/desk.md
  • the four skills enricher, new-year-checker, scouter and critic
  • utils/tidy_conf/data/denylist.yml
  • .claude/settings.json, .claude/hooks/desk_guard.py and its tests
  • the .gitignore carve-out for .claude/

The diff against main touches exactly these 11 files. #497, the PR this one is stacked on, is already in main.

2. Checked against

  • date: Wed Oct 14 23:31:04 UTC 2026
  • A PreToolUse hook that exits 2 blocks the call, its stderr goes to Claude, and a matcher like mcp__memory__.* works as a regex: https://code.claude.com/docs/en/hooks and https://code.claude.com/docs/en/permissions (via Context7)
  • Claude Code reads AGENTS.md when there is no CLAUDE.md, and some sessions need an import from CLAUDE.md instead. This matches the PR body's "Local CLAUDE.md" note: https://code.claude.com/docs/en/glossary (via Context7)
  • unverified: the denylist reasons (domain registration dates, site contents observed on 2026-10-14). I did not fetch RDAP or the sites.

3. Needs your decision

  1. Merge order. desk.md steps 4 and 8 call utils/desk_worklist.py and utils/desk_queue.py. Neither file exists on this head, and step 4's "Done when" can't be met without them. Do you merge now and leave the Routine prompt alone until the stack lands, or hold this PR? Recommendation: merge now, as the PR body says. Nothing else reads desk.md yet.
  2. Guard reach. .claude/settings.json is tracked, so the push-to-main block applies to every Claude Code session in this checkout. That includes your interactive ones, and pushes to main/master of any repository cd'd into from here. Is that intended? Recommendation: keep it. It matches "Any session" in the hook docstring.
  3. Public denylist wording. denylist.yml names 13 real third-party domains, with descriptive reasons such as "a deals and voucher site" and "home page reads 'arranging logical gatherings'", in a public repo. Recommendation: keep it. The reasons are stated as dated observations, which is the right form. The call is yours, though, and I did not re-verify them (see section 2).

4. Findings

Important: None.

Nits:

  • .claude/hooks/desk_guard.py:403 (program == "git" branch): the PR body says the hook "always blocks a git push that reaches main", but any refspec that comes from shell expansion gets through. For example, B=main; git push origin "$B" and git push origin $(echo main) both exit 0, as does gh api -X PATCH …/git/refs/heads/main. This is inherent to static matching and the docstring says so ("pattern matched, never run"). Consider rewording the PR body and AGENTS.md to "blocks literal pushes", so nobody counts on it as the only control.
  • .claude/hooks/desk_guard.py:102: the annotations tuple[Path, Path] | None are evaluated at import time. Under Python ≤3.9, python3 desk_guard.py raises TypeError before main() runs and exits 1. Claude Code treats exit 1 as non-blocking, so in a desk run the call goes through. That contradicts the comment at line 578 ("A crash must never let a desk call through"). Reproduced with DESK_ROUTINE=1 uv run --python 3.9 python desk_guard.py, which gives exit 1 for git push origin main. The sandbox python3 is 3.11, so this matters only on an older interpreter. Adding from __future__ import annotations closes it.

5. Tests

  • Coverage of the request: tests/test_desk_guard.py (145 tests) runs the hook as a subprocess over each rule in the request: push-to-main forms, .github/ edits, remote tools, the deny-list file and malformed input. test_settings_register_the_hook_for_every_matcher pins all four matchers in .claude/settings.json.
  • Base comparison: this is a new feature, not a fix. The test file and the hook do not exist on base, so there was no base run.
  • Change, full suite: uv run --python 3.11 --with "pandas<2.3",pytest,… pytest tests/ -q -p no:cacheprovider:
    944 passed, 5 skipped, 3 xfailed, 2 xpassed, 153 warnings in 34.58s
  • Change, hook tests only: pytest tests/test_desk_guard.py -q:
    145 passed in 6.64s
  • Lint: uvx ruff@0.4.4 check utils tests .claude/hooks:
    All checks passed!
  • Data: python utils/sort_yaml.py --skip_links exited 0, and git diff --stat -- _data was empty.
  • CI on the head commit: pre-commit.ci - pr: success. CI Status, Python Tests, Data Validation, Pre-commit Checks and CodeQL all completed with success.

6. Bounds

  • Route: read the PR through the REST API (pulls/498, the diff media type), and checked out refs/pull/498/head. rev-parse matched 9b3a79b.
  • Read in full: the hook, desk.md, the four skills, AGENTS.md, denylist.yml, settings.json and .gitignore.
  • Skimmed: the body of tests/test_desk_guard.py.
  • Checked by probe:
    • the hook's push detection, on a scratch repo;
    • the .gitignore negations, with git check-ignore: settings.local.json stays ignored and the four shared paths are tracked;
    • that the field names and sub codes AGENTS.md names exist in utils/tidy_conf/schema.py and _data/types.yml;
    • that desk.md's rolling-PR body markers match .github/workflows/check-conference-update.yml (<!-- END_UPDATES -->, labels automated and conference-update).
  • Not run: a live Claude Code session exercising the hook, and anything that depends on desk_worklist.py or desk_queue.py.
  • Pre-existing problems: none noticed.

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
JesperDramsch merged commit 4ad55d0 into main Oct 15, 2026
11 checks passed
@JesperDramsch
JesperDramsch deleted the ai-agent/exciting-tesla-g7fn0r-2 branch October 15, 2026 07:12
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, …
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants