Plan: Automated status-sync + archive for docs/priorities.md via nightly-devops-triage
Issue: #4559
Type: Code-change-design (plan only)
Status: Reviewed — iteration 2 (/review-plan findings applied: parser module + tests added to §1, dry-run source-of-truth fixed in §6, EU AI Act registration added as §9)
Last updated: 2026-07-29
Author: Claude Opus 5 (worker session i1-cw-1 on vps-i1)
Related: #4068 (GH App ephemeral token for bms-4 workers) · #4245 (App-token fleet migration) · #2816 (bms-4 git broken — blocker, see §5) · #4453 (stale shared /opt/p24-infra checkout) · #4047 (PAT-in-remote-URL leak — the pattern to avoid)
0. Summary — recommendation
The issue’s core mechanism is sound and I recommend building it. Seven of its stated details do not survive a read of the current tree, and two of them are the difference between a safe automation and a destructive one. Net effect: the change set gets smaller, and two guards get added that the issue does not ask for.
| # | Claim in #4559 | Measured in this tree | Effect on plan |
|---|---|---|---|
| F1 | Archive resolved rows into a new ## ✅ Resolved Archive (auto-synced) section | docs/priorities.md:5 states the opposite policy — “resolved rows are removed (git history preserves them)” — and :117 already names git log --follow as the archive | Decision D1 — recommend remove + record in commit/PR body, not an archive section |
| F2 | A row links one issue/PR | 8 of 40 rows carry 2–3 links ([#4053] · [#4065] · [#2824]); some mix in GitLab MRs and doc paths | Resolution must be AND across every link; any non-gh-resolvable link ⇒ skip row |
| F3 | Must update NightlyTriagePhaseFailed coverage in ai-workers.yml | expr: p24_nightly_triage_result{result="failed"} > 0 — label-agnostic, new slugs covered automatically | ai-workers.yml drops out of scope (no-op edit) |
| F4 | Critical file scripts/run-hourly-triage.sh | Does not exist. Real runner is scripts/run-nightly-triage.sh | Path correction |
| F5 | Reuse the branch+PR pattern from credential-rotation.yml | That file contains no branch/PR logic. The real precedent is rotation-log-housekeeping.yml — a near-exact analogue (bot prunes rows out of a docs/*.md log, fixed branch, force-reset, PR-if-not-open) | Reuse that; it also is the idempotency mechanism the issue asks for |
| F6 | Phase commits from the bms-4 checkout | /opt/p24-infra on bms-4 is a shared tree, P1-flagged as git-broken and hundreds of commits stale (#2816), read concurrently by other workers for SOPS (#4453) | New hard guard: operate in a throwaway /tmp clone, never git checkout -B in the shared tree |
| F7 | Verify against the current file: negative test on a 2416-style row, idempotency test on an existing ✅ RESOLVED row | Current file has 0 rows containing ✅ RESOLVED, 0 strikethroughs, and 0 mentions of #2416. Also 0 rows currently qualify as resolved | Both tests need synthetic fixtures; first real dry-run legitimately reports 0 actionable |
| F8 | Act on well-formed 3-column table rows | ## 🟢 Pending Human Actions is also 3-column but its middle column is Notes, not Status — 6 rows of human prose | New hard guard: section allowlist + exact header-row match |
Recommended scope, in order:
- Add Phase 3.5 to
.claude/commands/nightly-devops-triage.mdwith slugpriorities-status-sync— placed between Phase 3 and Phase 4 so Summary stays Phase 4 and no existing slug renumbers (the issue asked me to pick whichever is least disruptive; this is it). - Ship dry-run-only in PR #1. Flipping
$PRIORITIES_SYNC_DRYRUNto$falseis a separate one-line PR — that one-liner is the human enablement gate the issue asks for. - Extract the parser to
scripts/priorities_sync.py+ fixture tests, rather than leaving a load-bearing regex contract as untestable prose inside an 84 KB markdown prompt. - Register
nightly-devops-triageindocs/eu-ai-act-compliance.md§4 (§9) — it is missing, while the two comparable agents are registered, and this is the change that escalates it to writing repo files. - Do not touch
monitoring/prometheus/rules/ai-workers.yml(F3). - **Resolve Decision D1 before PR 2 (the write-enabling PR). Dry-run output is identical either way, so D1 does not block PR #1.
1. Changes required
| File | What changes | Why |
|---|---|---|
.claude/commands/nightly-devops-triage.md | Insert ## Phase 3.5 — priorities.md Status Sync between Phase 3 (ends line 1662) and Phase 4 (line 1664). Add 4 constants to the ## Constants block. Add Test-PrioritiesRowResolved + Sync-PrioritiesFile helpers to ## Helper functions. Update frontmatter description (line 2) Six-phase → Seven-phase, the phase list (lines 8–18), and the phase-slug list (lines 28–31) | The phase body; the header lists are the skill’s own self-documentation and go stale silently |
scripts/priorities_sync.py | New. The parser + resolution logic (§3) as an importable module with a CLI (--dry-run, --file, --max-rows). The phase shells out to it; the phase body itself stays thin | A regex contract this load-bearing must be unit-testable. Prose inside an 84 KB markdown prompt cannot be tested, and the §7 matrix has nowhere to attach otherwise |
scripts/tests/test_priorities_sync.py | New. The 14 fixture tests in §7 | scripts/tests is already in pyproject.toml testpaths, so python -m pytest collects this with no config change — do not add one |
docs/playbooks/priorities-status-sync.md | New. Operating/rollback doc: what the phase does, how to read a dry-run, how to enable writes, how to disable in an emergency, how to recover a bad PR | CLAUDE.md §Compliance — new automation needs an ops doc. Also the disable path must not require reading 84 KB of skill markdown at 03:00 |
docs/eu-ai-act-compliance.md | Add a nightly-devops-triage row to the §4 AI Systems Inventory — risk limited, — (not Annex III — infra repo/issue housekeeping only, no people scoring/allocation), matching the wording already used for resource-incident-triage and queue-analyst | See §9. Pre-existing gap, but this is the change that escalates the agent to writing repo files, and CLAUDE.md §Compliance mandates registration |
docs/plans/plan-4559-priorities-status-sync.md | New — this file | Plan artifact for #4559 |
New Python must pass ruff check . — line-length = 100, target-version = "py311", select = ["E","F","I","B","W"].
Not changed, and why (scope reductions vs. the issue body):
monitoring/prometheus/rules/ai-workers.yml— F3, the existing alert already covers any new slug..github/workflows/nightly-devops-triage.yml— that path isworkflow_dispatch-only withpermissions: contents: readand no push capability; it is a manual smoke-test harness. The phase must self-skip when it detects it is running there (see §5.1), not be granted write permissions. Grantingcontents: writeto a manually-dispatchable workflow widens blast radius for no gain.scripts/run-nightly-triage.sh— no change needed. It alreadycds to$REPO_DIRand the phase does its own git work in an isolated clone.infra/agent-prompts/worker-issue.md— the existing prohibition stays verbatim. This phase is not a worker; it is a distinct scheduled agent. Nothing in the worker prompt needs relaxing, and weakening it would let ordinary dev-issue workers edit the file, which is exactly what the issue says must not happen.
New files: docs/playbooks/priorities-status-sync.md, docs/plans/plan-4559-priorities-status-sync.md.
DB schema changes: no.
2. Constants (add to the skill’s ## Constants block)
# --- Phase 3.5: priorities.md status sync (#4559) ---
# DRY-RUN GATE. Ships $true. Flipping to $false is a deliberate one-line PR and IS the
# human enablement gate required by #4559. Never flip it in the same PR that adds the phase.
$PRIORITIES_SYNC_DRYRUN = $true
# Blast-radius cap. A regex defect can mis-classify at most this many rows in one night.
# 40 table rows exist today; 5 is >1 night of realistic churn and far short of "rewrote the file".
$PRIORITIES_SYNC_MAX_ROWS = 5
# Section allowlist (F8). ONLY these H2 sections are eligible. Matched as literal prefixes.
# Deliberately excludes "## Recent session log", "## Pending Human Actions" (middle column is
# Notes, not Status), and "## References".
$PRIORITIES_SYNC_SECTIONS = @(
"## 🔴 P0 ", "## 🟠 P1 ", "## 🟡 P2 ", "## 🟢 P3 "
)
# Required header row — a section is eligible only if its table header matches this EXACTLY
# after whitespace normalisation. Guards against a future section that is 3-column but not
# Item/Status/Issue-Ref.
$PRIORITIES_SYNC_HEADER = "| Item | Status | Issue / Ref |"
# Fixed sync branch — force-reset each run so an unmerged PR is UPDATED, never stacked (F5).
$PRIORITIES_SYNC_BRANCH = "automation/priorities-status-sync"3. Parsing contract
The phase is a structural parser, not a line-regex scan. State machine over the file, top to bottom:
for each line, tracking $currentSection (last "^## " line seen) and $inEligibleTable:
"^## " -> set $currentSection; $inEligibleTable = $false
$currentSection not in $PRIORITIES_SYNC_SECTIONS -> emit line verbatim, next
normalised line == $PRIORITIES_SYNC_HEADER -> $inEligibleTable = $true; emit verbatim
"^\|[-: |]+\|$" (separator) -> emit verbatim
not $inEligibleTable -> emit verbatim
"^\|" and $inEligibleTable -> CANDIDATE ROW -> evaluate
anything else -> emit verbatim; $inEligibleTable = $false
Everything not reached by the CANDIDATE branch is copied byte-for-byte. That is what makes the
“Last updated line and all narrative content byte-identical” verification in #4559 a property of the
parser rather than a test result.
On the naive alternative. A bare
^\|.*\|.*\|scan over the whole file happens to be safe against today’s file — I checked: no line outside a table currently holds 2+ pipes, and the session log’s issue references are bare#NNNNprose with zero markdown issue-links. But #4559’s own quoted example (~~**[P0] …**~~ … | ✅ RESOLVED 2026-07-05 | PR [#2729]…) shows narrative lines have carried that shape historically, and## Recent session loggrows a new multi-line prose bullet every session. Structural parsing costs a dozen lines and removes the class of bug entirely.
3.1 Candidate row evaluation
Split the row on unescaped | into exactly 3 cells (Item, Status, Ref). Cell count ≠ 3 ⇒ WARN + skip.
Idempotency short-circuits — skip immediately, no gh calls, if:
Statuscontains✅ RESOLVED, orItemstarts with~~(already struck through, by a human or a prior run).
Reference extraction from Ref: collect every match of
\[#(\d+)\]\(https://github\.com/radieu/p24-infra/(issues|pull)/\1\)
The \1 back-reference is load-bearing: it requires the link text and link target to name the same
number, so a mislabelled link is skipped rather than resolved against the wrong issue.
Skip the row (WARN, no state change) if any of:
- zero matches (row references a doc path, a GitLab MR, or bare prose — e.g. the P1 row whose Ref is
telegram-claude-bot-operations.md §Security Model); Refcontains any other markdown link besides those matches — i.e. link count ≠ match count. This is what excludes the#2542 · MR !792row: GitLab MR state is invisible togh, so “all links resolved” cannot be established and the row must not be touched.
Per-reference verdict (Test-PrioritiesRowResolved), from gh issue view N --json state,milestone /
gh pr view N --json state:
| Kind | Resolved when | Rationale |
|---|---|---|
pull/N | state == "MERGED" | Merged code shipped |
issues/N | state == "CLOSED" AND milestone.title == "Main" | Verified: the Main milestone description is literally “Merged to main - shipped” |
Anything else — open, or closed at Review/Staging/Triage/no milestone — is not resolved.
Row verdict: resolved only if EVERY extracted reference is resolved (F2). A row that names
#4053 · #4065 · #2824 stays put until all three ship. Any gh call that errors or times out ⇒ WARN + skip
the whole row (fail-safe; never treat an API failure as “resolved”).
The #2416 guard is the point of this table. #2416 is
CLOSEDwith milestoneReview, and its body says producing a plan was the deliverable while the MongoDB credential move it proposed never ran. Closed-state alone would have archived a live risk. Themilestone == "Main"conjunct is not belt-and-braces; it is the entire safety argument. (#2416 is not referenced in the current file — it is the canonical shape, and §7 tests it as a fixture.)
4. Row action — Decision D1
For each resolved row, up to $PRIORITIES_SYNC_MAX_ROWS per run (rows beyond the cap are logged as
deferred and picked up the next night, mirroring $UNESCALATE_MAX_PER_RUN in Phase 1D):
Option A — remove the row (recommended). Delete the row line from its section. Reproduce the full removed row verbatim in the commit message body and the PR body, one fenced block per row, with the resolving reference and date.
Option B — as literally specified in #4559. Rewrite Status to ✅ RESOLVED — <ref>, YYYY-MM-DD, wrap
Item in ~~…~~, and move the row to a ## ✅ Resolved Archive (auto-synced) section created once at the
bottom (above ## References) and appended to thereafter.
Recommendation: A. Three reasons, all from the file itself:
docs/priorities.md:5— “Only open / actionable items are kept here — resolved rows are removed (git history preserves them).” Last updated today. Option B automates the accumulation of exactly what that line says to remove, and would re-inflate a file a human deliberately pruned to 117 lines.docs/priorities.md:117already designates the archive: “Full historical session log:git log --follow docs/priorities.md”. Option A’s commit body makes every removal greppable there.- Option A deletes the issue’s own hardest sub-requirement — “never re-sort or prune the archive further; that stays a separate human cleanup” — by never creating a second growing surface to prune.
Option B is a ~15-line delta on top of Option A (rewrite two cells, append instead of drop), so this is genuinely reversible. It needs a human decision because it contradicts the issue text, and the issue text may reflect a preference the file’s line 5 does not capture. Dry-run output is identical under both, so PR #1 is not blocked.
Under either option the phase touches only: the Status cell, the Item cell’s strikethrough wrapper, and
the row’s position. Never item wording, never section ordering, never P0 risk judgement, never the session log.
5. Git write path
This is the first time this skill writes to the repo. Two properties of the runtime make the naive path unsafe.
/opt/p24-infra on bms-4 is shared and possibly stale. run-nightly-triage.sh:37 runs
git -C "$REPO_DIR" pull --ff-only --quiet 2>/dev/null || true — failure is swallowed by design. #2816 has
that tree P1-flagged as git-broken and hundreds of commits behind; #4453 records other workers reading SOPS
out of it concurrently. Running git checkout -B + git push --force there would move HEAD under a
concurrent worker and diff against an ancient base.
So: never write in the shared tree. The phase clones to a throwaway path:
$syncDir = "/tmp/priorities-sync-$([int][double]::Parse((Get-Date -UFormat %s)))"
# git clone --depth 1 --branch main <authed remote> $syncDir--depth 1 off main makes freshness structural — there is no stale tree to detect. $syncDir is removed in
a finally block on every exit path.
Token: GitHub App, minted per run, least privilege.
TOK=$(bin/gh-app-token.sh --repos p24-infra --permissions contents:write,pull_requests:write)scripts/gh-app-token reads GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY_B64 / GITHUB_APP_INSTALLATION_ID
from the SOPS-deployed bms-4 env and prints a 1 h installation token to stdout (#4068). No long-lived PAT.
Delivery: GIT_ASKPASS, per docs/playbooks/worker-push-workflow-files.md. The token stays in the child
environment and never reaches argv, git config, or the remote URL — the #4047 at-rest leak vector. $TOK is
unset and the askpass helper rm’d in the same finally block as $syncDir.
Branch + PR: rotation-log-housekeeping.yml:36-63 verbatim (F5) — the pattern that file’s own comment
traces to update-vps-h1-sops.yml, adopted because main is protected (GH006: Changes must be made through a pull request):
git checkout -B "$PRIORITIES_SYNC_BRANCH" # fresh clone => resets to main tip
git add docs/priorities.md
git commit -m "chore: sync docs/priorities.md resolved rows (#4559)" -m "<removed rows verbatim>"
git push --force origin "$PRIORITIES_SYNC_BRANCH"
if [ "$(gh pr view "$PRIORITIES_SYNC_BRANCH" --json state --jq .state 2>/dev/null || true)" != "OPEN" ]; then
gh pr create --base main --head "$PRIORITIES_SYNC_BRANCH" --title "chore: priorities.md status sync" --body-file "$prBody"
fiFixed branch + force-reset from a fresh main is #4559’s cross-night idempotency requirement: an unmerged
PR is updated in place, never stacked. One deliberate divergence from the precedent: no gh pr merge --auto.
This automation edits the file that governs infra prioritisation; every change lands under human review. The
issue says “never pushes directly to main” — auto-merge would satisfy that letter while defeating it.
5.1 Preconditions — all must hold, else skip cleanly
Checked before any file is written. Any failure ⇒ Push-TriagePhaseMetric "priorities-status-sync" "failed",
WARN to /var/log/nightly-devops-triage.log, Send-ErrorNotification, no partial write, next phase runs.
Preconditions 2–6 gate both modes; only 1 distinguishes them (see §6 — dry-run mints and clones exactly like the write path, and stops before the commit).
- Mode:
$PRIORITIES_SYNC_DRYRUN -eq $falseto reach the commit/push/PR steps; otherwise report-only (§6). bin/gh-app-token.sh --checksucceeds and the mint returns a non-empty token.git clone --depth 1succeeds.docs/priorities.mdexists in the clone and contains at least one$PRIORITIES_SYNC_SECTIONSheader (structure sanity — a rewritten file must not be silently mangled).- Resolved-row count
> 0— zero is the expected steady state (F7), a success, and produces no commit. - Post-edit diff self-check, and it is a hard gate:
git diff --numstat -- docs/priorities.mdmust touch onlydocs/priorities.md, and under Option Adeletions == rowsRemovedwithinsertions == 0(Option B:insertions == deletions == rowsMoved). Any other shape ⇒ abort, discard the clone, alert. This is the last line of defence if the parser regresses, and it is cheap. - GH Actions self-skip: if
$env:GITHUB_ACTIONS -eq "true", force dry-run. That path isworkflow_dispatch-only withcontents: read— it exists to smoke-test the skill, and a manual dispatch must not be able to open a priorities PR.
6. Dry-run output
Dry-run mints the token and takes the same --depth 1 clone as the write path, and stops immediately before
git add. It must not read docs/priorities.md out of the shared /opt/p24-infra tree.
This is not symmetry for its own sake. §5 establishes that the shared tree is the one thing not to trust —
run-nightly-triage.sh:37 swallows git pull failure, #2816 has it P1-flagged as git-broken and hundreds of
commits stale, #4453 records concurrent readers. A dry-run parsing that copy would have the human approve a
report computed against a stale file and then enable writes against a different one, defeating the enablement
gate this whole rollout is built around. Cost of doing it right: one shallow clone per night, no commit.
A mint failure therefore degrades dry-run to “phase skipped” (metric failed, WARN, Discord error) —
never to a 0 rows resolvable report, which is indistinguishable from the healthy steady state (§5.1.5) and
would read as a green light.
Dry-run runs the full parse and every gh lookup, and writes nothing. Emits per row:
[3.5] row 34 (P0) refs=#2620,#2055 -> SKIP (#2620 CLOSED/Review — not milestone Main)
[3.5] row 45 (P1) refs=#2542,MR!792 -> SKIP (non-gh link present — GitLab MR unresolvable)
[3.5] row 61 (P2) refs=#4018 -> SKIP (OPEN)
[3.5] row 72 (P2) refs=#2127 -> RESOLVE (CLOSED + milestone Main)
[3.5] summary: 40 candidates / 1 resolvable / 39 skipped / 0 malformed / cap 5
Emitted to the log and to Discord via the existing Send-DiscordEmbed, so the human review the issue asks for
lands in the channel already watched, with no new plumbing. Counters
($p35_resolved / $p35_skipped / $p35_malformed / $p35_deferred / $p35_dryRun) join the Phase 4 summary line,
matching every other phase.
7. Test plan
The two verifications in #4559 cannot run against the live file as written (F7): it has 0 rows containing
✅ RESOLVED, 0 strikethroughs, 0 mentions of #2416, and 0 rows that currently qualify as resolved.
So they become fixtures. Fixture-driven tests, extracting the parser to scripts/priorities_sync.py (imported
by the phase, unit-testable, pytest under the existing root suite) rather than living only as prose in an
84 KB markdown prompt:
| Test | Fixture | Expect |
|---|---|---|
| Negative — closed-but-not-shipped | row referencing a CLOSED / milestone Review issue (#2416’s shape) | untouched |
| Positive — merged PR | row referencing a MERGED PR | resolved |
| Positive — shipped issue | row referencing CLOSED + milestone Main | resolved |
| Multi-link AND (F2) | row with 3 links, 2 shipped 1 open | untouched |
| Non-gh link (F2) | row with [#2542](…) · [MR !792](gitlab…) | untouched, WARN |
| Mislabelled link | [#1234](…/issues/5678) | untouched, WARN (back-reference) |
| Idempotency — status | row already ✅ RESOLVED | untouched, zero gh calls |
| Idempotency — strikethrough | row whose Item starts ~~ | untouched, zero gh calls |
| Wrong-section guard (F8) | resolvable-looking row inside ## 🟢 Pending Human Actions | untouched |
| Session-log guard (F9) | ## Recent session log bullet containing pipes and a markdown issue-link | untouched |
| Byte-identity | full current docs/priorities.md, all refs stubbed unresolved | output sha256 == input sha256 |
| Malformed row | 2-cell and 4-cell rows | untouched, WARN, run continues |
| Cap | 9 resolvable rows | 5 acted, 4 deferred, no error |
| Diff-shape gate (§5.1.6) | parser stubbed to also edit another file | abort, no commit |
Live sequencing:
bin/gh-app-token.sh --checkon bms-4 — a precondition of **PR 1, not PR #2, since §6 makes dry-run mint a token too. Fixture tests andruff check .green locally.- PR #1 — phase (dry-run pinned),
scripts/priorities_sync.py+ tests, playbook, EU AI Act registration (§9), this plan. No behaviour change to any existing phase. - One real nightly dry-run on bms-4. Confirm the Discord/log report, and confirm
0 resolvablereads as healthy steady state, not as breakage — and that a mint failure reports skipped, not0 resolvable(§6). - End-to-end rehearsal on a throwaway branch, not
main: add a temporary row referencing a real merged PR, let the phase strike/relocate/remove it, inspect the PR diff, delete the branch. This is the one point where #4559’s “revert the test row before merging anything for real” applies — keeping it offmainmeans there is nothing to revert. - Resolve D1.
- PR #2 — flip
$PRIORITIES_SYNC_DRYRUNto$false. One line.
8. Regression risks
| Risk | Why it is bounded |
|---|---|
Mangling docs/priorities.md — highest-consequence failure; the file is read at the start of every substantive session and by every worker’s Priorities Check | Byte-identity test over the real file; structural parser (§3); diff-shape hard gate (§5.1.6); $PRIORITIES_SYNC_MAX_ROWS = 5; every change lands via reviewed PR, never auto-merge; git revert is a one-liner |
| False-positive archive hides a live risk (the #2416 class) | milestone == "Main" conjunct + AND-across-all-links (F2) + skip-on-any-unresolvable-link. Fixture-tested both ways |
Destroying human prose in Pending Human Actions (F8) | Section allowlist + exact header match; both fixture-tested |
| Writing from a stale/shared tree (#2816, #4453) | Throwaway --depth 1 clone; shared tree never mutated |
| Token leak (#4047) | Ephemeral 1 h App token, least-privilege scopes, GIT_ASKPASS delivery, finally-block cleanup. No value ever logged |
A broken token mint reads as “nothing to do” — 0 resolvable is the healthy steady state (§5.1.5), so a silent mint failure would look green indefinitely | Mint failure is classified skipped, never 0 resolvable — distinct log line, distinct Discord embed, Push-TriagePhaseMetric … failed ⇒ NightlyTriagePhaseFailed fires (§6). Relevant now that dry-run also mints |
| Dry-run reviewed against a different file than the write run edits | Dry-run takes the same --depth 1 clone of main; the shared /opt/p24-infra copy is never the parse source in either mode (§6) |
| Stacked PRs across nights | Fixed branch force-reset from fresh main (F5) |
| Phase aborts the whole triage run | Wrapped in the skill’s existing per-phase try/catch; every failure path is WARN + metric + continue, consistent with all six existing phases |
| Skill self-documentation drifts | Frontmatter description, phase list, and slug list are all updated in the same PR; §1 names them explicitly |
| Widening the worker prohibition | infra/agent-prompts/worker-issue.md unchanged; this is a separate scheduled agent, not a worker |
9. Compliance
EU AI Act — register nightly-devops-triage. It is absent from the §4 AI Systems Inventory in
docs/eu-ai-act-compliance.md, although the two directly comparable agents are already there —
resource-incident-triage (limited) and queue-analyst (limited), both annotated “not Annex III — infra
telemetry only, no people scoring/allocation”. The gap pre-dates #4559, but this plan is the change that
escalates the agent from GitHub-metadata-only actions to writing repository files, and CLAUDE.md
§Compliance mandates dev_r_ai_systems + the doc for AI-powered elements. Deadline is 2026-08-02.
Classification: limited, not Annex III. It acts only on repository and issue metadata — infra
housekeeping, no natural-person scoring, no task allocation to workers, no employment decisions. Same
reasoning already accepted for queue-analyst. Register in docs/eu-ai-act-compliance.md §4 and add the
matching dev_r_ai_systems row in the implementation PR.
dev_r_services — no new row required. This phase adds no service and no container; it is a phase inside
the already-scheduled nightly-devops-triage job. Confirm that job’s existing dev_r_services row is present
and its workbook_url still resolves; do not create a second row for the phase.
Error notification — already satisfied, no new wiring. Every failure path does
Push-TriagePhaseMetric "priorities-status-sync" "failed" + WARN to /var/log/nightly-devops-triage.log +
Send-ErrorNotification (Discord). No new cron is introduced, so the §Error Notification Standard is met by
the mechanisms the skill already uses for its six existing phases.
10. Open decision
D1 — remove resolved rows (Option A, recommended) or archive them in-place (Option B, as #4559 specifies)? See §4. Needed before PR #2; does not block PR #1.
11. Dependencies
- GH App creds present in the bms-4 SOPS-deployed env (
GITHUB_APP_ID,GITHUB_APP_PRIVATE_KEY_B64,GITHUB_APP_INSTALLATION_ID) — the mechanism #4068 shipped. Verify withbin/gh-app-token.sh --checkon bms-4 **before PR 1, not before PR #2 — since §6 makes dry-run mint a token too, a broken mint now blocks the dry-run as well, not just the write path.GITHUB_APP_PRIVATE_KEY_B64is one of the keys the P0 #2620 row lists as still pending rotation; if it is rotated mid-flight the mint fails closed and the phase skips (§5.1.2) as “skipped”, never as0 resolvable— see §6. - App installation must grant
contents:write+pull_requests:writeonradieu/p24-infra. - Haiku is sufficient — mechanical regex +
ghstate lookups, per the skill’s own Model selection note anddocs/playbooks/token-cost-optimization.md. No model change.
File-overlap signature: .claude/commands/nightly-devops-triage.md, scripts/priorities_sync.py, docs/playbooks/priorities-status-sync.md, docs/eu-ai-act-compliance.md, docs/plans/plan-4559-priorities-status-sync.md
files_to_change: .claude/commands/nightly-devops-triage.md, scripts/priorities_sync.py, scripts/tests/test_priorities_sync.py, docs/playbooks/priorities-status-sync.md, docs/eu-ai-act-compliance.md, docs/plans/plan-4559-priorities-status-sync.md
Plan authored by Claude Code worker session on vps-i1 for #4559. Implementation is a separate issue.