Fix locale-dependent test, surface policy-only CLAUDE.md follow-ups, add read-only seam-drift audit - #337
Conversation
test_policy_apply_compares_validated_non_ascii_policy_content_as_bytes read the published policy blob through `git show` backticks, which tags the result with Encoding.default_external. On a host with no locale exported that is US-ASCII, so matching it against the UTF-8 source literal "# cafe policy" raised Encoding::CompatibilityError instead of asserting. Because bin/validate runs under `set -euo pipefail`, that error aborted the whole validation run at its bin/push-downstream-test.rb step, so every later suite and RuboCop never executed. Hosted CI never saw it because the runner exports a UTF-8 locale. Compare as bytes, which is what the test name already promises: tag the command output with .b and compare it against .b literals. This keeps the assertion locale-independent instead of requiring callers to export a locale. Before (LANG and LC_ALL unset, Encoding.default_external US-ASCII): 1 runs, 6 assertions, 0 failures, 1 errors, 0 skips After, same environment: 1 runs, 9 assertions, 0 failures, 0 errors, 0 skips After, LANG=en_US.UTF-8: 1 runs, 9 assertions, 0 failures, 0 errors, 0 skips Fixes #281 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The full scaffold path already reports "existing CLAUDE.md preserved; consolidate it to import @AGENTS.md" when a consumer keeps a rich, standalone CLAUDE.md. The policy-only fleet path could update a consumer seam without carrying that follow-up into the generated PR, so a consumer could end up with Codex reading canonical AGENTS.md while Claude operated from a separate rulebook that omits current workflow gates. shakacode/shakapacker#1221 is the motivating miss. Add a read-only compatibility audit, claude_consolidation_follow_up, that reports a follow-up only when an existing CLAUDE.md neither matches the thin template nor imports @AGENTS.md. Policy-only sync now runs it after the base seam preflight, prints a FOLLOW_UP line for the consumer, and threads the follow-ups into the generated policy PR body. Full scaffold reconciliation delegates to the same audit so both paths agree. The check stays advisory. It reads CLAUDE.md and never rewrites, deletes, or requires it to be byte-identical to the thin template, and it does not fire when the file is absent. Detection requires the explicit `@AGENTS.md` import form, because that is what actually routes Claude to the canonical rulebook; a bare prose mention of AGENTS.md, or an address-like ops@AGENTS.md, does not. Reads CLAUDE.md as bytes and scrubs to UTF-8, so the audit is locale-independent. Coverage: a rich file without the import reproduces the Shakapacker shape and proves the warning reaches stdout and the PR body while the published file stays byte-identical; a rich file that already imports @AGENTS.md is a negative control that must produce no follow-up; the thin template and an absent file produce none. Fixes #317 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running bin/push-downstream against registered consumers is a manual maintainer action, so a consumer seam can fall behind the contract with no upstream signal. Registered consumers have been observed doctor-failing shortly after adoption. Add --audit: a read-only fleet mode that clones each selected registry consumer, runs the seam doctor, and computes the push-downstream desired-state diff inside the disposable clone. It emits one machine-readable JSON report and returns non-zero when any consumer is not clean. Each consumer entry names the consumer base SHA, the seam doctor issues, the exact changed managed paths, and the follow-ups the synchronizer intentionally cannot apply (for example the #317 CLAUDE.md consolidation). The report is bound to the exact agent-workflows source SHA and records whether that source worktree was clean, so a report can be traced to the state that produced it. The report separates repo-local seam freshness from host-installed shared skill freshness, so a drifted consumer is not misread as "every upstream commit needs a downstream PR". Shared skills and workflows stay per-host and are never vendored into consumers. A consumer that cannot be fetched, authenticated, or interpreted is reported blocked, with the values that could not be established left literally UNKNOWN. It is never reported clean. --audit is refused in combination with --apply, --root, --policy-fleet, and the --trusted-* flags, so the read-only mode cannot be turned into a writing one. It never pushes a branch and never opens or updates a pull request. Scope: this is the script-side mechanism only. The CI trigger on contract-file change, the scheduled backstop, and the optional focused sync PR per consumer described in the issue are deliberately not included here. Refs #319 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Warning Review limit reached
Next review available in: 19 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Review: Fix locale-dependent test, surface CLAUDE.md follow-ups, add
|
Two review findings on the --audit report. audit_changed_paths sliced every NUL-separated field of `git status --porcelain=v1 -z` at [3..]. That is correct for "XY <path>" entries, but a rename or copy entry is followed by a second field holding the origin path with no "XY " prefix, so the origin "old.txt" was silently reported as ".txt" and the real path was lost. Verified directly: field 0: " M keep.txt" -> "keep.txt" field 1: "R new.txt" -> "new.txt" field 2: "old.txt" -> ".txt" <- corrupted The path is unreachable today because reconcile_scaffold writes in place and never stages, and git only detects renames from the index. It is still one `git add` away from emitting a corrupted path list from a publishing tool whose report a maintainer acts on, so parse it properly rather than assume it stays unreachable. parse_porcelain_z_paths now consumes the paired origin field explicitly. A rename reports both the new path and the origin it removed; a copy reports only the new path, because a copy leaves its origin intact. Any field that cannot be interpreted returns nil, so an unparseable status is reported blocked rather than as a corrupted path list, consistent with never reporting an uninspectable consumer as clean. Add, modify, delete, and untracked handling is unchanged. The report's seam_doctor_issues, changed_managed_paths, and follow_ups are an Array for clean and drifted consumers and the string "UNKNOWN" for blocked ones. That shape is deliberate: an empty Array would read as "no issues, nothing to change" for a repo that could not be inspected. Rather than change it, document it in the caller contract, as a "contract" block in the report itself and in the --audit help text, stating the field types, the exit codes, and that consumers must type-check before iterating. Coverage: a fixture with a staged rename plus modified, deleted, and untracked files pins the exact path list and proves ".txt" never appears; the previous blind-slice parser fails that test. Fail-closed parsing and the documented contract are asserted too. Refs #319 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review summaryThree well-scoped, independently-reviewable changes to 1. #281 — locale-dependent test fixComparing 2. #317 — CLAUDE.md consolidation follow-up on the policy-only path
3. #319 —
|
… audit
AgentWorkflowSeamDoctor.check was the one unguarded call in audit_repo. Every
other per-consumer failure already degrades to an audit_blocked entry:
resolve_contract, the clone, reconcile_scaffold, and audit_changed_paths.
check reads consumer files and shells out through
Open3.capture2e("bash", "-n", path), so it can raise SystemCallError for a single
consumer under resource pressure, such as Errno::EMFILE while cloning and
shelling out across many fleet repos back to back, or Errno::ENOENT when bash
cannot be resolved. File.binread on AGENTS.md can raise as well.
The effect was that one bad consumer aborted the entire fleet audit with a
backtrace rather than being recorded. That contradicts the acceptance criterion
this mode is built to satisfy: failures to fetch, authenticate, or interpret a
consumer must remain UNKNOWN or blocked rather than being reported clean, and
interpreting the seam is exactly what check does.
Wrap it with the same rescue breadth used for reconcile_scaffold and return
audit_blocked with the base SHA that was already established, so the values we
did determine are kept and the rest stay UNKNOWN.
Coverage: a regression test forces check to raise for the first of two consumers
and asserts the first comes back blocked with its base SHA retained and UNKNOWN
elsewhere, while the second consumer is still audited normally. Without the
rescue the exception escapes audit_repo and the test errors with zero
assertions, so the fleet-abort behavior is pinned.
Refs #319
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review summaryReviewed the diff ( Overall: this is a well-scoped, carefully reasoned change. The rename/copy fix in One correctness gap found (left as an inline comment on No security concerns — all shell-outs use argv-array form (no shell interpolation), and the audit clone/reconcile happens in a disposable |
The previous commit guarded AgentWorkflowSeamDoctor.check, but two shell-outs in
audit_repo were still unprotected: local_ref_head, which runs
Open3.capture2("git", "-C", clone, "rev-parse", ...), and audit_changed_paths,
which runs Open3.capture2("git", "-C", clone, "status", ...).
Kernel#system, used for the clone, returns nil when the child cannot be spawned,
but Open3.capture2 raises instead:
Open3.capture2 on a missing binary -> raises Errno::ENOENT
Kernel#system on a missing binary -> returns nil
audit_changed_paths already handled a non-success exit status by returning nil,
but neither call handled the spawn itself failing. Under the same resource
pressure the check guard was written for, such as Errno::EMFILE while cloning and
shelling out across many fleet repos back to back, either call still aborted the
whole fleet audit instead of recording one consumer as blocked.
Bring both under the same rescue treatment, with a reason that names the step
that failed, so a report reader can tell which stage gave up:
consumer base commit could not be resolved: ...
reconciled clone could not be inspected: ...
The existing nil-on-uninterpretable-status path is unchanged and keeps its own
distinct reason, because that case is about interpreting output rather than
spawning a child.
Coverage: two regression tests force a spawn failure, not merely a non-zero exit,
on each path by making Open3.capture2 raise Errno::EMFILE for the first matching
git subcommand. Each asserts the consumer comes back blocked with the values
established before the failure retained, and that a second consumer is still
audited normally. Removing either guard makes its test error with zero
assertions, so both are pinned.
Refs #319
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReviewReviewed the diff ( Potential issues1. The Round 2/3 wrapped
That's true for the "command not found" case ( Notably, the round-3 tests ( 2. def audit_source_provenance(root = File.expand_path("..", __dir__))
sha, sha_status = Open3.capture2("git", "-C", root, "rev-parse", "HEAD")
porcelain, porcelain_status = Open3.capture2("git", "-C", root, "status", "--porcelain")
...This runs once, after every consumer has already been audited, to attach source-SHA provenance to the final report ( Notes (non-blocking)
🤖 Generated with Claude Code |
Closes out the remaining unguarded shell-outs in the audit path. audit_source_provenance ran two Open3.capture2 calls unguarded. It is invoked from audit_report, which run_audit calls only after every consumer has already been audited, so a spawn failure there discarded the entire completed report rather than degrading a single entry. That is a worse failure mode than the per-consumer cases fixed earlier. Both fields already had an UNKNOWN representation, so each now degrades to UNKNOWN independently through audit_source_sha and audit_source_worktree_clean. A report still prints, with its provenance honestly marked unknown instead of lost, which is the behavior this mode is built to guarantee. The clone used Kernel#system, which returns nil when exec fails in the forked child but still raises when fork itself fails, for example Errno::EAGAIN or Errno::ENOMEM under the same resource pressure the rest of this hardening targets. Bring it inside the same rescue as its neighbours so all of audit_repo is uniformly protected, with a distinct reason that separates a child that never started from a clone that ran and failed. The comment no longer claims Kernel#system cannot raise. Coverage: a provenance spawn failure asserts the report still prints with sha and worktree_clean UNKNOWN and the consumer results and summary intact; a clone fork failure asserts the consumer is blocked and the next consumer is still audited. Removing either guard makes its test error with zero assertions. Refs #319 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review summaryThis PR bundles three independently-scoped, well-isolated changes to
Code quality
Potential issues
SecurityNo new injection surface — all shell-outs use array-form ValidationThe PR description includes extensive before/after evidence (locale repro, Overall: solid, carefully hardened change with good test density for the individual audit-path building blocks. The one gap worth closing before merge is end-to-end coverage of |
AW E batch closeout —
|
|
head_sha: bc2fb0a
|
QA Evidence
|
…/pr291-redesign-implementation * commit '6239fd0afa5bb7d87cd3fb09cc22ae30bcf1e369': Fix locale-dependent test, surface policy-only CLAUDE.md follow-ups, add read-only seam-drift audit (#337) Add PR #377 changelog entry (#382) Make PR descriptions human-first (#377) Remove unsupported signed-launch enforcement (#374) Emit coordination telemetry and provenance at workflow checkpoints (#290) Add explicit multi-language lint CI (#313) Reconcile later-completed audit targets (#315) Report source lines in security preflight findings (#311) Gate completed-batch publication on terminal scope and QA (#308) Add guarded merge submission seam (#304) Fix exact-head readiness when status rows omit SHA (#307) fix: allow YAML timestamps in action scanner (#305) Pin workflow dependencies and define release trust boundary (#295) Support direct merges without merge queues (#297)
…cure-github-actions-273 * commit '6239fd0afa5bb7d87cd3fb09cc22ae30bcf1e369': Fix locale-dependent test, surface policy-only CLAUDE.md follow-ups, add read-only seam-drift audit (#337) # Conflicts: # bin/push-downstream
…2-prompt-host-adapter * commit '6239fd0afa5bb7d87cd3fb09cc22ae30bcf1e369': Fix locale-dependent test, surface policy-only CLAUDE.md follow-ups, add read-only seam-drift audit (#337)
…8-batch-docs-status-skill * origin/main: Default merge submission to direct (#407) Fix <PROJECT> digit ambiguity, anchor section extraction, ship coordination gate (#339) Enforce requested-versus-observed model route provenance (#348) Fix locale-dependent test, surface policy-only CLAUDE.md follow-ups, add read-only seam-drift audit (#337) Add PR #377 changelog entry (#382) Make PR descriptions human-first (#377) Remove unsupported signed-launch enforcement (#374) # Conflicts: # bin/validate
Fixes #281
Fixes #317
Refs #319
Three serial, independently reviewable commits against
bin/push-downstreamandits test. No other path in the repo is touched.
Release-behavior surface and blast radius
bin/push-downstreamis listed in.agents/agent-workflow.ymlunderautonomous_merge.human_review_paths(iddownstream-publishing, reasonrelease), so this PR is expected to require human review before merge. Blastradius per change:
--auditmodeNothing in this PR changes when a policy or scaffold PR is created, what is
committed, or any existing push/lease/PR-gating safety check.
1. #281 —
bin/push-downstream-test.rbwas locale-dependent and abortedbin/validatetest_policy_apply_compares_validated_non_ascii_policy_content_as_bytesread thepublished policy blob through
git showbackticks. That output is tagged withEncoding.default_external, which isUS-ASCIIon a host with no localeexported, so matching it against the UTF-8 source literal
"# café policy"raised
Encoding::CompatibilityErrorinstead of asserting.Because
bin/validateruns underset -euo pipefail, that error aborted theentire validation run at its
bin/push-downstream-test.rbstep, so every latersuite and RuboCop never executed. Hosted CI never saw it because the runner
exports a UTF-8 locale.
Fix: compare as bytes, which is what the test name already promises — tag the
command output with
.band compare against.bliterals. Test-only change.The issue's optional second half (having
bin/validateemit an explicit localediagnostic) is intentionally not included: it is outside this lane's owned
paths.
2. #317 — Policy-only sync now surfaces the CLAUDE.md consolidation follow-up
The full scaffold path already reported
existing CLAUDE.md preserved; consolidate it to import @AGENTS.md. The policy-only fleet path could update aconsumer seam without carrying that follow-up into the generated PR, leaving a
host-dependent gap where Codex reads canonical
AGENTS.mdwhile Claude operatesfrom a separate rulebook. shakacode/shakapacker#1221 is the motivating miss.
Adds
claude_consolidation_follow_up, a read-only audit that reports a follow-uponly when an existing
CLAUDE.mdneither matches the thin template nor imports@AGENTS.md. Policy-only sync runs it after the base seam preflight, prints aFOLLOW_UP <nwo> <text>line, and threads follow-ups into the generated policyPR body. Full scaffold reconciliation delegates to the same audit so both paths
agree.
Advisory only: the consumer-owned file is never rewritten, deleted, or required
to be byte-identical to the thin template, and an absent
CLAUDE.mdproduces nofollow-up.
Detection requires the explicit
@AGENTS.mdimport form, because that is whatactually routes Claude to the canonical rulebook. A bare prose mention of
AGENTS.md, or an address-likeops@AGENTS.md, does not count.3. #319 — Read-only fleet seam-drift audit (partial; see below)
Adds
--audit: clones each selected registry consumer, runs the seam doctor, andcomputes the desired-state diff inside the disposable clone. Emits one
machine-readable JSON report; exits non-zero when any consumer is not clean.
agent-workflowssource SHA, plus whether that sourceworktree was clean.
the follow-ups the synchronizer intentionally cannot apply.
so a drifted consumer is not misread as "every upstream commit needs a
downstream PR". Shared skills are never vendored into consumers.
blocked, with unestablished values left literallyUNKNOWN— neverclean.contractblock (and matching--audithelp text) statingthe statuses, the exit codes, and that
seam_doctor_issues,changed_managed_paths, andfollow_upsare each anArrayor the string"UNKNOWN", so callers type-check before iterating.--apply,--root,--policy-fleet, and--trusted-*, so the read-only mode cannot be turned into a writing one.#319 partial coverageImplemented here (script-side mechanism): direction items 1 (read-only
fleet-audit mode computing the desired-state diff without applying), 3 (one
machine-readable summary bound to the exact upstream source SHA), and 5
(distinguishing host-installed shared-skill freshness from repo-seam freshness).
Deliberately not implemented here, which is why this is
Refs #319and notFixes #319:backstop. Requires a
.github/workflows/file, outside this lane's ownedpaths and itself a
human_review_pathsinfrastructure surface.consumer. A live publishing behavior; deliberately not built in this batch.
Recommended follow-up: a scheduled + contract-path-triggered workflow that runs
bin/push-downstream --audit, uploads the JSON report, and fails on anydrifted/blockedconsumer. The exit code and report schema added here aredesigned for exactly that caller.
Review round 1 (commit 4)
audit_changed_pathsmis-parsed rename entries — real latent bug, fixed.git status --porcelain=v1 -zemits oneXY <path>field per entry, but arename/copy entry is followed by a second field holding the origin path with no
XYprefix. Slicing every field at[3..]corrupted it. Verified directly:Unreachable today (
reconcile_scaffoldwrites in place and never stages, and gitonly detects renames from the index) but one
git addaway from a publishingtool emitting a corrupted path list a maintainer would act on.
parse_porcelain_z_pathsnow consumes the paired origin field explicitly: a rename reports both the new
path and the origin it removed, a copy reports only the new path (its origin is
intact), and any uninterpretable field returns
nilso the consumer is reportedblockedrather than with a corrupted list. Add/modify/delete/untracked handlingis byte-for-byte unchanged.
Pinned by a staged-rename fixture that also contains modified, deleted, and
untracked files. The previous parser fails it:
Polymorphic report fields — documented, shape intentionally unchanged.
seam_doctor_issues,changed_managed_paths, andfollow_upsare anArrayforclean/driftedand the string"UNKNOWN"forblocked. That is deliberate andrequired by #319: an empty
Arraywould read as "no issues, nothing to change"for a repo that could not be inspected. Rather than flatten it, the types are now
documented where a caller sees them — a
contractblock in the report and the--audithelp text — including exit codes and an explicit instruction totype-check before iterating.
Review round 2 (commit 5)
Unguarded
AgentWorkflowSeamDoctor.checkcould abort the whole fleet audit — fixed.It was the one call in
audit_reponot degraded to anaudit_blockedentry;resolve_contract, the clone,reconcile_scaffold, andaudit_changed_pathsallwere.
checkreads consumer files and shells out viaOpen3.capture2e("bash", "-n", path), so it can raiseSystemCallErrorfor asingle consumer under resource pressure (
Errno::EMFILEacross many fleet reposback-to-back,
Errno::ENOENTifbashis unresolvable).That meant one bad consumer aborted the entire audit with a backtrace instead of
being recorded — contradicting the very criterion this mode exists to satisfy
("failures to fetch, authenticate, or interpret a consumer remain
UNKNOWN/blocked rather than being reported clean"), since interpreting the seamis exactly what
checkdoes.Now wrapped with the same
rescue RuntimeError, SystemCallErrorbreadth used forreconcile_scaffold, returningaudit_blockedwith the base SHA alreadyestablished (values determined before the failure are kept; the rest stay
UNKNOWN).Pinned by
test_audit_records_uninterpretable_consumer_as_blocked_and_continues_the_fleet,which forces
checkto raise for the first of two consumers and asserts the firstis
blockedwith its base SHA retained while the second is still auditednormally. Mutation-checked: without the rescue the exception escapes
audit_repoand the test errors with1 runs, 0 assertions, 0 failures, 1 errors.Review round 3 (commit 6)
Round 2's fix was incomplete — two more shell-outs in
audit_repowereunguarded.
local_ref_head(Open3.capture2("git", …, "rev-parse", …)) andaudit_changed_paths(Open3.capture2("git", …, "status", …)). The asymmetrythat made this easy to miss:
Kernel#system(used for the clone) degrades safely;Open3.capture2does not.audit_changed_pathsalready handled a non-success exit status by returningnil, but neither call handled the spawn itself failing — so under the sameErrno::EMFILEpressure the round-2 guard was written for, either one stillaborted the whole fleet audit.
Both are now under the same
rescue RuntimeError, SystemCallError, with reasonsthat name the failing step so a report reader can tell which stage gave up:
consumer base commit could not be resolved: …consumer seam could not be interpreted: …desired state could not be computed: …reconciled clone could not be inspected: …(spawn failure)reconciled clone could not be compared with its base(unparseable status — unchanged)Two regression tests force a spawn failure (not a non-zero exit) on each path
by making
Open3.capture2raiseErrno::EMFILEfor the first matching gitsubcommand, asserting the consumer is
blockedwith values established before thefailure retained, and that a second consumer is still audited normally. Removing
either guard makes its test error with
1 runs, 0 assertions, 0 failures, 1 errors.Review round 4 (commit 7) — audit path fully guarded
audit_source_provenance— highest consequence of the series. Its twoOpen3.capture2calls were unguarded, and it runs fromaudit_report, whichrun_auditcalls afterconsumers = repos.map { … }has already audited everyconsumer. A
SystemCallErrorthere discarded the entire completed audit, notone entry. Both fields already had an
UNKNOWNrepresentation, so each nowdegrades independently (
audit_source_sha,audit_source_worktree_clean): thereport still prints, with provenance honestly marked unknown rather than lost.
The
system(...)clone — now uniformly guarded.Kernel#systemreturnsnilwhen
exec()fails in the forked child, but still raises whenfork()itselffails (
Errno::EAGAIN,Errno::ENOMEM) — the same pressure this hardeningtargets. It is now inside the same rescue as its neighbours, with a distinct
reason separating "child never started" from "clone ran and failed", and the
comment no longer overstates the guarantee.
Every shell-out in the audit path is now guarded. Reasons distinguish the stage:
clone of <base> could not be started: …clone of <base> failedconsumer base commit could not be resolved: …consumer seam could not be interpreted: …desired state could not be computed: …reconciled clone could not be inspected: …reconciled clone could not be compared with its baseTwo new regressions: a provenance spawn failure asserts the report still prints
with
sha/worktree_cleanasUNKNOWNand the consumer results and summaryintact; a clone
fork()failure asserts the consumer isblockedand the nextconsumer is still audited. Both mutation-checked — removing either guard makes its
test error with
1 runs, 0 assertions, 0 failures, 1 errors.Codex Decision Log
Q: Should
reconcile_claude(full scaffold path) also stop reporting thefollow-up for a rich
CLAUDE.mdthat already imports@AGENTS.md?Decision: yes — both paths now delegate to the same audit.
Why: #317's acceptance list requires that a rich file already routing to
@AGENTS.mdproduce no false positive, and it does not scope that to thepolicy-only path. Two paths disagreeing about the same question would be a
defect. This only ever removes a spurious advisory line; it never changes file
content. Existing coverage still pins the rich-without-import case.
Review later: if maintainers want the scaffold path to keep nagging even after a
consumer adopts the import, revert this one delegation.
Q: What counts as "routes to canonical AGENTS.md"?
Decision: the explicit
@AGENTS.mdimport token, via a regex that rejects apreceding word character so
ops@AGENTS.mddoes not qualify.Why: Claude follows
@imports; prose mentions do not import anything, andtreating them as sufficient would silently reproduce the exact gap #317 reports.
Review later: if consumers adopt another routing convention, widen the pattern.
Q:
create_policy_prarity changed from 2 to 3.Decision: change it and update the 17 affected test stubs.
Why:
policy_pr_bodyis only reachable throughcreate_policy_pr, so thefollow-ups have to travel that way. Hiding them in mutable shared state would be
worse. The stub churn is mechanical and confined to the owned test file.
Review later: none needed.
Q: Should
--auditreconcile into the cloned consumer working tree?Decision: yes — reconcile inside the disposable tmpdir clone, then read
git statusfor the exact changed managed paths.Why: this is how the issue itself describes the replay ("applying it in a
disposable copy"). It is read-only with respect to the consumer repository: no
push, no PR, no
ghcall. Computing the diff any other way would duplicatereconcile logic and risk drifting from real behavior.
Review later: none needed.
Q: A consumer that is seam-clean but carries an unappliable follow-up — clean
or drifted?
Decision:
clean, with the follow-up still listed.Why: the follow-up is advisory and cannot be applied by the synchronizer, so
grading it as drift would produce permanent, unfixable red and encourage
churn-only PRs — an explicit non-goal of #319.
Review later: if maintainers want these surfaced as a distinct state, add a
fourth status rather than folding it into
drifted.Validation
All commands run on the final lane head. Real output, not summarized.
Locale before/after evidence for #281
The sandbox blocks
env -u, so the equivalence is shown explicitly — thisshell already has both variables unset, so a bare
rubyis theenv -u LANG -u LC_ALLcase:BEFORE (at base
282a87a, unset locale):AFTER (final head, unset locale):
AFTER (final head, UTF-8 locale):
Whole-file BEFORE at base, unset locale:
110 runs, 595 assertions, 0 failures, 1 errors, 0 skips.ruby bin/push-downstream-test.rb— final head, both localesbin/lint(hostedLintjob runs exactly this)RuboCop (pinned 1.87.0), shellcheck, and markdownlint all pass.
bin/lintcannotrun to completion on this machine because
yamllint 1.37.1is not installed;actionlintis installed but is not reached. Neither applies to this diff, whichchanges only two Ruby files:
Note:
bin/push-downstreamhas a#!/usr/bin/env rubyshebang, sobin/lintselects it for RuboCop, not shellcheck.
Sample
--auditreportGenerated against local fixture repositories (no network, no consumer repo
contacted):
{ "schema": "agent-workflows/downstream-seam-audit/v1", "source": { "repo": "shakacode/agent-workflows", "sha": "633370c4f65b0ecd8ee7c23212aa88dfa3b05a51", "worktree_clean": false }, "contract": { "statuses": ["clean", "drifted", "blocked"], "polymorphic_fields": ["seam_doctor_issues", "changed_managed_paths", "follow_ups"], "polymorphic_note": "For a clean or drifted consumer, seam_doctor_issues, changed_managed_paths, follow_ups are each an Array. For a blocked consumer they are the string \"UNKNOWN\", because the value could not be established. Type-check before iterating; an empty Array is never used to represent an unknown value.", "exit_codes": { "0": "every selected consumer is clean", "1": "at least one consumer is drifted or blocked" } }, "scope": { "audited": "repo-local agent-workflow seam and scaffold drift", "not_audited": "host-installed shared skills and workflows", "note": "Shared skills and workflows are installed per host and are intentionally not copied into consumer repos. A drifted consumer indicates repo-local seam drift only; it does not imply that every upstream commit requires a downstream PR." }, "summary": { "total": 3, "clean": 1, "drifted": 1, "blocked": 1 }, "consumers": [ { "repo": "local/drifted-consumer", "base_sha": "86bec655c03f0f1940feabfa9644b214d06172ec", "status": "drifted", "seam_doctor_issues": ["missing AGENTS.md"], "changed_managed_paths": [ ".agents/agent-workflow.yml", ".agents/bin/README.md", ".agents/bin/lint", ".agents/bin/test", ".agents/bin/validate", "AGENTS.md", "CLAUDE.md" ], "follow_ups": [] }, { "repo": "local/current-consumer", "base_sha": "61e8a9a5c9008653c0d96ce8845454683ff80411", "status": "clean", "seam_doctor_issues": [], "changed_managed_paths": [], "follow_ups": ["existing CLAUDE.md preserved; consolidate it to import @AGENTS.md"] }, { "repo": "local/unreachable-consumer", "base_sha": "UNKNOWN", "status": "blocked", "seam_doctor_issues": "UNKNOWN", "changed_managed_paths": "UNKNOWN", "follow_ups": "UNKNOWN", "reason": "clone of main failed" } ] }Mutation check on the #317 negative control
Removing the
@AGENTS.mdimport guard makes the negative control fail, provingit is not vacuous:
Guard restored; the test passes on the final head.
bin/validateFull local
bin/validateruns were executed at633370c. The later review-roundcommits change only
bin/push-downstream*and touch no other suite; that suitewent 120/679 → 127/725 and is green under both locales at the current head.
Hosted
validateis authoritative and passed on633370c,a1864d7, andc5f51ad. Thepush-downstreamstep passes in both local runs,and in the unset-locale run
bin/validatenow proceeds past it instead ofaborting there — which is the whole point of #281:
Neither full run reaches
PASS agent-workflows validationon this machine, andnot because of this PR. Both abort later, in files this PR does not touch
(
git diff --name-only origin/mainlists only the twobin/push-downstream*files). Reported honestly rather than as "clean":
1. Unset locale — aborts at
skills/post-merge-audit/bin/completed-batch-audit-receipt-test.rbSame root-cause class as #281 in a different file: green under UTF-8
(
70 runs, 572 assertions, 0 failures, 0 errors), 37 errors under US-ASCII. Thiswas previously masked —
bin/validateaborted atbin/push-downstream-test.rbbefore ever reaching it. Fixing #281 is what exposes it. Out of this lane's owned
paths; reported for separate scheduling, not fixed here.
2. UTF-8 locale — aborts at
bin/check-agent-workflow-drift-test.rb#test_git_probes_fail_closed_when_they_time_outA timing race: the test reads a pid file before the spawned hanging-git process
writes it. Re-run in isolation on this machine it still fails
(
1 runs, 8 assertions, 0 failures, 1 errors), so on this host it is worse thanintermittent — it loses the race consistently under load. Locale-independent and
unrelated to this diff. Out of lane; not fixed here.
Because
bin/validateruns underset -euo pipefail, both aborts mean its finalRuboCop step never executes. That is precisely the masking behavior #281
describes, so lint was verified independently via
bin/lintabove.