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 #4559Measured in this treeEffect on plan
F1Archive resolved rows into a new ## ✅ Resolved Archive (auto-synced) sectiondocs/priorities.md:5 states the opposite policy — “resolved rows are removed (git history preserves them)” — and :117 already names git log --follow as the archiveDecision D1 — recommend remove + record in commit/PR body, not an archive section
F2A row links one issue/PR8 of 40 rows carry 2–3 links ([#4053] · [#4065] · [#2824]); some mix in GitLab MRs and doc pathsResolution must be AND across every link; any non-gh-resolvable link ⇒ skip row
F3Must update NightlyTriagePhaseFailed coverage in ai-workers.ymlexpr: p24_nightly_triage_result{result="failed"} > 0 — label-agnostic, new slugs covered automaticallyai-workers.yml drops out of scope (no-op edit)
F4Critical file scripts/run-hourly-triage.shDoes not exist. Real runner is scripts/run-nightly-triage.shPath correction
F5Reuse the branch+PR pattern from credential-rotation.ymlThat 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
F6Phase 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
F7Verify against the current file: negative test on a 2416-style row, idempotency test on an existing ✅ RESOLVED rowCurrent file has 0 rows containing ✅ RESOLVED, 0 strikethroughs, and 0 mentions of #2416. Also 0 rows currently qualify as resolvedBoth tests need synthetic fixtures; first real dry-run legitimately reports 0 actionable
F8Act 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 proseNew hard guard: section allowlist + exact header-row match

Recommended scope, in order:

  1. Add Phase 3.5 to .claude/commands/nightly-devops-triage.md with slug priorities-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).
  2. Ship dry-run-only in PR #1. Flipping $PRIORITIES_SYNC_DRYRUN to $false is a separate one-line PR — that one-liner is the human enablement gate the issue asks for.
  3. 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.
  4. Register nightly-devops-triage in docs/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.
  5. Do not touch monitoring/prometheus/rules/ai-workers.yml (F3).
  6. **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

FileWhat changesWhy
.claude/commands/nightly-devops-triage.mdInsert ## 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-phaseSeven-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.pyNew. 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 thinA 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.pyNew. The 14 fixture tests in §7scripts/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.mdNew. 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 PRCLAUDE.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.mdAdd 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-analystSee §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.mdNew — this filePlan 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 is workflow_dispatch-only with permissions: contents: read and 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. Granting contents: write to a manually-dispatchable workflow widens blast radius for no gain.
  • scripts/run-nightly-triage.sh — no change needed. It already cds to $REPO_DIR and 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 #NNNN prose 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 log grows 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:

  • Status contains ✅ RESOLVED, or
  • Item starts 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);
  • Ref contains any other markdown link besides those matches — i.e. link count ≠ match count. This is what excludes the #2542 · MR !792 row: GitLab MR state is invisible to gh, 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:

KindResolved whenRationale
pull/Nstate == "MERGED"Merged code shipped
issues/Nstate == "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 CLOSED with milestone Review, 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. The milestone == "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:

  1. 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.
  2. docs/priorities.md:117 already designates the archive: “Full historical session log: git log --follow docs/priorities.md. Option A’s commit body makes every removal greppable there.
  3. 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"
fi

Fixed 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).

  1. Mode: $PRIORITIES_SYNC_DRYRUN -eq $false to reach the commit/push/PR steps; otherwise report-only (§6).
  2. bin/gh-app-token.sh --check succeeds and the mint returns a non-empty token.
  3. git clone --depth 1 succeeds.
  4. docs/priorities.md exists in the clone and contains at least one $PRIORITIES_SYNC_SECTIONS header (structure sanity — a rewritten file must not be silently mangled).
  5. Resolved-row count > 0 — zero is the expected steady state (F7), a success, and produces no commit.
  6. Post-edit diff self-check, and it is a hard gate: git diff --numstat -- docs/priorities.md must touch only docs/priorities.md, and under Option A deletions == rowsRemoved with insertions == 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.
  7. GH Actions self-skip: if $env:GITHUB_ACTIONS -eq "true", force dry-run. That path is workflow_dispatch-only with contents: 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:

TestFixtureExpect
Negative — closed-but-not-shippedrow referencing a CLOSED / milestone Review issue (#2416’s shape)untouched
Positive — merged PRrow referencing a MERGED PRresolved
Positive — shipped issuerow referencing CLOSED + milestone Mainresolved
Multi-link AND (F2)row with 3 links, 2 shipped 1 openuntouched
Non-gh link (F2)row with [#2542](…) · [MR !792](gitlab…)untouched, WARN
Mislabelled link[#1234](…/issues/5678)untouched, WARN (back-reference)
Idempotency — statusrow already ✅ RESOLVEDuntouched, zero gh calls
Idempotency — strikethroughrow whose Item starts ~~untouched, zero gh calls
Wrong-section guard (F8)resolvable-looking row inside ## 🟢 Pending Human Actionsuntouched
Session-log guard (F9)## Recent session log bullet containing pipes and a markdown issue-linkuntouched
Byte-identityfull current docs/priorities.md, all refs stubbed unresolvedoutput sha256 == input sha256
Malformed row2-cell and 4-cell rowsuntouched, WARN, run continues
Cap9 resolvable rows5 acted, 4 deferred, no error
Diff-shape gate (§5.1.6)parser stubbed to also edit another fileabort, no commit

Live sequencing:

  1. bin/gh-app-token.sh --check on bms-4 — a precondition of **PR 1, not PR #2, since §6 makes dry-run mint a token too. Fixture tests and ruff check . green locally.
  2. 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.
  3. One real nightly dry-run on bms-4. Confirm the Discord/log report, and confirm 0 resolvable reads as healthy steady state, not as breakage — and that a mint failure reports skipped, not 0 resolvable (§6).
  4. 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 off main means there is nothing to revert.
  5. Resolve D1.
  6. PR #2 — flip $PRIORITIES_SYNC_DRYRUN to $false. One line.

8. Regression risks

RiskWhy 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 CheckByte-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 indefinitelyMint failure is classified skipped, never 0 resolvable — distinct log line, distinct Discord embed, Push-TriagePhaseMetric … failedNightlyTriagePhaseFailed fires (§6). Relevant now that dry-run also mints
Dry-run reviewed against a different file than the write run editsDry-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 nightsFixed branch force-reset from fresh main (F5)
Phase aborts the whole triage runWrapped 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 driftsFrontmatter description, phase list, and slug list are all updated in the same PR; §1 names them explicitly
Widening the worker prohibitioninfra/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 with bin/gh-app-token.sh --check on 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_B64 is 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 as 0 resolvable — see §6.
  • App installation must grant contents:write + pull_requests:write on radieu/p24-infra.
  • Haiku is sufficient — mechanical regex + gh state lookups, per the skill’s own Model selection note and docs/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.