Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
68ada57
feat: implement issue #1728 — [Phase 2] Teach the interaction-model s…
don-petry Sep 13, 2026
b6903ae
fix(bot): address bot feedback [skip ci-relay]
don-petry Sep 13, 2026
24fc4d6
fix(bot): address bot feedback [skip ci-relay]
don-petry Sep 13, 2026
ecaa2b8
fix(validation): support dynamic YAML job indentation and detect dupl…
don-petry Sep 13, 2026
fdbb1a2
fix(reviews): address review comments [skip ci-relay]
don-petry Sep 22, 2026
ea8df28
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 22, 2026
689a6f9
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 23, 2026
cb5c04a
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 23, 2026
8cecbd1
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 23, 2026
5c0f700
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 23, 2026
9bb0f8f
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 24, 2026
fb34cfc
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 24, 2026
47656b0
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 24, 2026
c69d30b
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 24, 2026
3c4bc0c
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 25, 2026
92f4334
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 25, 2026
5463f55
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 25, 2026
cc77f09
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 26, 2026
78a87e9
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 26, 2026
7f7fcbc
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 27, 2026
747f214
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 27, 2026
6e1fa26
Merge branch 'main' into dev-lead/issue-1728-20260913-0441
don-petry Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions docs/adding-an-agentic-role.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,9 @@ in the standard
([Classification of current agentic workflows](./agentic-interaction-model.md#4-classification-of-current-agentic-workflows)),
in the fixed column order `Workflow (path) | Class | timer_role | Justification`. The
bidirectional completeness check (a workflow absent from both this table and the exclusion
list will fail CI) is planned for Story 4 / `validate-interaction-model` (#1406) and is not
yet enforced; keep the table in sync by convention until that check lands. If the role is CI
infrastructure or a thin caller stub rather than an agentic role, add it to the §4
**exclusion list** instead, with a one-line reason.
list will fail CI) is **enforced** by Story 4 / `validate-interaction-model` (#1406): keep the
table in sync or CI fails. If the role is CI infrastructure or a thin caller stub rather than
an agentic role, add it to the §4 **exclusion list** instead, with a one-line reason.

## Step 2 — Author the interaction contract (the Story-1 shape)

Expand Down Expand Up @@ -230,8 +229,7 @@ budget/stop-marker consistency ([§9](./agentic-interaction-model.md#9-the-cost-
A contract that fails these checks blocks the PR.

The **full cross-check** — verifying that a workflow's `on:` block, its §4 classification
row, and its contract all agree — is **planned** for Story 4 /
row, and its contract all agree — is **enforced** by Story 4 /
[#1406](https://github.com/petry-projects/.github-private/issues/1406)
([`validate-interaction-model`](./agentic-interaction-model.md#10-how-this-document-is-enforced)).
Until that check lands, keep the three artefacts in sync by convention; a mismatch will not
block merge today but will fail CI once Story 4 ships.
Keep the three artefacts in sync; a mismatch fails CI and blocks merge.
27 changes: 22 additions & 5 deletions docs/agentic-interaction-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,19 @@ triggers change, its row here must change in the same PR or the validator fails.
backtick spans (e.g. `` `path/to/file.yml` ``) are allowed; the separator row uses
`---|---|---|---` with no padding.

**Multi-role ingress convention (ADR-0007).** Normally one workflow file is one classified
role and contributes **exactly one** row. The one exception is a collapsed
`agent-ingress.yml`: it holds **one job per collapsed role**, all sharing a single `on:`
union (ADR-0007), so it contributes **one row per role-job** instead of one row for the file.
Each such row's first cell names the shared path **and** its job, shaped `` `path` (job-name) ``
— e.g. `` `.github/workflows/agent-ingress.yml` (dev-lead) ``. The set of role-qualifiers for
the ingress path must equal the file's set of `jobs:` names (an under-documented job, or a row
naming a job that does not exist, fails — see §10). Every per-role row is classified against
the **same** shared `on:` union via the §3 discriminator: since the collapsed roles are Class 1
(the ingress carries no `schedule`), each row must be Class 1 with no `timer_role`. A legacy
single-role stub (pre-collapse, or a `pull_request_target` / Class-2 / Class-3 carve-out that
keeps its own file) carries **no** job qualifier and remains exactly one row.

| Workflow (path) | Class | timer_role | Justification (cite the `on:` triggers) |
|---|---|---|---|
| `.github/workflows/dev-lead.yml` | 1 | — | `pull_request`, `pull_request_review`, `pull_request_review_comment`, `issue_comment`, `issues:labeled`, `check_run`, `repository_dispatch` — pure webhook reactions; no `schedule`. |
Expand Down Expand Up @@ -564,11 +577,15 @@ update the table (and the role's §8 contract) in the same PR, or CI fails.
is checked against that workflow's actual `on:` triggers via the §3 discriminator. A row
whose class no longer matches the workflow fails.
- **Completeness (bidirectional).** Every `.github/workflows/*.yml` file not in the §4
exclusion list must have exactly one row. A workflow absent from both the §4 table and the
§4 exclusion list fails the check. This prevents a new agentic role from being added with
no classification row — the enforcement is bidirectional, not row-to-reality only. The
matching AC for #1406: *every in-scope workflow has exactly one row; any in-scope workflow
with no row fails CI*.
exclusion list must be classified. A regular single-role workflow must have **exactly one**
row; a collapsed `agent-ingress.yml` must have **one row per role-job** — the set of its rows'
job qualifiers must equal the file's set of `jobs:` names, so an ingress with N role-jobs but
fewer than N rows (an under-documented job) fails, and a row naming a job the file no longer
defines fails as a stale row. A workflow absent from both the §4 table and the §4 exclusion
Comment thread
don-petry marked this conversation as resolved.
list fails the check. This prevents a new agentic role — or a newly collapsed ingress
role-job — from being added with no classification row; the enforcement is bidirectional, not
row-to-reality only. The matching AC for #1406: *every in-scope role is classified by exactly
one row (per file, or per role-job for an ingress); any in-scope role with no row fails CI*.
- **Per-role contracts vs. workflows** (once Story 2 lands): each contract's
`triggers.events` / `triggers.timers` match the workflow's `on:` block, `emits` does not
intersect subscribed `events` (§7 rule 1), and every Class 2 timer declares a
Expand Down
135 changes: 118 additions & 17 deletions scripts/validate-interaction-model.sh
Original file line number Diff line number Diff line change
Expand Up @@ -170,22 +170,67 @@ imv_on_crons() {

# ── §4 classification table parsing ──────────────────────────────────────────

# imv_table_rows <md> — print `path<TAB>class<TAB>timer_role` for each §4 data row
# (a table row whose first cell is a `.github/workflows/...` path). The header,
# separator, and the blockquoted exclusion table are ignored. Pure.
# imv_table_rows <md> — print `path<TAB>class<TAB>timer_role<TAB>role` for each §4
# data row (a table row whose first cell is a `.github/workflows/...` path). The
# path is the first backtick-delimited span of the first cell; the optional role
# is the trailing text after it — the ADR-0007 ingress convention, where one
# agent-ingress.yml contributes one row per collapsed role-job, each first cell
# shaped `` `path` (job-name) ``. A legacy single-role stub carries no qualifier,
# so its role field is empty. The header, separator, and the blockquoted
# exclusion table are ignored. Pure.
imv_table_rows() {
local md="${1:-}"
[ -n "$md" ] && [ -f "$md" ] || return 0
awk -F'|' '
$0 ~ "^\\|[[:space:]]*`\\.github/workflows/" {
path=$2; gsub(/[[:space:]`]/,"",path);
# first cell ($2): `path` optionally followed by a (role) qualifier
path=$2; sub(/^[^`]*`/,"",path); sub(/`.*/,"",path); gsub(/[[:space:]]/,"",path);
# role qualifier: accept ONLY the documented ` (job-name) ` form, or no
# qualifier at all. Any other trailing text is malformed and is tagged
# __invalid__ so completeness reports it instead of silently accepting it.
role=$2; sub(/^[^`]*`[^`]*`/,"",role);
if (role ~ /^[[:space:]]*\([A-Za-z0-9_-]+\)[[:space:]]*$/) {
sub(/^[[:space:]]*\(/,"",role); sub(/\)[[:space:]]*$/,"",role);
} else {
gsub(/^[[:space:]]+|[[:space:]]+$/,"",role);
if (role != "") role="__invalid__" role;
}
cls=$3; gsub(/[[:space:]]/,"",cls);
tr=$4; gsub(/^[[:space:]]+|[[:space:]]+$/,"",tr);
print path "\t" cls "\t" tr
print path "\t" cls "\t" tr "\t" role
}
' "$md"
}

# imv_wf_jobs <file> — print each top-level job name (a key indented under
# the `jobs:` block, at the same indentation as the first job). For an ADR-0007
# agent-ingress.yml this is one name per collapsed role-job, the unit the §4
# completeness check counts rows against. Pure: reads the file, writes stdout.
imv_wf_jobs() {
local file="${1:-}"
[ -n "$file" ] && [ -f "$file" ] || return 0
awk '
/^jobs:[[:space:]]*(#.*)?$/ { inj=1; job_indent=""; next }
inj && /^[A-Za-z_]/ { inj=0 }
# A job key may be an unquoted scalar or a quoted YAML scalar ("dev-lead":).
# Strip surrounding quote delimiters so a quoted key compares like a bare one.
inj && job_indent == "" && /^[[:space:]]+("[^"]+"|\047[^\047]+\047|[A-Za-z0-9_-]+):[[:space:]]*(#.*)?$/ {
match($0, /^[[:space:]]+/);
job_indent = substr($0, RSTART, RLENGTH);
k=$0; sub(/^[[:space:]]+/,"",k); sub(/:[[:space:]]*(#.*)?$/,"",k);
gsub(/^["\047]|["\047]$/,"",k); print k;
next
}
inj && job_indent != "" && $0 ~ /^[[:space:]]+("[^"]+"|\047[^\047]+\047|[A-Za-z0-9_-]+):[[:space:]]*(#.*)?$/ {
indent = ""; match($0, /^[[:space:]]+/); indent = substr($0, RSTART, RLENGTH);
if (indent == job_indent) {
k=$0; sub(/^[[:space:]]+/,"",k); sub(/:[[:space:]]*(#.*)?$/,"",k);
gsub(/^["\047]|["\047]$/,"",k); print k
}
}
' "$file"
}

# imv_table_exclusions <md> — print each `.github/workflows/...` path listed in
# the §4 blockquoted exclusion table. Pure.
imv_table_exclusions() {
Expand Down Expand Up @@ -359,28 +404,84 @@ imv_persona_opt_out() {

# ── detectors (each prints tagged FAIL lines; none aborts on first finding) ───

# imv_v_completeness <root> <md> — FAIL[a] for every in-scope workflow that has no
# §4 row (bidirectional completeness, §10). An in-scope workflow is a
# .github/workflows/*.yml not on the §4 exclusion list.
# imv_v_completeness <root> <md> — bidirectional §4 completeness (§10). Every
# in-scope .github/workflows/*.yml (not on the exclusion list) must be classified:
# - a regular single-role workflow: exactly one §4 row (FAIL[a] if it has none);
# - an ADR-0007 agent-ingress.yml: one row per collapsed role-job — the set of
# row role-qualifiers must equal the file's set of job names, so an
# under-documented ingress (a job with no row) FAIL[a]s and a stale row naming
# a job that no longer exists FAIL[table]s. Each per-role row is still
# classified against the shared on: block by imv_v_class.
imv_v_completeness() {
local root="$1" md="$2" f rel
local rows exclusions
rows="$(imv_table_rows "$md" | cut -f1)"
local root="$1" md="$2" f rel base
local rows exclusions matching_rows count
rows="$(imv_table_rows "$md")"
exclusions="$(imv_table_exclusions "$md")"
while IFS= read -r f; do
[ -n "$f" ] || continue
rel="${f#"$root/"}"
if printf '%s\n' "$exclusions" | grep -qxF "$rel"; then continue; fi
if ! printf '%s\n' "$rows" | grep -qxF "$rel"; then
printf 'FAIL[a]: %s is an in-scope workflow with no §4 classification row (declare it in docs/agentic-interaction-model.md §4, or add it to the exclusion list).\n' "$rel"
base="$(basename "$rel")"
if [ "$base" = "agent-ingress.yml" ]; then
imv_v_completeness_ingress "$f" "$rel" "$rows"
else
# For regular workflows: count rows with this path and no role qualifier
matching_rows="$(printf '%s\n' "$rows" | awk -F'\t' -v p="$rel" '$1==p && $4~/^[[:space:]]*$/ {print}')"
count=$(printf '%s' "$matching_rows" | grep -c . || true)
if [ "$count" -eq 0 ]; then
printf 'FAIL[a]: %s is an in-scope workflow with no §4 classification row (declare it in docs/agentic-interaction-model.md §4, or add it to the exclusion list).\n' "$rel"
elif [ "$count" -gt 1 ]; then
printf 'FAIL[a]: %s is a regular workflow with multiple §4 classification rows — each workflow must have exactly one row (§10).\n' "$rel"
fi
fi
done < <(find "$root/.github/workflows" -maxdepth 1 -type f -name '*.yml' 2>/dev/null | sort)
}

# imv_v_completeness_ingress <file> <rel> <rows> — per-job completeness for a
# collapsed ADR-0007 ingress: the set of §4 row role-qualifiers naming <rel> must
# equal <file>'s job names. FAIL[a] a job with no row; FAIL[table] a row-role that
# names no job. Called by imv_v_completeness; <rows> is imv_table_rows output.
imv_v_completeness_ingress() {
local file="$1" rel="$2" rows="$3" jobs roles job role roles_raw roles_dedup blank_count
jobs="$(imv_wf_jobs "$file" | sort -u)"
# An ingress row must name its role-job. Inspect ALL rows for this path before
# filtering blanks — a blank/whitespace-only qualifier is malformed and must
# FAIL[a], else a complete set of qualified rows plus one blank row would pass.
blank_count="$(printf '%s\n' "$rows" | awk -F'\t' -v p="$rel" '$1==p && $4~/^[[:space:]]*$/ {c++} END{print c+0}')"
if [ "$blank_count" -gt 0 ]; then
printf 'FAIL[a]: %s has %s §4 row(s) with a blank role qualifier — every collapsed ingress row must name its role-job (see the §4 ingress convention).\n' "$rel" "$blank_count"
fi
roles_raw="$(printf '%s\n' "$rows" | awk -F'\t' -v p="$rel" '$1==p && $4!~/^[[:space:]]*$/ {print $4}')"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
roles_dedup="$(printf '%s' "$roles_raw" | sort -u)"
# Check for duplicate role qualifiers (same role appearing more than once)
if [ -n "$roles_raw" ] && [ "$(printf '%s' "$roles_raw" | grep -c . || true)" -gt "$(printf '%s' "$roles_dedup" | grep -c . || true)" ]; then
while IFS= read -r role; do
[ -n "$role" ] || continue
count=$(printf '%s' "$roles_raw" | grep -c "^${role}$" || true)
if [ "$count" -gt 1 ]; then
printf 'FAIL[a]: %s has multiple §4 rows for role %s — each role-job must have exactly one row (§10).\n' "$rel" "$role"
fi
done <<< "$roles_dedup"
fi
roles="$roles_dedup"
while IFS= read -r job; do
[ -n "$job" ] || continue
if ! printf '%s\n' "$roles" | grep -qxF "$job"; then
printf 'FAIL[a]: %s job %s is a collapsed ingress role with no §4 classification row — add one row per role-job (see the §4 ingress convention).\n' "$rel" "$job"
fi
done <<< "$jobs"
while IFS= read -r role; do
[ -n "$role" ] || continue
if ! printf '%s\n' "$jobs" | grep -qxF "$role"; then
printf 'FAIL[table]: §4 row %s (%s) names an ingress role that is not a job in the file — remove the stale row (§10).\n' "$rel" "$role"
fi
done <<< "$roles"
}

# imv_v_timer_roles <md> — FAIL[b] for every Class-2 row lacking a valid timer_role.
imv_v_timer_roles() {
local md="$1" path cls tr trn
while IFS=$'\t' read -r path cls tr; do
local md="$1" path cls tr role trn
while IFS=$'\t' read -r path cls tr role; do
[ -n "$path" ] || continue
[ "$cls" = "2" ] || continue
trn="$(imv_norm_timer_role "$tr")"
Expand All @@ -395,8 +496,8 @@ imv_v_timer_roles() {
# FAIL[class] when a row's asserted Class contradicts the workflow's real on:
# block via the §3 discriminator.
imv_v_class() {
local root="$1" md="$2" path cls tr trn wf S
while IFS=$'\t' read -r path cls tr; do
local root="$1" md="$2" path cls tr role trn wf S
while IFS=$'\t' read -r path cls tr role; do
[ -n "$path" ] || continue
wf="$root/$path"
if [ ! -f "$wf" ]; then
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Agent Ingress
# FIXTURE — a MISCLASSIFIED ingress row: two role-jobs, both documented, but one
# row asserts Class 3 while the shared on: union carries NO schedule. The §3
# discriminator must FAIL[class] that row (a Class-3 origin is defined by its
# cadence), proving a row whose asserted Class contradicts the ingress on: block
# is rejected (QA AC #6).
on:
pull_request:
types: [opened, reopened, synchronize, review_requested]
issue_comment:
types: [created]

permissions: {}

jobs:
dev-lead:
if: github.event_name == 'pull_request'
uses: petry-projects/.github-private/.github/workflows/dev-lead-reusable.yml@dev-lead/v1-stable
secrets: inherit

pr-review-mention:
if: github.event_name == 'issue_comment'
uses: petry-projects/.github/.github/workflows/pr-review-mention-reusable.yml@pr-review-mention/v2-stable
secrets: inherit
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Fixture — agentic interaction model (§4 classification table)

Misclassified ingress row: both role-jobs are documented (completeness passes),
but the `pr-review-mention` row asserts Class 3 while the shared `on:` union has
no schedule. The §3 discriminator must FAIL[class] that row.

| Workflow (path) | Class | timer_role | Justification |
| --- | --- | --- | --- |
| `.github/workflows/agent-ingress.yml` (dev-lead) | 1 | — | Correctly Class 1 against the shared union. |
| `.github/workflows/agent-ingress.yml` (pr-review-mention) | 3 | — | WRONG: asserts Class 3 but the shared union carries no schedule. |
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Agent Ingress
# FIXTURE — a collapsed multi-role agent-ingress.yml (ADR-0007, epic #1723).
# One shared on: union (no schedule), one job per collapsed Class-1 role. The
# §4 table must carry ONE row per role-job (dev-lead, pr-review-mention), each
# classified against this same shared on: block.
on:
pull_request:
types: [opened, reopened, synchronize, review_requested]
issue_comment:
types: [created]

permissions: {}

jobs:
dev-lead:
if: >-
github.event_name == 'pull_request' || github.event_name == 'issue_comment'
uses: petry-projects/.github-private/.github/workflows/dev-lead-reusable.yml@dev-lead/v1-stable
secrets: inherit

pr-review-mention:
if: >-
(github.event_name == 'pull_request' && github.event.action == 'review_requested')
|| github.event_name == 'issue_comment'
uses: petry-projects/.github/.github/workflows/pr-review-mention-reusable.yml@pr-review-mention/v2-stable
secrets: inherit
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
name: legacy-stub
# FIXTURE — a pre-collapse single-role caller stub. Backward-compat: it still
# classifies as EXACTLY ONE §4 row (no role qualifier).
on:
pull_request:
types: [opened]
jobs:
run:
uses: petry-projects/.github-private/.github/workflows/dev-lead-reusable.yml@dev-lead/v1-stable
secrets: inherit
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Fixture — agentic interaction model (§4 classification table)

Minimal stand-in for docs/agentic-interaction-model.md exercising the ADR-0007
multi-role ingress shape: `agent-ingress.yml` contributes ONE row per role-job,
alongside a legacy single-role stub that keeps its one row. Only the §4 table
shape matters here.

| Workflow (path) | Class | timer_role | Justification |
| --- | --- | --- | --- |
| `.github/workflows/agent-ingress.yml` (dev-lead) | 1 | — | Collapsed Class-1 role; shared `on:` union has no schedule. |
| `.github/workflows/agent-ingress.yml` (pr-review-mention) | 1 | — | Collapsed Class-1 mention router; classified against the same shared `on:` union. |
| `.github/workflows/legacy-stub.yml` | 1 | — | Legacy single-role stub — exactly one row (no role qualifier). |
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Agent Ingress
# FIXTURE — an UNDER-DOCUMENTED ingress: two role-jobs (dev-lead,
# pr-review-mention) but the §4 table declares only one. Completeness must FAIL
# (a job with no classification row), proving the check cannot pass an
# under-documented ingress (QA AC #6 / backward-compat proven by failure).
on:
pull_request:
types: [opened, reopened, synchronize, review_requested]
issue_comment:
types: [created]

permissions: {}

jobs:
dev-lead:
if: github.event_name == 'pull_request'
uses: petry-projects/.github-private/.github/workflows/dev-lead-reusable.yml@dev-lead/v1-stable
secrets: inherit

pr-review-mention:
if: github.event_name == 'issue_comment'
uses: petry-projects/.github/.github/workflows/pr-review-mention-reusable.yml@pr-review-mention/v2-stable
secrets: inherit
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Fixture — agentic interaction model (§4 classification table)

Under-documented ingress: `agent-ingress.yml` has two role-jobs but only one
row here (the `pr-review-mention` job is missing). Completeness must FAIL.

| Workflow (path) | Class | timer_role | Justification |
| --- | --- | --- | --- |
| `.github/workflows/agent-ingress.yml` (dev-lead) | 1 | — | Only one of the two role-jobs is declared. |
Loading
Loading