diff --git a/.claude/scripts/board-claim-token-contract.test.sh b/.claude/scripts/board-claim-token-contract.test.sh new file mode 100755 index 00000000..ac85eeea --- /dev/null +++ b/.claude/scripts/board-claim-token-contract.test.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Guards monorepo#2265: board-only claims (path-less project-board mutations) must carry a unique, +# greppable per-lane token. Every instance comments as `devantler`, so the disclosure line alone +# cannot tell siblings apart — without `board-claim:`, two runs racing the same board issue +# cannot recognise each other's claim, and the ~2h lease + reply-to-close rule has nothing durable +# to key on. +# +# Pins four properties across the contract + run-loop + board product card: +# 1. the greppable token template exists; +# 2. the three live lanes are the only admitted values; +# 3. the lease is timed from the comment's created_at (not assignment — there is none); +# 4. close-out is a reply to the claim comment. +# +# Phrases are kept on ONE line so `grep -Fq` cannot go silently red on a soft wrap (same class as +# monorepo#2250 / #2312 / #2349). + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +constitution="${repo_root}/AGENTS.md" +run_loop="${repo_root}/.claude/skills/portfolio-maintenance/SKILL.md" +board_card="${repo_root}/.claude/skills/products/project-board/SKILL.md" + +fail() { + echo "board-claim-token contract: FAIL — $*" >&2 + exit 1 +} + +grep -Fq 'board-claim:' "${constitution}" || + fail "constitution does not require the greppable board-claim: token" +grep -Fq 'exactly `claude`, `codex`, or `cursor`' "${constitution}" || + fail "constitution does not pin the three live lanes as the only admitted board-claim values" +grep -Fq "timed from the comment's \`created_at\`" "${constitution}" || + fail "constitution does not time the board-only lease from the claim comment's created_at" +grep -Fq 'Reply to your own claim comment when finished' "${constitution}" || + fail "constitution does not require reply-to-close on a board-only claim" + +grep -Fq 'board-claim:' "${run_loop}" || + fail "portfolio-maintenance board Act step does not require board-claim:" +grep -Fq '`board-claim:claude`' "${run_loop}" || + fail "run loop has no concrete lane example an instance can copy" +grep -Fq "timed from the comment's \`created_at\`" "${run_loop}" || + fail "run loop does not time the board-only lease from created_at" +grep -Fq 'reply to your own claim' "${run_loop}" || + fail "run loop does not require reply-to-close on a board-only claim" + +grep -Fq 'board-claim:' "${board_card}" || + fail "project-board card Mutation safety does not mention board-claim:" + +echo "board-claim-token contract: all assertions passed" diff --git a/.claude/skills/portfolio-maintenance/SKILL.md b/.claude/skills/portfolio-maintenance/SKILL.md index 106afec0..a9bfaf1f 100644 --- a/.claude/skills/portfolio-maintenance/SKILL.md +++ b/.claude/skills/portfolio-maintenance/SKILL.md @@ -297,13 +297,23 @@ submodule. Split its work in two, because only one half is path-less: check out and no build to validate. Don't let the repo-shaped Act step below cause the board to be skipped for want of a ``. ⚠️ **But you still need a CLAIM**: with no branch to push, a bare assignment is not a claim, so two instances can pick the same board issue and mutate the board - concurrently. Before mutating, **comment the claim on the issue** (disclosure line + what you are - about to change) and **re-read the issue immediately before acting** — if a sibling's disclosed - claim is already there, that lane is owned; pick something else. **The claim MUST expire and MUST be - closed out**, or a crashed run blocks the issue forever: treat a disclosed claim as **live for ~2 - hours** (matching the branch-claim lease) and **stale after that — take it over and say so in a - reply**. On finishing, **reply to your own claim** stating what changed; an un-replied claim older - than the lease is abandoned, not owned. + concurrently. Before mutating, **comment the claim on the issue** and **re-read the issue + immediately before acting**. The comment shape is load-bearing — every instance still comments as + `devantler`, so the disclosure line alone cannot tell siblings apart (monorepo#2265): + 1. **Unique token.** Every board-only claim comment MUST include the greppable token + `` `board-claim:` `` on its own line, where `` is exactly `claude`, `codex`, or + `cursor` (the posting instance's lane — never invent a fourth). Example for this skill's local + Claude instance: `` `board-claim:claude` ``. Match **your own** claim by that exact token; a + different lane's token is a sibling's. + 2. **Body.** Disclosure line → the `board-claim:` token → one sentence naming what you are + about to change. No other prose is required. + 3. **Lease.** A claim is **live for ~2 hours** timed from the comment's `created_at` (matching the + branch-claim lease) and **stale after that**. An unreplied claim older than the lease is + abandoned, not owned — take it over and say so in a **new** claim comment (do not edit the + stale one). If a sibling's live claim is already there, that lane is owned; pick something else. + 4. **Close-out.** On finishing (or abandoning mid-flight), **reply to your own claim** stating + what changed. The reply is what ends the lease early; without it, siblings wait out the full + ~2h window. - **Any accompanying file change** (an `add-to-project` workflow, an agent-definition or card update) is **ordinary monorepo work and keeps the FULL discipline** — per-run worktree, validate, draft PR. **Never skip isolation for it:** several instances run concurrently, and editing the shared checkout diff --git a/.claude/skills/products/project-board/SKILL.md b/.claude/skills/products/project-board/SKILL.md index 2049f87a..5282410c 100644 --- a/.claude/skills/products/project-board/SKILL.md +++ b/.claude/skills/products/project-board/SKILL.md @@ -184,6 +184,10 @@ Until that is solved, backfill is a standing duty, not an exception. ## Mutation safety +- **Claim before you mutate (board-only).** Path-less board work has no branch to push, so the claim + is a comment carrying `` `board-claim:` `` (`claude` / `codex` / `cursor`) — see the contract + *Claim protocol* rule 6 and the `portfolio-maintenance` project-board Act step (monorepo#2265). + Re-read comments before acting; stand down on a sibling's live unreplied claim; reply to close yours. - `updateProjectV2Field` with `singleSelectOptions` **replaces the whole option list** — always pass the existing option **`id`**s or every assignment is destroyed. Verify emoji codepoints after writing (🫴 Ready is **U+1FAF4**; a wrong codepoint silently rewrites the option name). diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ea834e73..299d1b58 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,6 +27,7 @@ jobs: review-provider-loop-contract: ${{ steps.filter.outputs.review-provider-loop-contract }} agent-role-delivery-contract: ${{ steps.filter.outputs.agent-role-delivery-contract }} work-priority-ladder: ${{ steps.filter.outputs.work-priority-ladder }} + board-claim-token: ${{ steps.filter.outputs.board-claim-token }} portfolio-surveyor: ${{ steps.filter.outputs.portfolio-surveyor }} product-value: ${{ steps.filter.outputs.product-value }} agent-telemetry: ${{ steps.filter.outputs.agent-telemetry }} @@ -136,6 +137,12 @@ jobs: - '.claude/skills/portfolio-maintenance/SKILL.md' - '.claude/scripts/work-priority-ladder.test.sh' - '.github/workflows/ci.yaml' + board-claim-token: + - 'AGENTS.md' + - '.claude/skills/portfolio-maintenance/SKILL.md' + - '.claude/skills/products/project-board/SKILL.md' + - '.claude/scripts/board-claim-token-contract.test.sh' + - '.github/workflows/ci.yaml' portfolio-surveyor: - 'AGENTS.md' - '.claude/agents/daily-maintainer.md' @@ -514,6 +521,21 @@ jobs: - name: Verify the portfolio surveyor contract run: bash .claude/scripts/portfolio-surveyor.test.sh + test-board-claim-token-contract: + name: Test board-claim token contract + needs: changes + if: needs.changes.outputs.board-claim-token == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Verify the board-claim token contract + run: bash .claude/scripts/board-claim-token-contract.test.sh + status: name: "CI - Required Checks" runs-on: ubuntu-latest @@ -535,6 +557,7 @@ jobs: - test-review-provider-loop-contract - test-agent-role-delivery-contract - test-work-priority-ladder + - test-board-claim-token-contract - test-portfolio-surveyor-contract - test-product-value-contract permissions: {} @@ -559,5 +582,6 @@ jobs: ${{ needs.test-review-provider-loop-contract.result }} ${{ needs.test-agent-role-delivery-contract.result }} ${{ needs.test-work-priority-ladder.result }} + ${{ needs.test-board-claim-token-contract.result }} ${{ needs.test-portfolio-surveyor-contract.result }} ${{ needs.test-product-value-contract.result }} diff --git a/AGENTS.md b/AGENTS.md index ed6d2caf..404c6ba5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -710,6 +710,16 @@ touch of an unconfirmed repo: **audits the winner too**: re-check its findings against `main` before discarding them (that is how the merged armour guard's membership-vs-mapping gap was found). +6. **Board-only claims need a unique token — assignment+branch cannot cover them.** Path-less + project-board mutations have no branch to push, so the claim is a **comment**. Every instance + still comments as `devantler`, and the disclosure line alone cannot tell siblings apart + (monorepo#2265). Every board-only claim comment MUST carry the greppable token + `` `board-claim:` `` where `` is exactly `claude`, `codex`, or `cursor`. An instance + matches **its own** claim by that token; a different lane's unreplied claim younger than ~2h + (timed from the comment's `created_at`) is a live sibling claim — stand down. Reply to your own claim comment when finished (or when abandoning); an unreplied claim older than the lease is + abandoned and may be taken over. Full procedure lives in the `portfolio-maintenance` skill's + project-board Act step. + **A live claim is a temporary skip — the one addition to the skip test.** *Drain oldest-first* lists when an older issue may be passed over; a **live claim** (assigned **and** branched, inside the ~2h window, no PR yet) now joins it as skip reason **(e)**, and it is the only one that expires on its