diff --git a/.github/scripts/add-to-project/classify-initiative.sh b/.github/scripts/add-to-project/classify-initiative.sh
new file mode 100755
index 000000000..30408e1e4
--- /dev/null
+++ b/.github/scripts/add-to-project/classify-initiative.sh
@@ -0,0 +1,309 @@
+#!/usr/bin/env bash
+# classify-initiative.sh — assign the Initiative single-select field (and its
+# rolled-up Theme) on Initiatives-project items that don't have one yet.
+#
+# Why this exists (petry-projects/.github#415): the add-to-project automation
+# only ever places items ON the board (addProjectV2ItemById); nothing set the
+# Initiative field, so nearly every item sat on the board unassociated with any
+# initiative. This is the continuous, deterministic classifier that fills that
+# gap — and its first run back-fills the existing blank items.
+#
+# Classification is RULE-DRIVEN, not AI: each item's title + labels + repo are
+# flattened into a lowercase "signature" and matched against ordered regex
+# rules (initiative-rules.tsv, first match wins). A matched item gets its
+# Initiative set, plus the Theme that Initiative rolls up to
+# (initiative-taxonomy.tsv). An item that matches NO rule is left blank and
+# reported for triage — the classifier never guesses a bucket.
+#
+# Safety: this only ever writes per-item field VALUES
+# (updateProjectV2ItemFieldValue via lib.sh's set_item_single_select_value).
+# It never touches the field SCHEMA (updateProjectV2Field), so it cannot trip
+# the single-select option-wipe footgun documented in
+# standards/initiatives-project.md. By default it only fills items whose
+# Initiative is currently empty, so a human's manual assignment is never
+# overwritten; RECLASSIFY=all re-evaluates every item.
+#
+# Required env:
+# PROJECT_ID ProjectV2 node ID of the Initiatives project
+# GH_TOKEN Token with org Projects: Read+write
+#
+# Optional env:
+# PROJECT_URL Logged in human-readable messages only
+# DRY_RUN=1 Log intended writes, mutate nothing
+# RECLASSIFY=all Re-evaluate items that already have an Initiative
+# RULES_FILE Override path to the rules TSV
+# TAXONOMY_FILE Override path to the Initiative→Theme TSV
+# PAGE_SIZE Items fetched per GraphQL page (default 100)
+# INITIATIVE_FIELD Initiative field name (default "Initiative")
+# THEME_FIELD Theme field name (default "Theme")
+#
+# Functions (sourceable / unit-tested):
+# normalize_signature
+# classify_by_rules → Initiative name or "" (no match)
+# theme_for → Theme name or ""
+# decide_for_signature → "\t" or ""
+# resolve_fields → populate field-id / option maps
+# sweep_project → paginate + reconcile the board
+
+set -euo pipefail
+
+_ci_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source-path=SCRIPTDIR
+# shellcheck source=lib.sh
+. "${_ci_dir}/lib.sh"
+
+# Field ids + name→optionId maps, resolved once per run by resolve_fields.
+declare -gA CI_INIT_OPT CI_THEME_OPT
+CI_INIT_FIELD_ID=""
+CI_THEME_FIELD_ID=""
+
+# normalize_signature
+# Flatten an item into one lowercase line "title | l1,l2 | owner/repo" that
+# the regex rules match against. A non-array labels_json (null/object from
+# odd payloads) degrades to no labels rather than aborting under set -e.
+#
+# Gate labels are STRIPPED from the signature (SIGNATURE_IGNORE_LABELS,
+# default = the required + excluded noise-gate labels). This matters:
+# every board item carries the `dev-lead` required label, so leaving it in
+# would make the `dev-lead agent` rule match every single item. Only labels
+# that actually signal an initiative survive into the signature.
+normalize_signature() {
+ if [ "$#" -ne 3 ]; then
+ printf '[normalize_signature] expected 3 args (title labels_json repo), got %d\n' "$#" >&2
+ return 64
+ fi
+ local title="$1" labels_json="$2" repo="$3" labels=""
+ # Drop routing/process labels that carry no initiative signal, so they can't
+ # drive a classification. Two mechanisms:
+ # 1. an explicit ignore list (SIGNATURE_IGNORE_LABELS) — the noise-gate
+ # labels (dev-lead + the excluded set);
+ # 2. a family-prefix strip for `dev-lead*` and `initiative*`, which also
+ # removes their colon-variants (`dev-lead:needs-human`,
+ # `initiative:auto`, …). Those variants contain "dev-lead"/"initiative"
+ # and would otherwise make the `dev-lead agent` / `Initiatives Project`
+ # rule tokens match every pipeline-routed item.
+ local ignore="${SIGNATURE_IGNORE_LABELS-dev-lead,compliance-audit,health-check,fleet-tracker,daily-report}"
+ if printf '%s' "${labels_json}" | jq -e 'type == "array"' >/dev/null 2>&1; then
+ labels=$(printf '%s' "${labels_json}" | jq -r --arg ig "${ignore}" '
+ ($ig | ascii_downcase | split(",") | map(gsub("^\\s+|\\s+$";"")) | map(select(length > 0))) as $drop
+ | [ .[].name
+ | select((ascii_downcase) as $n
+ | (($drop | index($n)) | not)
+ and (($n | test("^(dev-lead|initiative)(:|$)")) | not)) ]
+ | join(",")')
+ fi
+ printf '%s | %s | %s' "${title}" "${labels}" "${repo}" | tr '[:upper:]' '[:lower:]'
+}
+
+# classify_by_rules → first-matching Initiative name, or "".
+classify_by_rules() {
+ if [ "$#" -ne 1 ]; then
+ printf '[classify_by_rules] expected 1 arg (signature), got %d\n' "$#" >&2
+ return 64
+ fi
+ local sig="$1" name rx
+ local rules="${RULES_FILE:-${_ci_dir}/initiative-rules.tsv}"
+ if [ ! -f "${rules}" ]; then
+ printf '[classify_by_rules] rules file not found: %s\n' "${rules}" >&2
+ return 65
+ fi
+ while IFS=$'\t' read -r name rx || [ -n "${name}" ]; do
+ name="${name%$'\r'}"
+ rx="${rx%$'\r'}"
+ case "${name}" in ''|'#'*) continue ;; esac
+ [ -n "${rx}" ] || continue
+ if printf '%s' "${sig}" | grep -Eiq -- "${rx}"; then
+ printf '%s' "${name}"
+ return 0
+ fi
+ done < "${rules}"
+ return 0
+}
+
+# theme_for → the Theme it rolls up to, or "".
+theme_for() {
+ if [ "$#" -ne 1 ]; then
+ printf '[theme_for] expected 1 arg (initiative), got %d\n' "$#" >&2
+ return 64
+ fi
+ local want="$1" init theme
+ local tax="${TAXONOMY_FILE:-${_ci_dir}/initiative-taxonomy.tsv}"
+ if [ ! -f "${tax}" ]; then
+ printf '[theme_for] taxonomy file not found: %s\n' "${tax}" >&2
+ return 65
+ fi
+ while IFS=$'\t' read -r init theme || [ -n "${init}" ]; do
+ init="${init%$'\r'}"
+ theme="${theme%$'\r'}"
+ case "${init}" in ''|'#'*) continue ;; esac
+ if [ "${init}" = "${want}" ]; then
+ printf '%s' "${theme}"
+ return 0
+ fi
+ done < "${tax}"
+ return 0
+}
+
+# decide_for_signature → "\t" (theme may be
+# empty), or "" when no rule matches.
+decide_for_signature() {
+ if [ "$#" -ne 1 ]; then
+ printf '[decide_for_signature] expected 1 arg (signature), got %d\n' "$#" >&2
+ return 64
+ fi
+ local sig="$1" init theme
+ init=$(classify_by_rules "${sig}") || return $?
+ [ -n "${init}" ] || return 0
+ theme=$(theme_for "${init}") || return $?
+ printf '%s\t%s' "${init}" "${theme}"
+}
+
+# resolve_fields — query the project once for the Initiative + Theme
+# single-select field ids and their live option name→id maps. Fails loudly
+# (75) if the project node is unreachable, (65) if the Initiative field is
+# absent. Theme is best-effort: its absence is not fatal.
+resolve_fields() {
+ _atp_require_env resolve_fields || return $?
+ local json
+ # shellcheck disable=SC2016 # $projectId/$initName/$themeName are GraphQL variables
+ json=$(gh api graphql \
+ -F projectId="${PROJECT_ID}" \
+ -F initName="${INITIATIVE_FIELD:-Initiative}" \
+ -F themeName="${THEME_FIELD:-Theme}" \
+ -f query='query($projectId:ID!,$initName:String!,$themeName:String!){
+ node(id:$projectId){
+ ... on ProjectV2 {
+ initiative: field(name:$initName){ ... on ProjectV2SingleSelectField { id options{ id name } } }
+ theme: field(name:$themeName){ ... on ProjectV2SingleSelectField { id options{ id name } } }
+ }
+ }
+ }')
+
+ if [ "$(printf '%s' "${json}" | jq -r '.data.node?')" = "null" ]; then
+ printf '[resolve_fields] GraphQL returned data.node:null for PROJECT_ID=%s — token may lack access, or the project was deleted.\n' "${PROJECT_ID}" >&2
+ return 75
+ fi
+ CI_INIT_FIELD_ID=$(printf '%s' "${json}" | jq -r '.data.node.initiative.id? // ""')
+ CI_THEME_FIELD_ID=$(printf '%s' "${json}" | jq -r '.data.node.theme.id? // ""')
+ if [ -z "${CI_INIT_FIELD_ID}" ]; then
+ printf '[resolve_fields] Initiative single-select field %q not found on the project.\n' "${INITIATIVE_FIELD:-Initiative}" >&2
+ return 65
+ fi
+
+ local id name
+ while IFS=$'\t' read -r id name; do
+ [ -n "${id}" ] && CI_INIT_OPT["${name}"]="${id}"
+ done < <(printf '%s' "${json}" | jq -r '.data.node.initiative.options?[]? | "\(.id)\t\(.name)"')
+ while IFS=$'\t' read -r id name; do
+ [ -n "${id}" ] && CI_THEME_OPT["${name}"]="${id}"
+ done < <(printf '%s' "${json}" | jq -r '.data.node.theme.options?[]? | "\(.id)\t\(.name)"')
+}
+
+# _ci_report
+_ci_report() {
+ local total="$1" already="$2" matched="$3" unmatched="$4" skipped="$5"
+ local mode="apply"; [ "${DRY_RUN:-}" = "1" ] && mode="dry-run"
+ printf '\n=== classify-initiative summary (%s) ===\n' "${mode}"
+ printf ' board items scanned : %d\n' "${total}"
+ printf ' already associated : %d (skipped; RECLASSIFY=all to re-evaluate)\n' "${already}"
+ printf ' newly matched : %d\n' "${matched}"
+ printf ' unmatched (blank) : %d\n' "${unmatched}"
+ printf ' option-missing skip : %d\n' "${skipped}"
+ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
+ {
+ printf '### classify-initiative (%s)\n\n' "${mode}"
+ printf '| scanned | already | matched | unmatched | skipped |\n'
+ printf '|--:|--:|--:|--:|--:|\n'
+ printf '| %d | %d | %d | %d | %d |\n' "${total}" "${already}" "${matched}" "${unmatched}" "${skipped}"
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+}
+
+# sweep_project — resolve fields, page through every board item, and set the
+# Initiative (+ Theme) on each qualifying item. Idempotent; DRY_RUN-aware.
+sweep_project() {
+ _atp_require_env sweep_project || return $?
+ resolve_fields || return $?
+
+ local page_size="${PAGE_SIZE:-100}" items
+ # --paginate concatenates one JSON document per page; jq -s slurps them and
+ # flattens all item nodes into a single array.
+ # shellcheck disable=SC2016 # GraphQL variables, not shell
+ items=$(gh api graphql --paginate \
+ -F projectId="${PROJECT_ID}" \
+ -F pageSize="${page_size}" \
+ -F initName="${INITIATIVE_FIELD:-Initiative}" \
+ -f query='query($projectId:ID!,$pageSize:Int!,$endCursor:String,$initName:String!){
+ node(id:$projectId){
+ ... on ProjectV2 {
+ items(first:$pageSize, after:$endCursor){
+ pageInfo{ hasNextPage endCursor }
+ nodes{
+ id
+ initiative: fieldValueByName(name:$initName){ ... on ProjectV2ItemFieldSingleSelectValue { name } }
+ content{
+ __typename
+ ... on Issue { title labels(first:20){ nodes{ name } } repository{ nameWithOwner } }
+ ... on PullRequest { title labels(first:20){ nodes{ name } } repository{ nameWithOwner } }
+ ... on DraftIssue { title }
+ }
+ }
+ }
+ }
+ }
+ }' | jq -s '[.[].data.node.items.nodes?[]?]')
+
+ local total=0 already=0 matched=0 unmatched=0 skipped=0
+ local node
+ while IFS= read -r node; do
+ [ -n "${node}" ] || continue
+ total=$((total + 1))
+
+ local item_id cur title labels repo sig decided init theme optid
+ item_id=$(printf '%s' "${node}" | jq -r '.id?')
+ cur=$(printf '%s' "${node}" | jq -r '.initiative.name? // ""')
+ title=$(printf '%s' "${node}" | jq -r '.content.title? // ""')
+ labels=$(printf '%s' "${node}" | jq -c '.content.labels.nodes? // []')
+ repo=$(printf '%s' "${node}" | jq -r '.content.repository.nameWithOwner? // ""')
+
+ if [ -n "${cur}" ] && [ "${RECLASSIFY:-}" != "all" ]; then
+ already=$((already + 1))
+ continue
+ fi
+
+ sig=$(normalize_signature "${title}" "${labels}" "${repo}")
+ decided=$(decide_for_signature "${sig}")
+ init="${decided%%$'\t'*}"
+ theme="${decided#*$'\t'}"
+
+ if [ -z "${init}" ]; then
+ unmatched=$((unmatched + 1))
+ printf 'UNMATCHED %-20s «%s»\n' "${repo:-draft}" "${title}"
+ continue
+ fi
+
+ optid="${CI_INIT_OPT[${init}]:-}"
+ if [ -z "${optid}" ]; then
+ printf '::warning::rule matched Initiative %q which is not a live project option; skipping «%s»\n' "${init}" "${title}" >&2
+ skipped=$((skipped + 1))
+ continue
+ fi
+
+ printf 'MATCH %-22s <- %-20s «%s»\n' "${init}" "${repo:-draft}" "${title}"
+ set_item_single_select_value "${item_id}" "${CI_INIT_FIELD_ID}" "${optid}"
+ matched=$((matched + 1))
+
+ # Theme is best-effort: co-assign only when the field and matching option
+ # both exist live. A missing Theme field/option is silently tolerated.
+ if [ -n "${theme}" ] && [ -n "${CI_THEME_FIELD_ID}" ]; then
+ local topt="${CI_THEME_OPT[${theme}]:-}"
+ [ -n "${topt}" ] && set_item_single_select_value "${item_id}" "${CI_THEME_FIELD_ID}" "${topt}"
+ fi
+ done < <(printf '%s' "${items}" | jq -c '.[]')
+
+ _ci_report "${total}" "${already}" "${matched}" "${unmatched}" "${skipped}"
+}
+
+if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
+ sweep_project
+fi
diff --git a/.github/scripts/add-to-project/initiative-rules.tsv b/.github/scripts/add-to-project/initiative-rules.tsv
new file mode 100644
index 000000000..ccc3e2b7a
--- /dev/null
+++ b/.github/scripts/add-to-project/initiative-rules.tsv
@@ -0,0 +1,38 @@
+# Initiative ERE regex — deterministic classification rules for
+# classify-initiative.sh, matched (case-insensitive) against a per-item
+# signature = "title | label,label | owner/repo", all lowercased.
+#
+# ORDER IS PRIORITY (first match wins). Two tiers:
+# 1. CROSS-CUTTING infra/agentic initiatives come FIRST — they win
+# regardless of which repo the work lives in. This is deliberate: a
+# SonarCloud/Scorecard sweep or an auto-rebase fix inside a product repo
+# is Org Standards / Auto-rebase work, not product work (see
+# standards/initiatives-project.md → Org Standards covers scorecard/
+# sonarcloud across all repos).
+# 2. PRODUCT initiatives come LAST — a repo-scoped fallback. Anything in a
+# product repo that no infra rule claimed, plus product-feature idea
+# drafts (which have no repo), get the product bucket via repo name or
+# product-feature keywords.
+# Unmatched items are left BLANK and reported for triage — never guessed.
+Auto-rebase auto.?rebase|update.?branch|rebase (bot|loop|collision)|conflict.?comment|dismiss.?stale|last.?push.?approval|stale.?base
+pr-review agent pr.?review|(code|bot|single|advisory) review|review.?(one.?pr|decision|enumeration|loop|comments?|bot|gate|findings|annotation|enhancement|dismissal|tier|cycles?|retry)|single.?review|max_review|rubber duck|cascade|advisory.?bot|coderabbit|(gemini|copilot|sonarcloud|codex) review|fix.?request|extract_verdict|verdict|mention.?(ack|trigger)|changes_requested|@petry-review|@donpetry-bot|review-one-pr|list-prs|copilot suggest|github models|churn breaker|self.?trigger|downstream.?impact|pr_diff|mcp.*(review|tiers?|knob|config|server)|lsp (index|pilot|tool)|reviewer|awaiting review|#860
+Business Analyst feature.?ideation|idea.?enhanc|idea.?triage|idea-enhancer|business analyst|enhance .*ideas?|single-idea enhancement|idea..?initiative pipeline
+Model Selection model.?(selection|fallback|tier|switch)|fallback model|opus.?fallback|retire .*(opus|gpt|claude-|model)|update to opus|opus 4|cross.?provider|provider.?switch|additional providers|providers,? .*model|engine.?(availability|model|upgrade)|multi-model chain|rate.?limit resilience
+Agent Shield agent.?shield|prompt.?injection|input sanitiz|sanitiz|agent.?(security|hardening|governance)|jailbreak|secret.?redact|mcp.*(governance|allowlist|registry)|credential isolation|owasp|posture scoring|egress (firewall|policy|filter|rule)|firewall policy|trust escalation|agent autonomy
+Self-healing self.?heal|auto.?remediat|auto.?fix|self.?repair|stall detect|pipeline stall|recovery escalation|kill switch|circuit breaker|runaway (protection|agent|pr)
+dev-lead agent dev.?lead|deep.?impl|bmad|engine.?(timeout|error)|story churn|phantom.?spec|thin.?caller|inlined (claude|workflow)|claude\.yml|claude-fix|claude-?issue|claude code workflow|claude workflow|issue automation|prompting framework|devops agent persona|duplicate .*(pr|agent)|dedup|umbrella issue|in-progress label
+GH-AW gh-?aw|agentic.?workflow|(^|[^[:alnum:]])aw([^[:alnum:]]|$)|ci.?failure|failure.?analyst|ci-?analyst|failure-to-agent|lock\.?file|lock\.yml|eval scorer|held-out|rubric|proposer|skill.?eval|skillopt|skill.?candidate|plan_json|artifact (type|contract)|self-improving|eval-case|evals/|agents\.md (spec|align)
+Copilot Instructions copilot.*instruction|copilot coding agent|copilot governance|copilot custom|\.github/copilot|copilot-instructions
+Cost Observability cost (attribution|observ|meter|arbitrage|report|api)|token.?(cost|savings|report|metric|observ)|budget (enforcement|circuit breaker|guardrail|tracking|aware)|credit budget|observability dashboard|opentelemetry|otel|effective token|\bet\b .*anomaly|usage & cost|billing-aware|batch api cost
+Fleet Monitor fleet.?(monitor|tracker|view|health)|health.?check|hourly runs?|cron entr|scheduled .*(runs?|actions)
+Daily Reports daily.?report|nightly.?report|status.?digest|org.?status
+Compliance Blitz compliance.?blitz|compliance.?day|compliance backlog
+Org Standards sonarcloud|sonar[ ._-]|s7[0-9]{3}|s86[0-9]{2}|s59[0-9]{2}|s3776|s5906|scorecard|ruleset|codeowners|branch.?(protection|ruleset)|push.?protection|ci.?standard|baseline|standards.?(sync|deploy|reconcile)|nosonar|secret.?scan|gitleaks|dependency.?(harden|pinning)|pin.*(sha|ref|reusable|version|action)|actionlint|yamllint|lint check|dependabot|org.?standard|org-wide .*(standard|instruction|reusable)|code.?quality|zizmor|oidc|access control|standards drift|drift.?(guard|detection)|integrity verification|execution protection|security linting|pull.?request.?limits|pr.?limits|admission gate|agents\.md|claude\.md|codeql|reusable workflow|apply-repo-settings|repo settings|profile readme|coding standard|ci pipeline|markdown lint|concurrency group|check-suite|slsa|attestation|bootstrap.?new.?repo|repo.?template|non-file policy|require .*discussions|has_discussions|structured logging|cqrs|stacked pr|worktree|convention|merge queue|noise reduction|cognitive complexity|jest assertion
+Compliance program compliance|audit|remediat
+Release Strategy release.?(strategy|train|notes|channel|cadence|automation|cut|soak|ring)|cut.?release|autocut|soak.?(promote|and-promote)|ring.?(rollout|promote|enrol|stag)|channel.?(promote|cut|soak|pin)|canary|version(ing)? (scheme|standard)|semantic versioning|tag protection|rollback|promote .*(channel|ring|stable)
+Initiatives Project initiative|add.?to.?project|projects? v2|project board|roadmap view|initiatives project|issue.?fields|github projects
+Tooling tooling|scratchpad|dev.?tool|utility script|template gallery
+Broodly /broodly|broodminder|\bhive\b|honey|nectar|bloom|colony|\bmite|apiary|beekeep|hornet|treatment efficacy|nfc and qr|acoustic colony|zero-tap|off-season engagement
+Markets /markets|snap/ebt|produce recognition|app-averse|failed-trip|vendor catalog|north-star|discover filter|sms check-in
+Google App Scripts /google-app-scripts|apps? script|script_catalog|gmail|deployed-script
+TalkTerm /talkterm|avatar|elevenlabs|lip-sync|screen companion|companion mode|3d avatar|spatial audio|byok|effort modes|voice (assistant|agent|interface|provider|transcription)|conversational (voice|oauth|permission|mcp|onboarding)|screenshot context|deliberation mode|emotion-aware|multi-language voice|infinite conversation|on-device (voice|ai)|vision-native
diff --git a/.github/scripts/add-to-project/initiative-taxonomy.tsv b/.github/scripts/add-to-project/initiative-taxonomy.tsv
new file mode 100644
index 000000000..9dd686865
--- /dev/null
+++ b/.github/scripts/add-to-project/initiative-taxonomy.tsv
@@ -0,0 +1,25 @@
+# Initiative Theme — canonical roll-up used by classify-initiative.sh.
+# Comments (#) and blank lines ignored. See standards/initiatives-project.md.
+dev-lead agent Agentic Framework
+pr-review agent Agentic Framework
+GH-AW Agentic Framework
+Copilot Instructions Agentic Framework
+Agent Shield Agentic Framework
+Model Selection Agentic Framework
+Fleet Monitor Fleet Operations
+Daily Reports Fleet Operations
+Org Standards Fleet Operations
+Release Strategy Fleet Operations
+Compliance program Compliance
+Compliance Blitz Compliance
+Self-healing Compliance
+Auto-rebase Compliance
+Initiatives Project Tooling
+Tooling Tooling
+Ad hoc Ad hoc
+Business Analyst Agentic Framework
+Cost Observability Fleet Operations
+TalkTerm Products
+Broodly Products
+Markets Products
+Google App Scripts Products
diff --git a/.github/scripts/add-to-project/lib.sh b/.github/scripts/add-to-project/lib.sh
index 04d19baea..9f2bb86f4 100644
--- a/.github/scripts/add-to-project/lib.sh
+++ b/.github/scripts/add-to-project/lib.sh
@@ -260,3 +260,45 @@ delete_project_item() {
return 1
fi
}
+
+# set_item_single_select_value
+# Set a single-select field on one project item to the given option.
+#
+# This is updateProjectV2ItemFieldValue — a per-item VALUE write. It is NOT
+# updateProjectV2Field (the schema mutation that full-replaces the option
+# list and can orphan every item's value; see
+# standards/initiatives-project.md → "Adding or modifying single-select
+# options safely"). Writing an item value here can never wipe the schema, so
+# a back-fill sweep over hundreds of items is inherently safe.
+#
+# Exit 0 on success; 64 on bad args. DRY_RUN=1 logs the intended write and
+# mutates nothing.
+set_item_single_select_value() {
+ if [ "$#" -ne 3 ]; then
+ printf '[set_item_single_select_value] expected 3 args (item_id field_id option_id), got %d\n' "$#" >&2
+ return 64
+ fi
+ local item_id="$1"
+ local field_id="$2"
+ local option_id="$3"
+
+ if [ "${DRY_RUN:-}" = "1" ]; then
+ printf '[dry-run] would set item %s field %s = option %s\n' "${item_id}" "${field_id}" "${option_id}"
+ return 0
+ fi
+
+ # shellcheck disable=SC2016 # $projectId/$itemId/$fieldId/$optionId are GraphQL variables
+ gh api graphql \
+ -F projectId="${PROJECT_ID}" \
+ -F itemId="${item_id}" \
+ -F fieldId="${field_id}" \
+ -F optionId="${option_id}" \
+ -f query='mutation($projectId:ID!,$itemId:ID!,$fieldId:ID!,$optionId:String!){
+ updateProjectV2ItemFieldValue(input:{
+ projectId:$projectId, itemId:$itemId, fieldId:$fieldId,
+ value:{ singleSelectOptionId:$optionId }
+ }){
+ projectV2Item { id }
+ }
+ }' >/dev/null
+}
diff --git a/.github/workflows/add-to-project-reconcile.yml b/.github/workflows/add-to-project-reconcile.yml
index bbd5f324e..dcb94b0ae 100644
--- a/.github/workflows/add-to-project-reconcile.yml
+++ b/.github/workflows/add-to-project-reconcile.yml
@@ -9,19 +9,29 @@
#
# Runs centrally in petry-projects/.github (the scripts live here); mints the
# petry-projects-planner App token for issues/PRs read + project write.
+#
+# Two phases run in order each cycle:
+# 1. reconcile — ensure every qualifying issue/PR/Idea is ON the board.
+# 2. classify — set the Initiative (+ Theme) field on board items that
+# don't have one yet (petry-projects/.github#415). Classify runs AFTER
+# reconcile so items added this cycle are categorized in the same run.
name: Add-to-Project Reconcile
on:
workflow_dispatch:
inputs:
dry_run:
- description: "Log intended adds/removes without mutating the board"
+ description: "Log intended adds/removes/classifications without mutating the board"
type: boolean
default: true
repos:
description: "Optional space-separated owner/repo list (default: all App-installed repos)"
type: string
default: ""
+ reclassify_all:
+ description: "Re-evaluate the Initiative on items that already have one (default: only fill blanks)"
+ type: boolean
+ default: false
schedule:
- cron: "0 7 * * *" # daily 07:00 UTC
@@ -63,3 +73,15 @@ jobs:
# Manual runs default to dry-run; scheduled runs are live.
DRY_RUN: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '1' || '0' }}
run: bash .github/scripts/add-to-project/reconcile-backlog.sh
+
+ - name: Classify Initiative on board items
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ PROJECT_ID: PVT_kwDOD2inqs4BZq3-
+ PROJECT_URL: https://github.com/orgs/petry-projects/projects/1
+ # Same dry-run gating as reconcile: manual runs default to dry-run,
+ # scheduled runs are live.
+ DRY_RUN: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '1' || '0' }}
+ # Only fill blank Initiatives by default; opt in to re-evaluate all.
+ RECLASSIFY: ${{ (github.event_name == 'workflow_dispatch' && inputs.reclassify_all) && 'all' || '' }}
+ run: bash .github/scripts/add-to-project/classify-initiative.sh
diff --git a/standards/initiatives-project.md b/standards/initiatives-project.md
index 80b27d4ea..2e6e32d7f 100644
--- a/standards/initiatives-project.md
+++ b/standards/initiatives-project.md
@@ -109,8 +109,8 @@ Theme. Roadmap-view grouping uses Initiative; cross-cutting filters
| Theme | Initiatives |
|---|---|
-| **Agentic Framework** | `dev-lead agent`, `pr-review agent`, `GH-AW`, `Copilot Instructions`, `Agent Shield`, `Model fallback` |
-| **Fleet Operations** | `Fleet Monitor`, `Daily Reports`, `Org Standards` |
+| **Agentic Framework** | `dev-lead agent`, `pr-review agent`, `GH-AW`, `Copilot Instructions`, `Agent Shield`, `Model Selection` |
+| **Fleet Operations** | `Fleet Monitor`, `Daily Reports`, `Org Standards`, `Release Strategy` |
| **Compliance** | `Compliance program`, `Compliance Blitz`, `Self-healing`, `Auto-rebase` |
| **Tooling** | `Initiatives Project`, `Tooling` |
| **Ad hoc** | `Ad hoc` |
@@ -195,12 +195,15 @@ configured manually in the UI):
```text
.github/workflows/add-to-project.yml # Workflow (events → script call)
-.github/workflows/add-to-project-reconcile.yml # Scheduled/manual backlog reconcile (#518)
+.github/workflows/add-to-project-reconcile.yml # Scheduled/manual backlog reconcile (#518) + classify (#415)
.github/scripts/add-to-project/
- lib.sh # find/add/draft/delete helpers (DRY_RUN-aware)
+ lib.sh # find/add/draft/delete/set-field helpers (DRY_RUN-aware)
add-issue-or-pr.sh # Noise gate + addProjectV2ItemById
reconcile-discussion.sh # Paginated find + 4-state reconciler
reconcile-backlog.sh # Scans open issues/PRs + Ideas, reconciles via the above
+ classify-initiative.sh # Rule-driven Initiative/Theme back-fill (#415)
+ initiative-rules.tsv # Ordered "Initiative regex" match rules
+ initiative-taxonomy.tsv # "Initiative Theme" roll-up
.github/workflows/add-to-project-tests.yml # shellcheck + bats CI gate
test/workflows/add-to-project/ # bats tests, gh stub, fixtures
```
@@ -219,6 +222,46 @@ discussion number) serialize via concurrency group — `created` and
**Project ID:** `PVT_kwDOD2inqs4BZq3-` (hardcoded in the workflow env).
Multi-Project consumers of the same scripts would parameterize this.
+## How Initiative classification works
+
+Adding an item to the board and *categorizing* it are separate concerns. The
+add path (above) only sets board membership; it leaves the **Initiative**
+field blank. `classify-initiative.sh` (#415) fills it.
+
+- **Deterministic, not AI.** Each item is flattened into a lowercase
+ signature `title | labels | owner/repo` and matched against ordered regex
+ rules in `initiative-rules.tsv` — **first match wins**, so specific
+ initiatives sit above the generic bucket they'd otherwise be swallowed by
+ (Compliance Blitz before Compliance program; Org Standards before
+ Compliance program). A matched item also gets the **Theme** that Initiative
+ rolls up to, from `initiative-taxonomy.tsv`.
+- **Gate labels are stripped from the signature.** Every board item carries
+ the `dev-lead` required label, so if it stayed in the signature the
+ `dev-lead agent` rule would match *every* item. `SIGNATURE_IGNORE_LABELS`
+ (default = the required + excluded noise-gate labels) drops it; only labels
+ that actually signal an initiative survive. This is the concrete mechanism
+ behind the "`dev-lead` is a work-assignment signal, not a classification
+ signal" note in **Fields** above.
+- **No guessing.** An item that matches no rule is left blank and printed as
+ `UNMATCHED` for triage — the classifier never assigns "Ad hoc" as a
+ catch-all. Coverage is reported in the job summary on every run. Tune
+ coverage by adding rows to `initiative-rules.tsv`.
+- **Safe by construction.** It writes only per-item field *values*
+ (`updateProjectV2ItemFieldValue`), never the field *schema*
+ (`updateProjectV2Field`), so it cannot trip the option-wipe footgun in
+ "Adding or modifying single-select options safely" above. It resolves live
+ option ids by name at runtime; a rule naming an initiative that isn't a
+ live option is skipped with a warning rather than failing the run.
+- **Non-destructive.** By default it only fills items whose Initiative is
+ empty, so a human's manual assignment is never overwritten. Set
+ `RECLASSIFY=all` (workflow input `reclassify_all`) to re-evaluate every
+ item.
+
+It runs as the second step of `add-to-project-reconcile.yml`, right after the
+backlog reconcile, so items added in a cycle are categorized in the same run.
+Manual dispatch defaults to `dry_run` (preview coverage + the UNMATCHED list
+before mutating). The first live run back-fills the existing blank items.
+
## Deferred work (not in the pilot)
Tracked in [#415](https://github.com/petry-projects/.github/issues/415).
diff --git a/test/workflows/add-to-project/classify-initiative.bats b/test/workflows/add-to-project/classify-initiative.bats
new file mode 100644
index 000000000..a014b5fe4
--- /dev/null
+++ b/test/workflows/add-to-project/classify-initiative.bats
@@ -0,0 +1,314 @@
+#!/usr/bin/env bats
+# Tests for classify-initiative.sh — the deterministic Initiative/Theme
+# back-fill classifier:
+# - pure classification (signature build, rule match, taxonomy roll-up)
+# - the gate-label strip that stops the universal `dev-lead` label from
+# making every item look like the "dev-lead agent" initiative
+# - the sweep: resolve fields → page items → set values (DRY_RUN + apply),
+# skipping already-associated items and leaving unmatched ones blank.
+#
+# Classification cases run against the REAL rules/taxonomy TSVs, so they also
+# guard those data files against regressions.
+
+bats_require_minimum_version 1.5.0
+
+load 'helpers/setup'
+
+setup() {
+ tt_make_tmpdir
+ tt_install_gh_stub
+ export PROJECT_ID="PVT_test_project"
+ export PROJECT_URL="https://example.invalid/projects/1"
+ export GH_TOKEN="t_test"
+ export GH_STUB_LOG="${TT_TMP}/gh.log"
+ # shellcheck source=/dev/null
+ . "${TT_SCRIPTS_DIR}/classify-initiative.sh"
+}
+
+teardown() {
+ tt_cleanup_tmpdir
+}
+
+# One-page items response. Each arg is a compact JSON node object.
+write_items_page() {
+ local out_path="$1"; shift
+ local nodes='[]'
+ local n
+ for n in "$@"; do
+ nodes=$(jq --argjson node "$n" '. + [$node]' <<<"$nodes")
+ done
+ jq --argjson nodes "$nodes" \
+ '{data:{node:{items:{pageInfo:{hasNextPage:false,endCursor:""},nodes:$nodes}}}}' \
+ <<<"{}" >"$out_path"
+}
+
+# Field-schema response used by resolve_fields.
+write_fields_schema() {
+ local out_path="$1"
+ cat >"$out_path" <<'JSON'
+{"data":{"node":{
+ "initiative":{"id":"F_INIT","options":[
+ {"id":"o_orgstd","name":"Org Standards"},
+ {"id":"o_auto","name":"Auto-rebase"},
+ {"id":"o_devlead","name":"dev-lead agent"}
+ ]},
+ "theme":{"id":"F_THEME","options":[
+ {"id":"t_fleet","name":"Fleet Operations"},
+ {"id":"t_comp","name":"Compliance"},
+ {"id":"t_agentic","name":"Agentic Framework"}
+ ]}
+}}}
+JSON
+}
+
+gh_script_line() { printf '%s\t%s\t%s\n' "$1" "$2" "$3"; }
+
+assert_invocation_count() {
+ local expected="$1" actual=0
+ [ -f "${GH_STUB_LOG}" ] && actual=$(wc -l <"${GH_STUB_LOG}" | tr -d ' ')
+ [ "$actual" -eq "$expected" ] || {
+ printf 'expected %d gh invocations, got %d\n' "$expected" "$actual" >&2
+ [ -f "${GH_STUB_LOG}" ] && cat "${GH_STUB_LOG}" >&2
+ return 1
+ }
+}
+
+assert_log_contains() {
+ local blob; blob=$(sed 's/\\//g' "${GH_STUB_LOG}")
+ for needle in "$@"; do
+ [[ "$blob" == *"${needle}"* ]] || {
+ printf 'expected gh log to contain %q\nlog: %s\n' "$needle" "$blob" >&2
+ return 1
+ }
+ done
+}
+
+# ---------------------------------------------------------------------------
+# Arg validation
+# ---------------------------------------------------------------------------
+
+@test "normalize_signature: rejects wrong arg count" {
+ run normalize_signature "t" "[]"
+ [ "$status" -eq 64 ]
+}
+
+@test "classify_by_rules: rejects wrong arg count" {
+ run classify_by_rules
+ [ "$status" -eq 64 ]
+}
+
+@test "theme_for: rejects wrong arg count" {
+ run theme_for a b
+ [ "$status" -eq 64 ]
+}
+
+@test "decide_for_signature: rejects wrong arg count" {
+ run decide_for_signature
+ [ "$status" -eq 64 ]
+}
+
+# ---------------------------------------------------------------------------
+# normalize_signature — lowercasing + gate-label strip
+# ---------------------------------------------------------------------------
+
+@test "normalize_signature: lowercases and joins title | labels | repo" {
+ run normalize_signature "Fix The Thing" '[{"name":"auto-rebase"}]' "petry-projects/.github"
+ [ "$status" -eq 0 ]
+ [[ "$output" == "fix the thing | auto-rebase | petry-projects/.github" ]]
+}
+
+@test "normalize_signature: strips the dev-lead gate label but keeps real labels" {
+ run normalize_signature "some title" '[{"name":"dev-lead"},{"name":"auto-rebase"}]' "o/r"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"dev-lead"* ]]
+ [[ "$output" == *"auto-rebase"* ]]
+}
+
+@test "normalize_signature: strips dev-lead:* and initiative:* routing variants" {
+ # These colon-variants contain dev-lead/initiative and would otherwise drive
+ # a spurious dev-lead-agent / Initiatives-Project match.
+ run normalize_signature "some title" '[{"name":"dev-lead:needs-human"},{"name":"initiative:auto"},{"name":"scorecard"}]' "o/r"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"dev-lead"* ]]
+ [[ "$output" != *"initiative"* ]]
+ [[ "$output" == *"scorecard"* ]]
+}
+
+@test "normalize_signature: non-array labels degrade to no labels (no crash)" {
+ run normalize_signature "title" "null" "o/r"
+ [ "$status" -eq 0 ]
+ [[ "$output" == "title | | o/r" ]]
+}
+
+# ---------------------------------------------------------------------------
+# classify_by_rules — real-rules regression guard + ordering
+# ---------------------------------------------------------------------------
+
+@test "classify_by_rules: the bare dev-lead label does NOT match dev-lead agent" {
+ # Signature as normalize_signature would produce it: gate label already gone.
+ run classify_by_rules "some unrelated title | | petry-projects/.github"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "classify_by_rules: sonarcloud title → Org Standards" {
+ run classify_by_rules "sonarcloud: resolve remaining s7635 stubs | | petry-projects/.github"
+ [ "$output" = "Org Standards" ]
+}
+
+@test "classify_by_rules: auto-rebase title → Auto-rebase" {
+ run classify_by_rules "auto-rebase: unguarded conflict-comment aborts | | x/y"
+ [ "$output" = "Auto-rebase" ]
+}
+
+@test "classify_by_rules: pr-review title → pr-review agent" {
+ run classify_by_rules "pr-review advisory bot review gate | | x/y"
+ [ "$output" = "pr-review agent" ]
+}
+
+@test "classify_by_rules: a dev-lead-agent title still matches via the word in the title" {
+ run classify_by_rules "dev-lead deep-impl engine-error timeout | | x/y"
+ [ "$output" = "dev-lead agent" ]
+}
+
+@test "classify_by_rules: ordering — Compliance Blitz beats generic Compliance" {
+ run classify_by_rules "compliance blitz 2026 backlog | | x/y"
+ [ "$output" = "Compliance Blitz" ]
+}
+
+@test "classify_by_rules: cross-cutting infra WINS over the product repo (SonarCloud in a product repo → Org Standards)" {
+ # The key ordering guarantee: a SonarCloud sweep inside a product repo is
+ # Org Standards work, not product work.
+ run classify_by_rules "sonarcloud: shell script hygiene cleanup | | petry-projects/talkterm"
+ [ "$output" = "Org Standards" ]
+}
+
+@test "classify_by_rules: a genuine product feature in the product repo → the product initiative" {
+ run classify_by_rules "add spatial audio to the voice provider pipeline | | petry-projects/talkterm"
+ [ "$output" = "TalkTerm" ]
+}
+
+@test "classify_by_rules: avatar idea draft (no repo) → TalkTerm" {
+ run classify_by_rules "3d avatar evolution with lip-sync | | draft"
+ [ "$output" = "TalkTerm" ]
+}
+
+@test "classify_by_rules: beekeeping idea → Broodly, not TalkTerm (hive wins)" {
+ run classify_by_rules "conversational voice assistant grounded in hive history | | draft"
+ [ "$output" = "Broodly" ]
+}
+
+@test "classify_by_rules: feature-ideation → Business Analyst" {
+ run classify_by_rules "feat(feature-ideation): add curated source list for mary | | petry-projects/.github"
+ [ "$output" = "Business Analyst" ]
+}
+
+@test "classify_by_rules: token cost report → Cost Observability, not Daily Reports" {
+ run classify_by_rules "org-wide weekly token cost observatory report | | petry-projects/.github"
+ [ "$output" = "Cost Observability" ]
+}
+
+@test "classify_by_rules: no match → empty output, exit 0" {
+ run classify_by_rules "zzq nonsense placeholder widget | | x/y"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "classify_by_rules: missing rules file → 65" {
+ RULES_FILE="${TT_TMP}/nope.tsv" run classify_by_rules "anything"
+ [ "$status" -eq 65 ]
+}
+
+# ---------------------------------------------------------------------------
+# theme_for + decide_for_signature
+# ---------------------------------------------------------------------------
+
+@test "theme_for: Auto-rebase rolls up to Compliance" {
+ run theme_for "Auto-rebase"
+ [ "$output" = "Compliance" ]
+}
+
+@test "theme_for: unknown initiative → empty" {
+ run theme_for "Nonexistent"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "decide_for_signature: matched → 'initiativetheme'" {
+ run decide_for_signature "sonarcloud ruleset drift | | x/y"
+ [ "$status" -eq 0 ]
+ [[ "$output" == "Org Standards"$'\t'"Fleet Operations" ]]
+}
+
+@test "decide_for_signature: unmatched → empty" {
+ run decide_for_signature "zzq nonsense placeholder | | x/y"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+# ---------------------------------------------------------------------------
+# sweep_project — DRY_RUN: resolve + page only, correct tallies
+# ---------------------------------------------------------------------------
+
+setup_sweep_stub() {
+ local fields="${TT_TMP}/fields.json" items="${TT_TMP}/items.json"
+ write_fields_schema "$fields"
+ write_items_page "$items" \
+ '{"id":"PVTI_A","initiative":null,"content":{"__typename":"Issue","title":"SonarCloud: fix S7635 stubs","labels":{"nodes":[{"name":"dev-lead"}]},"repository":{"nameWithOwner":"petry-projects/.github"}}}' \
+ '{"id":"PVTI_B","initiative":{"name":"Auto-rebase"},"content":{"__typename":"PullRequest","title":"auto-rebase tweak","labels":{"nodes":[{"name":"dev-lead"}]},"repository":{"nameWithOwner":"petry-projects/.github"}}}' \
+ '{"id":"PVTI_C","initiative":null,"content":{"__typename":"DraftIssue","title":"zzq nonsense placeholder"}}'
+ local script="${TT_TMP}/script.txt"
+ {
+ gh_script_line 0 "$fields" "-" # resolve_fields
+ gh_script_line 0 "$items" "-" # paginate items
+ gh_script_line 0 "-" "-" # (apply) set Initiative on A
+ gh_script_line 0 "-" "-" # (apply) set Theme on A
+ } >"$script"
+ export GH_STUB_SCRIPT="$script"
+}
+
+@test "sweep_project DRY_RUN: tallies scanned/already/matched/unmatched, no mutations" {
+ setup_sweep_stub
+ DRY_RUN=1 run sweep_project
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"board items scanned : 3"* ]]
+ [[ "$output" == *"already associated : 1"* ]]
+ [[ "$output" == *"newly matched : 1"* ]]
+ [[ "$output" == *"unmatched (blank) : 1"* ]]
+ [[ "$output" == *"MATCH Org Standards"* ]]
+ [[ "$output" == *"UNMATCHED"* ]]
+ # Only the two read calls (resolve + paginate); the mutations are dry-run.
+ assert_invocation_count 2
+}
+
+@test "sweep_project apply: sets Initiative and Theme on the matched item" {
+ setup_sweep_stub
+ run sweep_project
+ [ "$status" -eq 0 ]
+ # resolve + paginate + set-initiative + set-theme
+ assert_invocation_count 4
+ assert_log_contains "updateProjectV2ItemFieldValue" "PVTI_A" "F_INIT" "o_orgstd"
+ assert_log_contains "F_THEME" "t_fleet"
+ # Never touches the already-associated or unmatched items.
+ run grep -c PVTI_B "${GH_STUB_LOG}"
+ [ "$output" = "0" ]
+ run grep -c PVTI_C "${GH_STUB_LOG}"
+ [ "$output" = "0" ]
+}
+
+@test "sweep_project: fails fast (75) when the project node is null" {
+ local nullresp="${TT_TMP}/null.json"
+ printf '%s' '{"data":{"node":null}}' >"$nullresp"
+ local script="${TT_TMP}/script.txt"
+ gh_script_line 0 "$nullresp" "-" >"$script"
+ export GH_STUB_SCRIPT="$script"
+ run sweep_project
+ [ "$status" -eq 75 ]
+}
+
+@test "sweep_project: fails fast without GH_TOKEN" {
+ unset GH_TOKEN
+ run --separate-stderr sweep_project
+ [ "$status" -eq 64 ]
+ [[ "$stderr" == *"GH_TOKEN is empty"* ]]
+}