Skip to content

Rectify: Fixed-set join contract and Claude team-dispatch isolation (#4575, #4520) - #4613

Merged
Trecek merged 58 commits into
developfrom
impl-rectify-join-20260815-133231
Aug 17, 2026
Merged

Rectify: Fixed-set join contract and Claude team-dispatch isolation (#4575, #4520)#4613
Trecek merged 58 commits into
developfrom
impl-rectify-join-20260815-133231

Conversation

@Trecek

@Trecek Trecek commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Rectify: Fixed-set join contract and Claude team-dispatch isolation

This PR addresses both related issues together — #4575 — Claude/MiniMax teammate result delivery supplies the Claude dispatch guard, and #4520 — fixed-set blocking join supplies the end-to-end join contract. Neither half is complete alone: without the join contract the dispatch guard would have nothing to gate; without the dispatch guard the join contract would still admit the prose-only / team-mode escape path that #4575 records losing results.

Closes #4575
Closes #4520

Summary

The change keeps JoinSpec.required as the sole semantic authority and closes the production gaps through existing deep paths:

  • Backend admission is now truthful. Codex returns unsupported_operation(REQUIRED_JOIN) instead of rendering the impossible "wait on exact returned child IDs" contract. Claude refuses join.required=true when the static capability is absent and narrows its supported projection to declare-batch + unnamed foreground + Stop gating.
  • The session flag is a JSON envelope with OR-accumulated join_required, semantic/adaptation/artifact/projected digests, artifact_digest + artifact_incarnation, and child_spawn_cardinality. A later join-false load does NOT downgrade an established required-join binding (the OR-accumulated monotonic bit is asserted by test_subsequent_join_false_load_does_not_downgrade_join_required).
  • The Claude dispatch boundary (background_exec_guard.py) has its interactive-only early-exit removed and now rejects name, team_name, and run_in_background selectors when join_required=true. Legitimate team calls in clean sessions continue to work (asserted by test_clean_session_allows_named_teammate_dispatch, test_clean_session_allows_named_teammate_dispatch).
  • A declared-batch ledger (hooks/_join_ledger.py) keys membership and results by session, top-level parent, batch id, assignment, and direct tool_use_id. Four new hook scripts (join_claim_guard, join_settle_guard, join_stop_guard, join_followup_guard) provide PreToolUse claim, PostToolUse/PostToolUseFailure settlement, matcherless Stop gating, and unresolved-wave follow-up blocking with exit-code 2.
  • A free-range MCP tool declare_join_batch opens one wave with resolved assignment labels, validates the loaded skill / artifact / session binding, and returns a fresh join_batch_id.
  • Repository-scoped launch isolation (agent_backend.force_claude_agent_teams_inactive, default False) plus helpers that neutralize the documented public toggle (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) and detect conflicting entries in the target repo's .claude/settings.json / .claude/settings.local.json. Repositories with the option disabled remain byte-for-byte unchanged.
  • All 45 prose-only join consumers in skills_extended/ now declare semantic_requirements.join.required: true and the matching child_spawns / concurrency / evidence blocks. audit-docs gained the full semantic_version: 1 + block.
  • Threaded force_inactive_agent_teams through every launch corridor — interactive (build_interactive_cmd), resume, skill-session, and food-truck builders all honor the option via a force_inactive_agent_teams: bool = False parameter; _apply_config propagates the value from SkillSessionConfig.
  • Explorer dispatch unified under the semantic authority: ExplorationDispatchConventions gained assignments_argument and fail_unsupported_join; ExplorationRouterPlan carries join_required; the renderer declares resolved vector assignments and refuses frontend join prose on unsupported Codex.

Test Coverage

This PR adds new tests addressing the audit's MISSING findings:

Test file Coverage
tests/contracts/test_capability_hook_pairing.py Capability/hook pairing contract (REQ-EXTRACT-019)
tests/execution/backends/test_join_conformance_traces.py 4-child reusable trace + Codex admission refusal + Claude happy path (REQ-EXTRACT-083..089)
tests/execution/test_launch_force_inactive_default.py Byte-for-byte disabled-default preservation (REQ-EXTRACT-054)
tests/execution/test_launch_force_inactive_call_path.py Call-path coverage for every builder, including build_interactive_cmd with force_inactive_agent_teams=True (REQ-EXTRACT-052, REQ-076)
tests/execution/test_settings_file_neutralization.py Settings-file detection + neutralization + pre-spawn refusal (REQ-EXTRACT-053)
tests/hooks/test_join_composition.py 8 composition assertions including REQ-090 (denied PreToolUse creates no result record) (REQ-EXTRACT-081)
tests/hooks/test_join_declared_batch_cases.py 8 declared-batch cases + 5 non-success outcomes + 5 negative traces
tests/hooks/test_join_parallel_process.py 12 parallel-process declaration/ledger tests (REQ-EXTRACT-010, 067, 093)
tests/hooks/test_4575_negative_control.py Production-shaped #4575 negative control (REQ-EXTRACT-094, 095)
tests/hooks/test_join_diagnostics.py #4575 diagnostic reconstruction (REQ-EXTRACT-082)
tests/hooks/test_spawn_before_await_subordinate.py Spawn-before-await subordinate assertions (REQ-EXTRACT-090)
tests/hooks/test_skill_load_post_hook_json.py JSON envelope cases (REQ-EXTRACT-092)
tests/hooks/test_skill_load_post_hook.py Extended join-bearing assertions: test_join_bearing_skill_load_writes_complete_json_envelope, test_join_false_skill_load_keeps_join_required_false, test_subsequent_join_false_load_does_not_downgrade_join_required, test_fresh_session_without_join_loads_reports_join_required_false, test_skips_flag_write_when_agent_id_present_join_bearing_skill, test_skill_load_post_hook_backend_authority_join_bearing, test_codex_bypass_join_bearing_skill_with_nonempty_profile_writes_no_flag (REQ-052, REQ-053, REQ-103)
tests/infra/test_background_exec_guard.py 11 join-bound composition tests (REQ-054)
tests/server/test_server_tool_registration.py Updated test_all_tools_exist to include declare_join_batch (REQ-059)
tests/server/test_tool_registry_parity.py Updated test_every_tool_has_an_explicit_initialization_operation to include declare_join_batch in MUTATION (REQ-059)
tests/core/test_type_constants.py Updated test_free_range_tools_contains_expected_names to include declare_join_batch (REQ-059)
tests/arch/test_layer_enforcement.py::test_display_categories_sync Already derives from FREE_RANGE_TOOLS dynamically — already in sync

Cross-references

Adjacent ownership only — not in scope here:

  • #4233 — process lifecycle, declared timeout, resumption, orphan cleanup
  • #4224 — process lifecycle ownership
  • #4285 — capture lifecycle ownership
  • #4492 — descriptive provenance for team semantics

Verification

The change is complete when:

  • A four-assignment Claude wave cannot release follow-up effects or successful Stop until all four declared slots are claimed by direct foreground handles and settled.
  • Two sequential waves retain disjoint membership and outcomes.
  • Complete, partial-timeout, failure, cancellation, interruption, and missing-child ledger outcomes are distinguishable; only complete permits success/completion.
  • Current Codex join-bearing skills are rejected at admission with an honest capability reason; no rendered document instructs an unsupported exact-ID wait.
  • With join.required=true, undeclared/active-team/named Claude dispatch is denied before child creation; a declared unnamed foreground retry succeeds.
  • A nested join-false Skill load cannot downgrade an established required join; a clean non-governed/join-false session is not globally blocked.
  • The repository force-inactive policy neutralizes ambient activation and leaves permissive repositories unchanged.
  • All 45 prose-only join consumers carry semantic_requirements.join.required: true.

Tool count: 73 (was 72). Hook count: 51 (was 46 — 37 PreToolUse, 11 PostToolUse, 2 SessionStart, 1 Stop).

Implementation Plan

Plan file: /home/talon/projects/generic_automation_mcp/.autoskillit/temp/rectify/rectify_join_contract_and_claude_team_dispatch_2026-08-15_110450.md

🤖 Generated with Claude Code via AutoSkillit

@Trecek

Trecek commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Audit remediation round — 13/13 blocking findings addressed in commits 20d8643 and e2ae3b7.

Highlights:

  • Session binding is now manifest-resolved: skill_load_post_hook.py reads the projection manifest sidecar and populates the JSON envelope with the loaded skill's actual join_required, semantic/adaptation/projected/canonical digests, and child-spawn cardinality. A missing or unreadable manifest fails closed (binding_valid=false, join_required=true).
  • SkillProjectionBinding extended with join_required_by_member, child_spawn_cardinality_by_member, artifact_digest_by_member, artifact_incarnation_by_member.
  • declare_join_batch handler fails closed unless binding.join_required=true, the skill is in loaded_skills, the active backend attests fixed_set_join_capable, and the assignment count matches child_spawn_cardinality.
  • New matcherless PreToolUse join_followup_guard denies non-Agent side-effecting tools while a wave is unresolved (exit 2).
  • background_exec_guard now rejects ScheduleWakeup in join-bound sessions before the headless gate so interactive sessions also block the deferral escape hatch.
  • Codex Protocol signatures accept force_inactive_agent_teams as a no-op across all five launch builders.
  • Bounded telemetry on every gate (join_claim_guard, join_settle_guard, join_stop_guard, join_followup_guard, declare_join_batch handler) writes redacted JSONL to .autoskillit/logs/join_diagnostics.jsonl. Child bodies, prompts, secrets, and private task IDs never land in the sink.
  • New parsed-behavior inventory test asserts every child-spawning/exploration consumer declares join.required: true. Zero prose-only consumers remain.
  • Tool map updated in docs/execution/tool-access.md (declare_join_batch and lock_ingredients rows).
  • Join Contract and Batch Admission section added to docs/execution/architecture.md.
  • test_docs_state_73_mcp_tools renamed/updated for the 73-tool count.

Ready for re-audit.

Trecek added a commit that referenced this pull request Aug 17, 2026
C1 (tests): Restore script-presence assertion in
test_hooks_json_matches_hook_registry_after_generate that was lost
during the matcherless-rendering refactor.

C2 (tests): Fix or-vs-and typo in
test_claude_required_join_emits_keep_batch_first_directive — both
'name=' and 'name@' must be absent.

C3 (tests): Rename test_missing_binding_fails_closed_for_join_required
to test_missing_binding_defaults_to_permissive and align the
docstring with the actual permissive-default assertion.

C4/C5/C6 (defense/bugs): build_interactive_cmd gains a
project_root parameter, runs _resolve_project_root_for_inactive_check
when force_inactive_agent_teams=True (matching the headless/skill
builders), and explicitly refuses the force=True + executable
combination that previously caused the executable-binding guard to
raise after legitimate env neutralization. The Protocol signature is
updated in lockstep; Codex's build_interactive_cmd accepts the same
parameter (no-op — Codex has no team concept).

C7 (bugs): _aggregate_wave_outcome falls through to WAVE_PENDING for
mixed terminal outcomes (e.g. success+missing). Introduce WAVE_PARTIAL
and add it to _NON_SUCCESS_WAVE_OUTCOMES so mixed-terminal waves no
longer silently stall downstream join checks.

C8 (bugs): claim_assignment's duplicate tool_use_id guard now fires
regardless of whether the prior entry is pending — emitting the same
id twice corrupts downstream bookkeeping even if the prior claim
never settled.

C9 (overengineering): SkillProjectionBinding loses six speculative
by_member Mapping fields (join_required_by_member,
child_spawn_cardinality_by_member, artifact_digest_by_member,
artifact_incarnation_by_member, semantic_digests_by_member,
adaptation_digests_by_member) — they were populated but never read.
Removed the population logic from workspace/skill_projection.py
(artifact_digest / artifact_incarnation parameters were dead inside
the function).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 17, 2026
- tools_kitchen.py: _declare_join_batch_handler now sums declared
  per-role child_spawn_cardinality values rather than reading only
  the first one. A plan declaring multiple distinct role counts
  (e.g. {role_a: 2, role_b: 1}) is now correctly validated against
  the sum.

- tools_kitchen.py: _emit_join_diagnostic no longer maintains a
  local subset of DIAGNOSTIC_KEYS. The canonical frozenset in
  hooks/_hook_settings.py is the single source of truth for what
  fields may appear in a diagnostic record.

- background_exec_guard.py / join_followup_guard.py /
  join_stop_guard.py: replace bare open(...).read() with a
  `with open(...) as handle` block to close the file descriptor
  deterministically.

- join_stop_guard.py: drop dead json.JSONDecodeError/ValueError
  handlers around sys.stdin.read() — those exceptions can never
  be raised by a plain read().

- join_claim_guard.py: remove the no-op _resolve_top_level_parent
  wrapper that accepted and discarded a parameter to return a
  hardcoded marker. Inline the constant string at the call site.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 17, 2026
- tests/hooks/test_spawn_before_await_subordinate.py: promote the
  inline `from autoskillit.hooks._join_ledger import active_batch`
  block to the module-level import and use WAVE_PENDING /
  WAVE_COMPLETE constants instead of the bare `"pending"` /
  `"complete"` string literals.

- tests/execution/test_launch_force_inactive_default.py: drop the
  inner `import pytest` from
  `test_force_inactive_without_project_root_refuses` — pytest is
  already imported at the module top.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@Trecek
Trecek force-pushed the impl-rectify-join-20260815-133231 branch from d133acf to a391aaf Compare August 17, 2026 18:12
Trecek added a commit that referenced this pull request Aug 17, 2026
C1 (tests): Restore script-presence assertion in
test_hooks_json_matches_hook_registry_after_generate that was lost
during the matcherless-rendering refactor.

C2 (tests): Fix or-vs-and typo in
test_claude_required_join_emits_keep_batch_first_directive — both
'name=' and 'name@' must be absent.

C3 (tests): Rename test_missing_binding_fails_closed_for_join_required
to test_missing_binding_defaults_to_permissive and align the
docstring with the actual permissive-default assertion.

C4/C5/C6 (defense/bugs): build_interactive_cmd gains a
project_root parameter, runs _resolve_project_root_for_inactive_check
when force_inactive_agent_teams=True (matching the headless/skill
builders), and explicitly refuses the force=True + executable
combination that previously caused the executable-binding guard to
raise after legitimate env neutralization. The Protocol signature is
updated in lockstep; Codex's build_interactive_cmd accepts the same
parameter (no-op — Codex has no team concept).

C7 (bugs): _aggregate_wave_outcome falls through to WAVE_PENDING for
mixed terminal outcomes (e.g. success+missing). Introduce WAVE_PARTIAL
and add it to _NON_SUCCESS_WAVE_OUTCOMES so mixed-terminal waves no
longer silently stall downstream join checks.

C8 (bugs): claim_assignment's duplicate tool_use_id guard now fires
regardless of whether the prior entry is pending — emitting the same
id twice corrupts downstream bookkeeping even if the prior claim
never settled.

C9 (overengineering): SkillProjectionBinding loses six speculative
by_member Mapping fields (join_required_by_member,
child_spawn_cardinality_by_member, artifact_digest_by_member,
artifact_incarnation_by_member, semantic_digests_by_member,
adaptation_digests_by_member) — they were populated but never read.
Removed the population logic from workspace/skill_projection.py
(artifact_digest / artifact_incarnation parameters were dead inside
the function).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 17, 2026
- tools_kitchen.py: _declare_join_batch_handler now sums declared
  per-role child_spawn_cardinality values rather than reading only
  the first one. A plan declaring multiple distinct role counts
  (e.g. {role_a: 2, role_b: 1}) is now correctly validated against
  the sum.

- tools_kitchen.py: _emit_join_diagnostic no longer maintains a
  local subset of DIAGNOSTIC_KEYS. The canonical frozenset in
  hooks/_hook_settings.py is the single source of truth for what
  fields may appear in a diagnostic record.

- background_exec_guard.py / join_followup_guard.py /
  join_stop_guard.py: replace bare open(...).read() with a
  `with open(...) as handle` block to close the file descriptor
  deterministically.

- join_stop_guard.py: drop dead json.JSONDecodeError/ValueError
  handlers around sys.stdin.read() — those exceptions can never
  be raised by a plain read().

- join_claim_guard.py: remove the no-op _resolve_top_level_parent
  wrapper that accepted and discarded a parameter to return a
  hardcoded marker. Inline the constant string at the call site.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 17, 2026
- tests/hooks/test_spawn_before_await_subordinate.py: promote the
  inline `from autoskillit.hooks._join_ledger import active_batch`
  block to the module-level import and use WAVE_PENDING /
  WAVE_COMPLETE constants instead of the bare `"pending"` /
  `"complete"` string literals.

- tests/execution/test_launch_force_inactive_default.py: drop the
  inner `import pytest` from
  `test_force_inactive_without_project_root_refuses` — pytest is
  already imported at the module top.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 17, 2026
The rebase merged develop's 3 new MCP tools plus our 1 new declare_join_batch
tool into the codebase, bringing the total from 72 to 76. Doc claims and the
test_docs_state_75_mcp_tools test still asserted 75; update them to 76 to
match the post-merge tool_registry.py inventory.

Files updated:
- docs/README.md (top-level count)
- docs/execution/README.md (subdir reference)
- docs/execution/architecture.md (overview + order-session description)
- docs/execution/tool-access.md (3 occurrences)
- tests/docs/test_doc_counts.py (test function name + expected value)

Refs PR #4613.
@Trecek
Trecek enabled auto-merge August 17, 2026 18:13
@Trecek
Trecek force-pushed the impl-rectify-join-20260815-133231 branch from 4dac534 to 6301e97 Compare August 17, 2026 21:18
Trecek added a commit that referenced this pull request Aug 17, 2026
C1 (tests): Restore script-presence assertion in
test_hooks_json_matches_hook_registry_after_generate that was lost
during the matcherless-rendering refactor.

C2 (tests): Fix or-vs-and typo in
test_claude_required_join_emits_keep_batch_first_directive — both
'name=' and 'name@' must be absent.

C3 (tests): Rename test_missing_binding_fails_closed_for_join_required
to test_missing_binding_defaults_to_permissive and align the
docstring with the actual permissive-default assertion.

C4/C5/C6 (defense/bugs): build_interactive_cmd gains a
project_root parameter, runs _resolve_project_root_for_inactive_check
when force_inactive_agent_teams=True (matching the headless/skill
builders), and explicitly refuses the force=True + executable
combination that previously caused the executable-binding guard to
raise after legitimate env neutralization. The Protocol signature is
updated in lockstep; Codex's build_interactive_cmd accepts the same
parameter (no-op — Codex has no team concept).

C7 (bugs): _aggregate_wave_outcome falls through to WAVE_PENDING for
mixed terminal outcomes (e.g. success+missing). Introduce WAVE_PARTIAL
and add it to _NON_SUCCESS_WAVE_OUTCOMES so mixed-terminal waves no
longer silently stall downstream join checks.

C8 (bugs): claim_assignment's duplicate tool_use_id guard now fires
regardless of whether the prior entry is pending — emitting the same
id twice corrupts downstream bookkeeping even if the prior claim
never settled.

C9 (overengineering): SkillProjectionBinding loses six speculative
by_member Mapping fields (join_required_by_member,
child_spawn_cardinality_by_member, artifact_digest_by_member,
artifact_incarnation_by_member, semantic_digests_by_member,
adaptation_digests_by_member) — they were populated but never read.
Removed the population logic from workspace/skill_projection.py
(artifact_digest / artifact_incarnation parameters were dead inside
the function).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 17, 2026
- tools_kitchen.py: _declare_join_batch_handler now sums declared
  per-role child_spawn_cardinality values rather than reading only
  the first one. A plan declaring multiple distinct role counts
  (e.g. {role_a: 2, role_b: 1}) is now correctly validated against
  the sum.

- tools_kitchen.py: _emit_join_diagnostic no longer maintains a
  local subset of DIAGNOSTIC_KEYS. The canonical frozenset in
  hooks/_hook_settings.py is the single source of truth for what
  fields may appear in a diagnostic record.

- background_exec_guard.py / join_followup_guard.py /
  join_stop_guard.py: replace bare open(...).read() with a
  `with open(...) as handle` block to close the file descriptor
  deterministically.

- join_stop_guard.py: drop dead json.JSONDecodeError/ValueError
  handlers around sys.stdin.read() — those exceptions can never
  be raised by a plain read().

- join_claim_guard.py: remove the no-op _resolve_top_level_parent
  wrapper that accepted and discarded a parameter to return a
  hardcoded marker. Inline the constant string at the call site.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 17, 2026
- tests/hooks/test_spawn_before_await_subordinate.py: promote the
  inline `from autoskillit.hooks._join_ledger import active_batch`
  block to the module-level import and use WAVE_PENDING /
  WAVE_COMPLETE constants instead of the bare `"pending"` /
  `"complete"` string literals.

- tests/execution/test_launch_force_inactive_default.py: drop the
  inner `import pytest` from
  `test_force_inactive_without_project_root_refuses` — pytest is
  already imported at the module top.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 17, 2026
The rebase merged develop's 3 new MCP tools plus our 1 new declare_join_batch
tool into the codebase, bringing the total from 72 to 76. Doc claims and the
test_docs_state_75_mcp_tools test still asserted 75; update them to 76 to
match the post-merge tool_registry.py inventory.

Files updated:
- docs/README.md (top-level count)
- docs/execution/README.md (subdir reference)
- docs/execution/architecture.md (overview + order-session description)
- docs/execution/tool-access.md (3 occurrences)
- tests/docs/test_doc_counts.py (test function name + expected value)

Refs PR #4613.
Trecek added 15 commits August 17, 2026 16:20
Add fixed_set_join_capable field on BackendCapabilities (default False).
Pair it with CLAUDE_CODE_CAPABILITIES=True in the same commit so the
capability is statically declared wherever the required hooks are
unconditionally registered in HOOK_REGISTRY.

Make Codex return unsupported_operation(REQUIRED_JOIN) at admission with
an actionable reason instead of rendering the impossible 'wait on exact
returned child IDs' contract. Drop the unreachable wait_agent fragment
from Codex adapt_skill_semantics.

Make Claude refuse join.required=true when the capability is not
attested, and narrow the supported join projection to declare-batch plus
unnamed foreground calls plus Stop gating.

This is the foundation for the join ledger and dispatch guard added in
later steps; it makes compile_session_skill_catalog drop join-bearing
skills on Codex with a precise reason rather than a prose illusion.

Refs #4575, #4520.
…dary

Extend the projected skill manifest entry with join_required, normalized
child-spawn cardinality, and semantic/adaptation digests so the
post-hook sidecar has a structured source for the join-required bit.

Convert the session flag from a raw skill-name string to a JSON envelope
listing every loaded skill with its own join_required value. The flag's
top-level join_required is the OR of every entry — a later join-false
load does NOT downgrade an established required-join binding. Existing
single-skill string flags are read without loss.

Migrate the skill_load_guard auto-exempt writer to set auto_exempt=true
on the JSON envelope rather than clobbering it. The companion guard
treats any non-empty flag file as evidence that Skill was loaded.

Remove the unconditional headless-only early-exit in
background_exec_guard.py that previously short-circuited every
interactive session. The guard now runs in interactive Claude sessions
too — closing the #4575 escape hatch. When the session flag (or the
AUTOSKILLIT_JOIN_REQUIRED=1 env-var mirror) reports join_required=true,
the guard rejects Agent calls carrying name, team_name, or
run_in_background selectors with one deterministic denial. Ordinary
unnamed foreground Agent calls remain allowed. Legitimate team calls
in clean sessions (no join-bearing skill loaded) continue to work.
The ADR-0001 ScheduleWakeup/run_in_background gate still applies in
headless tier sessions.

Broaden the background_exec_guard HOOK_REGISTRATION from
session_scope='headless_only' to 'any' so the new behavior is actually
installed for interactive sessions.

Refs #4575, #4520.
Register a new free-range MCP tool ``declare_join_batch`` that opens one
parent/wave ledger with resolved assignment labels. The tool is in
FREE_RANGE_TOOLS (always addressable), classified MUTATION (denied
during recipe initialization like the other free-range siblings), and
listed in _DISPLAY_CATEGORIES so the Kitchen menu reflects it.

The tool is classified MUTATION because every ToolDef needs an explicit
initialization-operation bucket — none is exempt. The recipe-init
boundary (``admit_registered_tool_during_initialization``) denies it
during InitializingRecipe, exactly like ``lock_ingredients``,
``configure_fleet``, ``configure_order``, and ``reload_session``.

Add the shared ``hooks/_join_ledger.py`` helper: flock-based cross-process
locking (matching ``CaptureLifecycleStore``) plus atomic ``os.replace``
publish, JSON envelope keyed by (session_id, top_level_parent), and the
declare/claim/settle operations required by the hooks added in the next
commit. The helper is stdlib-only so hook scripts can use it without
importing autoskillit.*.

Update the documented tool counts from 72 to 73 (one new free-range tool).

Refs #4575, #4520.
Add three new stdlib-only hook scripts:

  * guards/join_claim_guard.py — PreToolUse, matcher='Agent'. For every
    top-level direct Agent tool_use_id in a join-bound session, atomically
    claim one declared assignment via the shared join ledger. Nested
    descendants (agent_id present) are exempt. Denials cover duplicate
    claims, missing declared wave, no unclaimed assignment, and missing
    tool_use_id.

  * guards/join_settle_guard.py — PostToolUse and PostToolUseFailure.
    Maps the upstream event to one of success/failure/timeout/cancelled/
    interruption/missing and records the terminal outcome on the claimed
    handle. Empty/non-substantive results are mapped to missing and
    never converted to success. Identical duplicate events are
    idempotent; conflicting terminals fail closed.

  * guards/join_stop_guard.py — Stop, matcher=''. Reads the session
    binding; no-ops when join_required=false. Otherwise reads the
    active wave from the ledger and blocks Stop with exit code 2 when
    the wave is missing, pending, or settled non-success. Per Claude
    docs, Stop fires once per turn and exit code 2 prevents Claude from
    stopping while continuing the conversation.

Register all three in HOOK_REGISTRY with codex_status='not-applicable'
so the Codex-filtered hook output and its tests remain correctly
unaffected (these hooks are Claude-only). Extend HookDef.event_type's
Literal to include PostToolUseFailure and Stop. Add all three
basenames to NEW_SUBDIR_BASENAMES (additions, not retirements).

Refs #4575, #4520.
Add the FastMCP handler for the new free-range ``declare_join_batch``
tool. Reads the session flag as JSON to recover the artifact identity,
opens one declared batch via the shared join ledger, and returns the
new ``join_batch_id`` plus the wave record.

The handler is tagged ``{"autoskillit", "kitchen"}`` and readOnlyHint is
False (it persists state). Like the other free-range kitchen tools, it
flows through ``_cancellation_shield`` and ``track_response_size``.

Refs #4575, #4520.
Add ``agent_backend.force_claude_agent_teams_inactive`` to
``AgentBackendConfig`` (default False, repository-scoped). Document the
key in the contracts ledger; the dotted key is new so no retired-config
entry is appropriate.

Add ``_neutralize_agent_teams_env`` and ``detect_repository_agent_teams_setting``
helpers in claude.py. The first removes
``CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`` (the documented public toggle
per code.claude.com/docs/en/agent-teams — the only such surface) from
the constructed env dict. The second reads the target repository's
.claude/settings.json and .claude/settings.local.json for any conflicting
``env.<var>`` entry that would otherwise re-enable teams after the
launcher process env has been scrubbed.

Add a ``force_inactive_agent_teams`` parameter to
``build_headless_cmd``; the interactive/resume/food-truck/skill-session
builders share the same env assembly and pick it up at their merge
points in follow-on commits.

This is the foundation for the repository-scoped launch isolation
described in Step 5. A follow-up commit threads the value through the
managed and interactive launch corridors and refuses the launch when
neither the launcher env nor the target repo's settings files positively
confirm an inactive effective state.

Refs #4575.
Extend HookDef.event_type's Literal to include PostToolUseFailure and
Stop. Add a module-level _MATCHERLESS_EVENT_TYPES frozenset so the
``__post_init__`` matcher validation accepts Stop (which fires once per
turn with no tool-name scope, per code.claude.com/docs/en/hooks) and
the existing SessionStart exception.

Extend _count_hooks_by_event in tests/docs/test_doc_counts.py to bucket
PostToolUseFailure and Stop separately so the test does not raise
KeyError on the new join_* hook scripts. The doc accuracy check now
covers all five event types.

Update docs/safety/hooks.md: 36 PreToolUse, 10 PostToolUse, 2
SessionStart, 1 Stop (49 total scripts) — matches HOOK_REGISTRY after
the three new join-aware scripts are registered.

Refs #4575, #4520.
  * Add ``_join_ledger`` to SINGLETON_ALLOWED_MODULES so the constant
    resolution test allows the module-level _BATCH_ID_ALPHABET and
    LEDGER_FILENAME assignments in the new join ledger.
  * Bump the pinned ``hooks/`` file count from 23 to 24 (one for
    ``_join_ledger.py``).
  * Bump the pinned ``hooks/guards/`` file count from 35 to 38 (one
    each for ``join_claim_guard.py``, ``join_settle_guard.py``,
    ``join_stop_guard.py``).

Refs #4575.
…g, follow-up guard, fail-closed validation, Codex Protocol symmetry

* skill_load_post_hook.py now reads the projection manifest sidecar
  (resolved via AUTOSKILLIT_PROJECTION_MANIFEST_PATH or sibling
  .<dir>.autoskillit-projection.json discovery) and populates the
  JSON envelope with the loaded skill's actual join_required, semantic
  and adaptation digests, projected/canonical digests, and child-spawn
  cardinality. A missing or unreadable manifest fails closed: the
  envelope is written with binding_valid=false and join_required=true
  so dispatch guards refuse all join-bearing work.
* SkillProjectionBinding extended with join_required_by_member,
  child_spawn_cardinality_by_member, artifact_digest_by_member,
  artifact_incarnation_by_member. build_skill_projection_binding
  populates them from each member's parsed semantic_plan.
* materialization.py manifest entry now exposes artifact_digest and
  artifact_incarnation; validator accepts any non-empty string for
  them since the values come from the publish-time binding.
* declare_join_batch handler fails closed unless the binding reports
  join_required=true, the skill is in loaded_skills, the active backend
  attests fixed_set_join_capable, and assignment count matches the
  manifest's child_spawn_cardinality.
* Codex Protocol signatures now accept force_inactive_agent_teams as
  a no-op across build_headless_cmd, build_skill_session_cmd,
  build_food_truck_cmd, build_interactive_cmd, build_resume_cmd to
  mirror the Claude launch builders.
* background_exec_guard rejects ScheduleWakeup in join-bound sessions
  before the headless gate so interactive sessions also block the
  deferral escape hatch.
* New matcherless PreToolUse join_followup_guard denies non-Agent
  side-effecting tools while a wave is unresolved (exit 2).
* docs/execution/tool-access.md adds lock_ingredients and
  declare_join_batch rows to the FREE RANGE map; tool count says 73.
* docs/execution/architecture.md adds the "Join Contract and Batch
  Admission" section describing declare_join_batch, JoinLedger, the
  claim/settle/Stop lifecycle, backend admission, session binding
  monotonicity, and the repository force-inactive option.
* docs/safety/hooks.md counts updated to 37 PreToolUse hooks and 50
  total scripts.
* tests/docs/test_doc_counts.py renamed test_docs_state_72_mcp_tools to
  test_docs_state_73_mcp_tools with expected=73.
* tests/arch/test_subpackage_isolation.py bumps hooks/guards count from
  38 to 39 (one new followup guard).

Refs #4575, #4520.
* ``hooks/_join_ledger.py`` exposes ``DIAGNOSTIC_KEYS`` and
  ``write_diagnostic(record, caller)`` for bounded JSONL emission to
  ``<project>/.autoskillit/logs/join_diagnostics.jsonl``. The record
  is filtered to a fixed allow-list so child bodies, prompts, secrets,
  and private task IDs never land in the sink. Stdlib-only — usable
  from hook scripts without importing ``autoskillit.*``.
* ``hooks/_hook_settings.py`` adds ``write_join_diagnostic`` mirroring
  the helper for non-hook call sites.
* ``join_claim_guard`` emits ``status=deny`` / ``status=deny_no_open_wave`` /
  ``status=claim`` records with ``join_batch_id`` and ``assignment`` on
  success.
* ``join_settle_guard`` emits ``status=<outcome>`` with the latest
  ``wave_outcome``.
* ``join_stop_guard`` emits ``status=allow|block`` with
  ``binding_valid`` and the current ``wave_outcome`` reason.
* ``join_followup_guard`` emits ``status=block`` with the offending
  tool name and current ``wave_outcome``.
* ``_declare_join_batch_handler`` emits ``status=declared`` and
  ``status=declare_refused`` records.
* New parsed-behavior inventory test
  ``tests/skills/test_semantic_join_inventory.py`` walks every bundled
  SKILL.md, parses the YAML frontmatter, and asserts:
  - any skill with a non-empty ``child_spawns`` block must declare
    ``semantic_requirements.join.required: true``;
  - any skill whose body contains structural child-dispatch markers
    (``Agent(``, ``Task(``, ``spawn_agent``, ``Dispatch``, etc.) must
    declare ``join.required: true``;
  - zero prose-only join consumers remain after the Step 6 migration.

Refs #4575, #4520.
…ization, capability/hook pairing, #4575 negative control, parallel-process ledger tests, diagnostic reconstruction
…-bound dispatch composition, parity tests, denied-PreToolUse no-result-record, interactive-corridor force=True, OR-accumulated downgrade test, build_interactive_cmd neutralization bugfix

REQ-052, REQ-053, REQ-103: assert the full atomic JSON envelope on a join-bearing skill load (skill_name, join_required, semantic/adaptation/projected/artifact digests, artifact_incarnation, child_spawn_cardinality, binding_valid), extend existing subagent/backend-authority/codex-bypass tests with join-bearing projections, and assert that a later join-false load does not downgrade an established required-join binding.

REQ-054: extend tests/infra/test_background_exec_guard.py with eleven join-bound composition cases (required join + name/team_name denial, run_in_background=true denial, unnamed foreground allowance, clean-session preservation, malformed/missing binding fail-closed, ScheduleWakeup rejection, activation source/state reporting).

REQ-059: register declare_join_batch in test_type_constants.py (FREE_RANGE_TOOLS), test_server_tool_registration.py (test_all_tools_exist), and test_tool_registry_parity.py (MUTATION operation). test_layer_enforcement.py::test_display_categories_sync derives from FREE_RANGE_TOOLS dynamically and is already in sync.

REQ-076: extend test_launch_force_inactive_call_path.py parametrize set to include build_interactive_cmd with force_inactive_agent_teams=True.

REQ-090: add test_denied_pre_tool_use_creates_no_result_record asserting that a denied Agent PreToolUse produces no ledger entry, no claim, no settlement, and that can_release_stop blocks Stop until the wave is declared and resolved.

REQ-081/REQ-082: verified _explorer_dispatch.py declares resolved vector assignment labels (resolved_assignment_labels in preamble, per-vector Resolved vector assignment label lines) and that CODEX_EXPLORATION_DISPATCH_RENDERER.fail_unsupported_join refuses frontend join prose when the backend does not attest fixed_set_join_capable.

bugfix: build_interactive_cmd previously neutralized CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS on a throwaway dict copy of a MappingProxyType, leaving effective_env unchanged and causing assert_agent_teams_inactive to raise. Re-derive neutralized_env as a single mutable copy and bind effective_env to it after the assertion.
…llowlist, AST violations, atomic_write

- Update test_backend_capabilities.py field-count and field-name-locked tests to include fixed_set_join_capable (REQ-JOIN backend authority).
- Update test_backend_dataclasses.py field-exhaustive test to include force_inactive_agent_teams.
- Add hooks/_join_ledger.py to FCNTL_ALLOWED_MODULES — stdlib-only hook script needs direct fcntl.flock for cross-process atomic ledger writes.
- Replace print() + bare Exception in hooks/_join_ledger.py with stdlib logging + OSError/ValueError catches (ARCH-001/ARCH-003 compliance).
- Replace bare Exception in tools_kitchen.py declare_join_batch paths with typed exceptions and logger.warning calls.
- Replace path.write_text in neutralize_repository_agent_teams_settings with atomic_write from autoskillit.core.io.
- Add fixed_set_join_capable to test_coding_agent_backend_conformance.py::CAPABILITY_CLASSIFICATION as OPTIONAL and to NOT_YET_LIVE — the field is declared but the test infrastructure is being wired up.

These are inherited regressions from earlier commits that the previous audit's Slice C missed; the current audit (Slice C) flagged only documentation parity. This commit closes the underlying field-count, AST-rule, and atomic-write regressions.
Trecek and others added 8 commits August 17, 2026 16:22
The worktree commit a391aaf over-counted by 1 in both dimensions (claimed
76/142, actual post-rebase source has 75/141). Update docs and test fixture
to match the actual MCP tool registry and bundled skill inventory.
…ations, add missing reinforcement prose, fix Codex admission-refusal test fallout

- Revert _fmt_status.py relative import back to bare sibling import (matches
  every other formatter module); a prior commit broke the standalone hook
  dispatch path while 'fixing' an unrelated test.
- Update test_skill_load_guard.py and test_schema_version_convention.py to
  match the new JSON auto-exempt schema and current tools_kitchen.py line
  numbers.
- Revert spurious child_spawns/join.required frontmatter added to
  make-campaign, make-experiment-diag, report-bug, write-recipe — none of
  these skills spawn children (report-bug explicitly prohibits it); the
  additions only existed to silence a substring-matching false positive in
  test_semantic_join_inventory.py. Tighten that heuristic's markers
  (drop bare 'delegate'/'child delegations' prose matches and require
  'Dispatch(' call syntax) to match its own stated 'parsed behavior, not
  prose markers' design intent.
- Add the required three-layer parallel-dispatch reinforcement prose
  (NEVER/ALWAYS) to the 11 vis-lens-* skills that genuinely declare
  concurrent child dispatch, matching the established arch-lens-*/
  vis-lens-chart-select pattern.
- Update tests that now correctly observe Codex's honest REQUIRED_JOIN
  refusal (SkillContractError) for join-bearing skills: either assert the
  refusal directly, or swap an incidental test-fixture skill name to a
  non-join skill when the test's actual purpose is unrelated to join
  semantics (session materialization, home cleanup, lease handling,
  discovery collision, override precedence).
- Fix test_launch_force_inactive_default.py's PATH-dependent test to use a
  hermetic tmp_path executable, matching sibling tests in the same family.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n boundary

Codex's adapt_skill_semantics() fails closed (raises SkillContractError)
directly from inside the method for join.required=true plans, rather than
returning a gradable unsupported_operation result. Three production call
sites were built against the old gradable-result contract and crashed
instead of gracefully excluding the skill:

- ProjectedPluginArtifactAuthority._plan() (authority.py): computed a
  per-skill adaptation-identity digest for the DIRECT_INSTALL bundled
  catalog. Now catches the refusal, excludes the skill from the catalog
  (skills/namespace_sources/digests), and re-derives skill/adaptation
  identity from the filtered set.
- compile_session_skill_catalog() (session_skills.py): the primary
  SESSION-role catalog compiler; already had unsupported_operation-based
  graceful exclusion downstream of adapt_skill_semantics(), but that path
  was unreachable since the raise happens before any result is returned.
  Now wraps the call and routes the refusal into the same
  SkillUnavailableMetadata/unavailable-list contract.
- build_skill_projection_binding() (skill_projection.py): materializes the
  actual per-skill projected documents for a binding. This is reached by
  the ORCHESTRATOR-role catalog used by kitchen/food-truck dispatch, which
  is resolved before a backend is selected and therefore never passes
  through compile_session_skill_catalog()'s exclusion. Now excludes any
  skill whose document projection raises, consistent with the other two
  call sites.

Traced end-to-end via tests/fleet/test_dispatch_backend_override.py and
tests/fleet/test_fleet_e2e_codex.py, which silently failed with
'fleet_l3_no_result_block' (the crash was logged via logger.error but never
surfaced to the dispatch outcome) before this fix.

Also fixes test_codex_cook_projects_only_spawnable_compose_pr_roles, which
called adapt_skill_semantics() directly on the join-bearing compose-pr
skill to derive an expected role-mapping set; compose-pr's role names are
unaffected by Codex's mapping rule, so the expected set is now derived
directly from the semantic plan instead of the refusing adapter call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- declare_join_batch's MCP tool registration set readOnlyHint=False,
  violating the codebase-wide contract (test_all_tools_match_readonly_hint_contract)
  that every tool except open_kitchen declares readOnlyHint=True regardless
  of internal mutation — the same convention already used by every other
  free-range/mutating tool (lock_ingredients, configure_fleet, git/clone
  operations, etc.). Corrected to True.
- test_force_inactive_false_keeps_env_var[build_skill_session_cmd/
  build_food_truck_cmd] relied on the ambient host CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
  env var being present to test that force=False leaves it untouched — it
  never explicitly injected the var, unlike every other builder helper in
  the same file (_headless_stripped, _resume_stripped, _interactive_stripped),
  so it passed by environmental accident locally and failed in CI's clean
  environment. Made hermetic by explicitly injecting the var via
  provider_extras/env_extras, matching the sibling helpers' pattern.
  Verified with the ambient var explicitly unset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lowlist

The previous commit flipped declare_join_batch's readOnlyHint from False to
True to satisfy tests/server/test_tool_annotation_completeness.py, but that
was wrong: scripts/check_tool_annotations.py (REQ-ARCH-ANNOTATION-E1) and
its own arch-level test (tests/arch/test_tool_annotation_completeness.py)
explicitly and intentionally treat declare_join_batch as a second effectful
exception alongside open_kitchen — it opens a join batch ledger entry, so
readOnlyHint=False is correct and was already passing that contract.

Reverted tools_kitchen.py back to readOnlyHint=False. The actual bug was
tests/server/test_tool_annotation_completeness.py's allowlist
(), which was never updated when
declare_join_batch was introduced as the second exception. Fixed the
allowlist to match REQ-ARCH-ANNOTATION-E1's two-tool exception set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Trecek
Trecek force-pushed the impl-rectify-join-20260815-133231 branch from 033f19f to 9b05631 Compare August 17, 2026 23:38
@Trecek
Trecek added this pull request to the merge queue Aug 17, 2026
Merged via the queue into develop with commit 55b6974 Aug 17, 2026
4 checks passed
@Trecek
Trecek deleted the impl-rectify-join-20260815-133231 branch August 17, 2026 23:52
github-merge-queue Bot pushed a commit that referenced this pull request Aug 18, 2026
…cs, wired config authority, and an unstubbed corridor startup net (#4688)

## Summary

Commit `55b697427` (PR #4613) grafted an unconditional Claude
agent-teams environment policy onto the interactive pre-spawn
checkpoint. The opt-in authority
`agent_backend.force_claude_agent_teams_inactive` was dead config —
defined, never read anywhere in `src/` — while the checkpoint enforced
the policy on every corridor (`cook`, `order`, fleet) for every backend,
crashing startup on any machine whose gitignored
`.claude/settings.local.json` or shell env legitimately enables
`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`.

The architectural weakness is threefold, and each half of it enabled the
other:

1. **Intent-blind checkpoints.** `CmdSpec` carried no record of what
policy was requested at build time. A post-hoc checker structurally
*cannot* know whether neutralization was asked for — so it degrades to
"always enforce." The shipped docstring described behavior that could
not exist.
2. **Authority declared but unconsumed, with no contract catching it.**
The config ledger proves a key *parses*; nothing proved a key is *read*.
`force_claude_agent_teams_inactive` passed every contract while being
dead from birth — and it was not alone.
3. **No behavioral net over the interactive corridor.** 13 call sites
across 7 test files stub `validate_interactive_invocation` to `return
[]`; the two real-gate tests run in sterile tmp dirs with no `.claude/`
settings; smoke lanes are excluded from PR CI. This is the fifth
fail-closed-checkpoint corridor breakage in two weeks.

The immunity: (A) policy intent becomes **data on the spec**, stamped by
the builder — checkpoints read intent, never infer it; (B) the config
authority is wired end-to-end and a **config-consumption contract test**
makes "declared but unread" a CI failure for every config field forever;
(C) an **unstubbed corridor startup net** — realistic-fixture launches
with a fake agent binary, running per-PR — makes any future checkpoint
that fires on legitimate state an instant test failure, regardless of
its mechanism.

## Requirements

Policy intent becomes data on the spec, stamped by the builder, so
checkpoints read intent
rather than infer it. The config authority is wired end-to-end and a
config-consumption
contract makes "declared but unread" a CI failure for every config
field. An unstubbed
corridor startup net running per-PR makes any future checkpoint that
fires on legitimate
state an instant test failure, regardless of its mechanism.

Default-config launches must remain byte-for-byte unaffected, and the
opt-in must remain
fail-closed when neutralization is genuinely impossible (#4575's
original protection).

`#4572` (attestation env drift tripping the executable-binding guard) is
a separate defect
on the same functions and is explicitly out of scope.

## Verification

Measured on a machine whose `.claude/settings.local.json` legitimately
enables agent teams —
the configuration that triggered the defect:

| | `develop` (`5e3a29a86`) | this branch |
|---|---|---|
| `task test-check` | **81 failed**, 39595 passed | **16 failed**, 39738
passed |

The 16 remaining failures fail identically at the base commit and are
set-wise a strict
subset of the baseline: **zero regressions introduced, 65 baseline
failures fixed.** The
residue is the `#4572` attestation-drift family, explicitly out of
scope.

`pre-commit run --all-files` is clean (ruff, mypy, gitleaks, stub and
contract checks).

## Two deliberate departures from the plan

**The opt-in was unreachable, and the plan preserved the ordering that
made it so.**
`build_interactive_cmd` asserted inactivity *before* stripping settings,
so any repository
whose settings enabled teams raised instead of being neutralized —
leaving
`neutralize_repository_agent_teams_settings` dead code for the exact
population it exists to
serve. Neutralization now precedes confirmation. A malformed file is
still refused rather
than rewritten, so the confirmation continues to fail closed there,
preserving #4575's
guarantee where it matters.

**The prescribed config-consumption exclusion rule would have flagged a
live field.**
Excluding `_config_dataclasses.py` wholesale reports
`GitHubConfig.allowed_labels` as dead —
it is read solely by `check_label_allowed()`, an accessor colocated with
the definition and
called from six production sites. The rule implemented is narrower: skip
`__post_init__`
bodies only. Self-validation is not consumption; an accessor beside the
definition is.

## Not delivered

The cook black-box launch test. `cook` hangs before its confirmation
prompt in a fully
hermetic environment for reasons unrelated to this change, and a
timing-out test is worse
than no test. The cook corridor remains covered by
`test_force_inactive_config_corridors.py`
(config read plus both build branches) and the existing cold-launch
mediums; the corridor
realism gap is closed on the `order` path, which now launches against a
teams-enabled
checkout and passes, including the exported-env variant.

## Follow-ups filed

Two config fields are forward-declared rather than deleted to make the
new contract green:
#4685 (`ProviderProfileDef.context_window`) and #4686
(`RunSkillConfig.natural_exit_grace_seconds`). Each carries a 180-day
staleness time-bomb.

Closes #4687

## Implementation Plan

Plan file:
`.autoskillit/temp/rectify/rectify_interactive_policy_checkpoint_immunity_2026-08-17_174353.md`

🤖 Generated with [Claude Code](https://claude.com/claude-code) via
AutoSkillit
<!-- autoskillit:pipeline-signature
steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr
-->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 19, 2026
…g-field-has-consumer (#4684)

Move the interactive content-policy checkpoint from an unconditional gate
inside assert_interactive_ordering/validate_interactive_invocation to an
opt-in flag carried on CmdSpec itself, mirroring the existing
force_inactive_agent_teams builder-kwarg precedent. Wires the previously-dead
AgentBackendConfig.force_claude_agent_teams_inactive config field into the
cook CLI so it finally has a production consumer.

- CmdSpec gains force_inactive_agent_teams: bool = False; all 16 constructor
  sites across claude.py, codex.py, _cmd_builder.py, launch_resolution.py,
  and _type_launch.py's launch-record (de)serializer now pass it explicitly.
- assert_interactive_ordering (_headless_helpers.py) and
  validate_interactive_invocation (claude.py) both short-circuit when
  spec.force_inactive_agent_teams is False — the policy no longer fires for
  every interactive launch regardless of caller intent.
- _session_cook.py resolves force_inactive from
  config.agent_backend.force_claude_agent_teams_inactive and threads it into
  both the probe-required and fallback build paths.
- prepare_interactive_launch (_session_launch.py) fixes two propagation
  gaps: (1) its first build_interactive_cmd call never forwarded
  project_root, so force_inactive_agent_teams=True unconditionally raised
  "requires project_root"; (2) the second (executable-bound) call cannot
  itself carry force_inactive_agent_teams=True (build_interactive_cmd's own
  guard forbids combining it with executable=), so the resulting spec never
  carried the caller's intent — fixed via dataclasses.replace() after the
  call returns.
- build_interactive_cmd's env-drift check ("interactive environment changed
  after executable binding") now excludes CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
  from the comparison: when force_inactive_agent_teams=True neutralizes that
  var on the first (probe) call, the second (executable-bound) call cannot
  re-neutralize it (guarded), so a real ambient env value would otherwise
  always trip the drift check as a false positive. The var's own state is
  already validated by assert_agent_teams_inactive on whichever call
  requested force_inactive.
- New tests/contracts/test_cmd_spec_force_inactive_field.py: AST-walks every
  CmdSpec(...) site in src/autoskillit/, asserting each passes the kwarg.
- New tests/contracts/test_config_field_has_consumer.py: generalizes the
  inert-tracked:#NNNN discipline (tests/AGENTS.md) to config dataclass
  fields, scoped to AgentBackendConfig (the dataclass this plan wires).
- New tests/cli/test_cook_settings_local_agent_teams.py: end-to-end cook()
  against a populated .claude/settings.local.json without stubbing
  validate_interactive_invocation — the exact composition PR #4613 broke.
  This test class of failure (72 pre-existing failures across
  test_fleet_dispatch.py, test_session_launch.py, test_interactive_cold_
  launch_medium.py, test_reload_loop.py, test_terminal.py, and others) is
  now resolved now that the policy is opt-in instead of unconditional.

Part of the #4684 broker-provisioning-catch-all-and-cook-loop-opt-in-
regression rectify plan (steps 2.2+2.3 of 13).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 19, 2026
…4684)

Three consecutive rectify-adjacent changes to the same function/module
(PR #4503 -> #4512 -> #4613/#4684) shipped with no CI signal that the
underlying plan touched multiple import layers at once — PR #4613's plan
touched >=3 layers across 141 files with no gate to flag it.

- New tests/arch/test_rectify_blast_radius_guard.py: scans
  'git diff --name-only HEAD~1 -- .autoskillit/temp/rectify/*.md' (not a
  static directory listing — .autoskillit/temp/ is gitignored per
  .autoskillit/.gitignore:1, so a directory scan would pass vacuously in
  every fresh CI clone with zero chance of ever firing) for any rectify
  plan committed in the most recent commit, counts distinct IL-0..IL-3
  import-layer tokens referenced in it, and classifies: <=3 layers OK,
  4-5 layers emits a pytest warning (explicit reviewer check), >5 layers
  is a hard test failure (decomposition required). The counting/
  classification logic is pinned by direct unit tests independent of git
  state; the integration test passes vacuously in this worktree (the
  driving plan for this very implementation lives outside version control,
  per the same gitignore rule) — documented as the accepted, structural
  limitation of a git-diff-based scan versus a directory-listing scan that
  could never fire at all.
- Extend skills_extended/rectify/SKILL.md's plan-split rule: split per
  layer when >3 distinct import layers are touched, even under the
  pre-existing 500-line threshold; >5 layers must be decomposed before
  implementation begins. Line count and layer count are independent
  triggers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 19, 2026
…ocal.json (#4684)

- Extend tests/cli/_cook_launch_helpers.py's arrange_cook with two new
  optional parameters, both additive/opt-in (existing call sites unchanged):
  - settings_content: dict | None — writes .claude/settings.local.json into
    the tmp_path project before cook runs. Refactored
    test_cook_settings_local_agent_teams.py (delivered in Rectify 2.2/2.3)
    to use it instead of its own duplicate write, removing the redundancy.
  - project_dir_override: Path | None — uses a caller-supplied project
    directory (e.g. the real repository root) instead of creating an empty
    tmp_path/project. Caller owns creating/populating that directory; the
    helper will not mkdir() or write settings_content into it, since doing
    so on a real, pre-existing directory would be destructive.
- New tests/contracts/test_cli_cook_validator_stub_guard.py: an AST guard
  that fails when a tests/cli/ test both invokes cli.cook(...) and
  monkeypatches the *real* ClaudeCodeBackend.validate_interactive_invocation
  (3-arg class-target or 2-arg dotted-path form) — the exact composition
  that hid PR #4613's regression. Deliberately narrower than 'any
  validate_interactive_invocation stub': stubbing the real CodexBackend (a
  distinct, legitimately no-op-for-this-policy implementation — Codex has
  no agent-teams concept, out of scope per the plan's Step 6) or defining a
  standalone fake backend double (neither is 'hiding' any real production
  behavior) does not flag. A fully generic rule would immediately
  false-positive on 4+ pre-existing, legitimate Codex-focused tests. Five
  synthetic self-tests exercise both the two violation shapes and the three
  allowed shapes; a sixth confirms zero real violations exist today.
- New tests/cli/test_cook_real_root_smoke.py (opt-in live gate, Step 1.13):
  gated by AUTOSKILLIT_COOK_REAL_ROOT_SMOKE=1, drives cli.cook() against
  this repository's own project root and its real .claude/settings.local.json
  (backed up and restored around each sub-case; restoration failure fails
  the test loudly rather than silently leaving the file test-modified).
  Asserts cli.cook() completes without error when force_inactive is False,
  and raises when CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 is active and
  force_inactive is True — the exact composition PR #4613 broke. A single
  test function (not two) so 'exactly one non-skipped test' holds.
- New Taskfile.yml task test-smoke-cook-real-root, mirroring
  test-smoke-claude-explorer-live-gate's preconditions (claude installed +
  isolated auth present) and post-validation shape (JUnit non-skipped-count
  check), simplified to a stdout ValueError-absence check in place of the
  explorer gate's evidence-JSONL correlation check (this gate has no
  analogous evidence stream to validate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 19, 2026
…g-field-has-consumer (#4684)

Move the interactive content-policy checkpoint from an unconditional gate
inside assert_interactive_ordering/validate_interactive_invocation to an
opt-in flag carried on CmdSpec itself, mirroring the existing
force_inactive_agent_teams builder-kwarg precedent. Wires the previously-dead
AgentBackendConfig.force_claude_agent_teams_inactive config field into the
cook CLI so it finally has a production consumer.

- CmdSpec gains force_inactive_agent_teams: bool = False; all 16 constructor
  sites across claude.py, codex.py, _cmd_builder.py, launch_resolution.py,
  and _type_launch.py's launch-record (de)serializer now pass it explicitly.
- assert_interactive_ordering (_headless_helpers.py) and
  validate_interactive_invocation (claude.py) both short-circuit when
  spec.force_inactive_agent_teams is False — the policy no longer fires for
  every interactive launch regardless of caller intent.
- _session_cook.py resolves force_inactive from
  config.agent_backend.force_claude_agent_teams_inactive and threads it into
  both the probe-required and fallback build paths.
- prepare_interactive_launch (_session_launch.py) fixes two propagation
  gaps: (1) its first build_interactive_cmd call never forwarded
  project_root, so force_inactive_agent_teams=True unconditionally raised
  "requires project_root"; (2) the second (executable-bound) call cannot
  itself carry force_inactive_agent_teams=True (build_interactive_cmd's own
  guard forbids combining it with executable=), so the resulting spec never
  carried the caller's intent — fixed via dataclasses.replace() after the
  call returns.
- build_interactive_cmd's env-drift check ("interactive environment changed
  after executable binding") now excludes CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
  from the comparison: when force_inactive_agent_teams=True neutralizes that
  var on the first (probe) call, the second (executable-bound) call cannot
  re-neutralize it (guarded), so a real ambient env value would otherwise
  always trip the drift check as a false positive. The var's own state is
  already validated by assert_agent_teams_inactive on whichever call
  requested force_inactive.
- New tests/contracts/test_cmd_spec_force_inactive_field.py: AST-walks every
  CmdSpec(...) site in src/autoskillit/, asserting each passes the kwarg.
- New tests/contracts/test_config_field_has_consumer.py: generalizes the
  inert-tracked:#NNNN discipline (tests/AGENTS.md) to config dataclass
  fields, scoped to AgentBackendConfig (the dataclass this plan wires).
- New tests/cli/test_cook_settings_local_agent_teams.py: end-to-end cook()
  against a populated .claude/settings.local.json without stubbing
  validate_interactive_invocation — the exact composition PR #4613 broke.
  This test class of failure (72 pre-existing failures across
  test_fleet_dispatch.py, test_session_launch.py, test_interactive_cold_
  launch_medium.py, test_reload_loop.py, test_terminal.py, and others) is
  now resolved now that the policy is opt-in instead of unconditional.

Part of the #4684 broker-provisioning-catch-all-and-cook-loop-opt-in-
regression rectify plan (steps 2.2+2.3 of 13).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 19, 2026
…4684)

Three consecutive rectify-adjacent changes to the same function/module
(PR #4503 -> #4512 -> #4613/#4684) shipped with no CI signal that the
underlying plan touched multiple import layers at once — PR #4613's plan
touched >=3 layers across 141 files with no gate to flag it.

- New tests/arch/test_rectify_blast_radius_guard.py: scans
  'git diff --name-only HEAD~1 -- .autoskillit/temp/rectify/*.md' (not a
  static directory listing — .autoskillit/temp/ is gitignored per
  .autoskillit/.gitignore:1, so a directory scan would pass vacuously in
  every fresh CI clone with zero chance of ever firing) for any rectify
  plan committed in the most recent commit, counts distinct IL-0..IL-3
  import-layer tokens referenced in it, and classifies: <=3 layers OK,
  4-5 layers emits a pytest warning (explicit reviewer check), >5 layers
  is a hard test failure (decomposition required). The counting/
  classification logic is pinned by direct unit tests independent of git
  state; the integration test passes vacuously in this worktree (the
  driving plan for this very implementation lives outside version control,
  per the same gitignore rule) — documented as the accepted, structural
  limitation of a git-diff-based scan versus a directory-listing scan that
  could never fire at all.
- Extend skills_extended/rectify/SKILL.md's plan-split rule: split per
  layer when >3 distinct import layers are touched, even under the
  pre-existing 500-line threshold; >5 layers must be decomposed before
  implementation begins. Line count and layer count are independent
  triggers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 19, 2026
…ocal.json (#4684)

- Extend tests/cli/_cook_launch_helpers.py's arrange_cook with two new
  optional parameters, both additive/opt-in (existing call sites unchanged):
  - settings_content: dict | None — writes .claude/settings.local.json into
    the tmp_path project before cook runs. Refactored
    test_cook_settings_local_agent_teams.py (delivered in Rectify 2.2/2.3)
    to use it instead of its own duplicate write, removing the redundancy.
  - project_dir_override: Path | None — uses a caller-supplied project
    directory (e.g. the real repository root) instead of creating an empty
    tmp_path/project. Caller owns creating/populating that directory; the
    helper will not mkdir() or write settings_content into it, since doing
    so on a real, pre-existing directory would be destructive.
- New tests/contracts/test_cli_cook_validator_stub_guard.py: an AST guard
  that fails when a tests/cli/ test both invokes cli.cook(...) and
  monkeypatches the *real* ClaudeCodeBackend.validate_interactive_invocation
  (3-arg class-target or 2-arg dotted-path form) — the exact composition
  that hid PR #4613's regression. Deliberately narrower than 'any
  validate_interactive_invocation stub': stubbing the real CodexBackend (a
  distinct, legitimately no-op-for-this-policy implementation — Codex has
  no agent-teams concept, out of scope per the plan's Step 6) or defining a
  standalone fake backend double (neither is 'hiding' any real production
  behavior) does not flag. A fully generic rule would immediately
  false-positive on 4+ pre-existing, legitimate Codex-focused tests. Five
  synthetic self-tests exercise both the two violation shapes and the three
  allowed shapes; a sixth confirms zero real violations exist today.
- New tests/cli/test_cook_real_root_smoke.py (opt-in live gate, Step 1.13):
  gated by AUTOSKILLIT_COOK_REAL_ROOT_SMOKE=1, drives cli.cook() against
  this repository's own project root and its real .claude/settings.local.json
  (backed up and restored around each sub-case; restoration failure fails
  the test loudly rather than silently leaving the file test-modified).
  Asserts cli.cook() completes without error when force_inactive is False,
  and raises when CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 is active and
  force_inactive is True — the exact composition PR #4613 broke. A single
  test function (not two) so 'exactly one non-skipped test' holds.
- New Taskfile.yml task test-smoke-cook-real-root, mirroring
  test-smoke-claude-explorer-live-gate's preconditions (claude installed +
  isolated auth present) and post-validation shape (JUnit non-skipped-count
  check), simplified to a stdout ValueError-absence check in place of the
  explorer gate's evidence-JSONL correlation check (this gate has no
  analogous evidence stream to validate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 20, 2026
…g-field-has-consumer (#4684)

Move the interactive content-policy checkpoint from an unconditional gate
inside assert_interactive_ordering/validate_interactive_invocation to an
opt-in flag carried on CmdSpec itself, mirroring the existing
force_inactive_agent_teams builder-kwarg precedent. Wires the previously-dead
AgentBackendConfig.force_claude_agent_teams_inactive config field into the
cook CLI so it finally has a production consumer.

- CmdSpec gains force_inactive_agent_teams: bool = False; all 16 constructor
  sites across claude.py, codex.py, _cmd_builder.py, launch_resolution.py,
  and _type_launch.py's launch-record (de)serializer now pass it explicitly.
- assert_interactive_ordering (_headless_helpers.py) and
  validate_interactive_invocation (claude.py) both short-circuit when
  spec.force_inactive_agent_teams is False — the policy no longer fires for
  every interactive launch regardless of caller intent.
- _session_cook.py resolves force_inactive from
  config.agent_backend.force_claude_agent_teams_inactive and threads it into
  both the probe-required and fallback build paths.
- prepare_interactive_launch (_session_launch.py) fixes two propagation
  gaps: (1) its first build_interactive_cmd call never forwarded
  project_root, so force_inactive_agent_teams=True unconditionally raised
  "requires project_root"; (2) the second (executable-bound) call cannot
  itself carry force_inactive_agent_teams=True (build_interactive_cmd's own
  guard forbids combining it with executable=), so the resulting spec never
  carried the caller's intent — fixed via dataclasses.replace() after the
  call returns.
- build_interactive_cmd's env-drift check ("interactive environment changed
  after executable binding") now excludes CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
  from the comparison: when force_inactive_agent_teams=True neutralizes that
  var on the first (probe) call, the second (executable-bound) call cannot
  re-neutralize it (guarded), so a real ambient env value would otherwise
  always trip the drift check as a false positive. The var's own state is
  already validated by assert_agent_teams_inactive on whichever call
  requested force_inactive.
- New tests/contracts/test_cmd_spec_force_inactive_field.py: AST-walks every
  CmdSpec(...) site in src/autoskillit/, asserting each passes the kwarg.
- New tests/contracts/test_config_field_has_consumer.py: generalizes the
  inert-tracked:#NNNN discipline (tests/AGENTS.md) to config dataclass
  fields, scoped to AgentBackendConfig (the dataclass this plan wires).
- New tests/cli/test_cook_settings_local_agent_teams.py: end-to-end cook()
  against a populated .claude/settings.local.json without stubbing
  validate_interactive_invocation — the exact composition PR #4613 broke.
  This test class of failure (72 pre-existing failures across
  test_fleet_dispatch.py, test_session_launch.py, test_interactive_cold_
  launch_medium.py, test_reload_loop.py, test_terminal.py, and others) is
  now resolved now that the policy is opt-in instead of unconditional.

Part of the #4684 broker-provisioning-catch-all-and-cook-loop-opt-in-
regression rectify plan (steps 2.2+2.3 of 13).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 20, 2026
…4684)

Three consecutive rectify-adjacent changes to the same function/module
(PR #4503 -> #4512 -> #4613/#4684) shipped with no CI signal that the
underlying plan touched multiple import layers at once — PR #4613's plan
touched >=3 layers across 141 files with no gate to flag it.

- New tests/arch/test_rectify_blast_radius_guard.py: scans
  'git diff --name-only HEAD~1 -- .autoskillit/temp/rectify/*.md' (not a
  static directory listing — .autoskillit/temp/ is gitignored per
  .autoskillit/.gitignore:1, so a directory scan would pass vacuously in
  every fresh CI clone with zero chance of ever firing) for any rectify
  plan committed in the most recent commit, counts distinct IL-0..IL-3
  import-layer tokens referenced in it, and classifies: <=3 layers OK,
  4-5 layers emits a pytest warning (explicit reviewer check), >5 layers
  is a hard test failure (decomposition required). The counting/
  classification logic is pinned by direct unit tests independent of git
  state; the integration test passes vacuously in this worktree (the
  driving plan for this very implementation lives outside version control,
  per the same gitignore rule) — documented as the accepted, structural
  limitation of a git-diff-based scan versus a directory-listing scan that
  could never fire at all.
- Extend skills_extended/rectify/SKILL.md's plan-split rule: split per
  layer when >3 distinct import layers are touched, even under the
  pre-existing 500-line threshold; >5 layers must be decomposed before
  implementation begins. Line count and layer count are independent
  triggers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Trecek added a commit that referenced this pull request Aug 20, 2026
…ocal.json (#4684)

- Extend tests/cli/_cook_launch_helpers.py's arrange_cook with two new
  optional parameters, both additive/opt-in (existing call sites unchanged):
  - settings_content: dict | None — writes .claude/settings.local.json into
    the tmp_path project before cook runs. Refactored
    test_cook_settings_local_agent_teams.py (delivered in Rectify 2.2/2.3)
    to use it instead of its own duplicate write, removing the redundancy.
  - project_dir_override: Path | None — uses a caller-supplied project
    directory (e.g. the real repository root) instead of creating an empty
    tmp_path/project. Caller owns creating/populating that directory; the
    helper will not mkdir() or write settings_content into it, since doing
    so on a real, pre-existing directory would be destructive.
- New tests/contracts/test_cli_cook_validator_stub_guard.py: an AST guard
  that fails when a tests/cli/ test both invokes cli.cook(...) and
  monkeypatches the *real* ClaudeCodeBackend.validate_interactive_invocation
  (3-arg class-target or 2-arg dotted-path form) — the exact composition
  that hid PR #4613's regression. Deliberately narrower than 'any
  validate_interactive_invocation stub': stubbing the real CodexBackend (a
  distinct, legitimately no-op-for-this-policy implementation — Codex has
  no agent-teams concept, out of scope per the plan's Step 6) or defining a
  standalone fake backend double (neither is 'hiding' any real production
  behavior) does not flag. A fully generic rule would immediately
  false-positive on 4+ pre-existing, legitimate Codex-focused tests. Five
  synthetic self-tests exercise both the two violation shapes and the three
  allowed shapes; a sixth confirms zero real violations exist today.
- New tests/cli/test_cook_real_root_smoke.py (opt-in live gate, Step 1.13):
  gated by AUTOSKILLIT_COOK_REAL_ROOT_SMOKE=1, drives cli.cook() against
  this repository's own project root and its real .claude/settings.local.json
  (backed up and restored around each sub-case; restoration failure fails
  the test loudly rather than silently leaving the file test-modified).
  Asserts cli.cook() completes without error when force_inactive is False,
  and raises when CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 is active and
  force_inactive is True — the exact composition PR #4613 broke. A single
  test function (not two) so 'exactly one non-skipped test' holds.
- New Taskfile.yml task test-smoke-cook-real-root, mirroring
  test-smoke-claude-explorer-live-gate's preconditions (claude installed +
  isolated auth present) and post-validation shape (JUnit non-skipped-count
  check), simplified to a stdout ValueError-absence check in place of the
  explorer gate's evidence-JSONL correlation check (this gate has no
  analogous evidence stream to validate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant