diff --git a/docs/README.md b/docs/README.md index 9ce3b6e9a8..01bc8b3a31 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ AutoSkillit is a Claude Code plugin that runs YAML recipes through a multi-level orchestrator. The bundled recipes implement issue → plan → worktree -→ tests → PR → merge pipelines using 74 MCP tools and 141 bundled skills. +→ tests → PR → merge pipelines using 75 MCP tools and 141 bundled skills. ## Start here diff --git a/docs/execution/README.md b/docs/execution/README.md index cf3473d76d..d4ae999e24 100644 --- a/docs/execution/README.md +++ b/docs/execution/README.md @@ -7,6 +7,6 @@ merge gates. - [claude-startup-readiness.md](claude-startup-readiness.md) — bounded MCP addressability and conformance evidence - [explorer-agents.md](explorer-agents.md) — bounded specialized repository exploration agents - [fleet-effect-provenance.md](fleet-effect-provenance.md) — dispatch effect receipts and retry disposition -- [tool-access.md](tool-access.md) — visibility and authority across all 74 MCP tools +- [tool-access.md](tool-access.md) — visibility and authority across all 75 MCP tools - [orchestration.md](orchestration.md) — orchestration levels, retry reasons, merge decision tree - [orchestration-levels.md](../orchestration-levels.md) — L0–L3 level definitions, mapping table diff --git a/docs/execution/architecture.md b/docs/execution/architecture.md index a556fed662..7467542ec0 100644 --- a/docs/execution/architecture.md +++ b/docs/execution/architecture.md @@ -4,7 +4,7 @@ How AutoSkillit runs a recipe end to end: orchestrator, kitchen gating, clone an ## Overview -AutoSkillit is a Claude Code plugin that orchestrates automated workflows using headless sessions. It provides 74 MCP tools and 141 bundled skills, organized into a gated visibility system. +AutoSkillit is a Claude Code plugin that orchestrates automated workflows using headless sessions. It provides 75 MCP tools and 141 bundled skills, organized into a gated visibility system. ## Core Concepts @@ -39,8 +39,8 @@ AutoSkillit uses several overlapping tool visibility surfaces: `mcp.enable({'headless'})` — `test_check`, `unlock_agent_pack`, `commit_files`, `write_audit_semantic_result`, `write_standalone_audit_evidence`, `write_audit_disposition_bundle`, `post_pr_review`, and `delegate_evidence_reader`. -- **Kitchen-tagged tools (51 tools total)**: Gated behind `open_kitchen` — `run_skill`, - `run_cmd`, `run_python`, `merge_worktree`, `clone_repo`, `push_to_remote`, and 45 more. +- **Kitchen-tagged tools (52 tools total)**: Gated behind `open_kitchen` — `run_skill`, + `run_cmd`, `run_python`, `merge_worktree`, `clone_repo`, `push_to_remote`, and 46 more. Seven kitchen tools also carry the `headless` tag and are additionally pre-enabled in headless sessions. `post_pr_review` is headless-only and deliberately not application-gated. @@ -102,7 +102,7 @@ AutoSkillit supports four session modes with different tool and skill visibility - **`$ claude` (plugin, no kitchen)**: Regular Claude Code session with the AutoSkillit plugin loaded. Sees the 8 Free Range MCP tools and Tier 1 skills only - (`open-kitchen`, `close-kitchen`). After calling `/open-kitchen`, all 51 kitchen-tagged MCP + (`open-kitchen`, `close-kitchen`). After calling `/open-kitchen`, all 52 kitchen-tagged MCP tools become available. - **`$ autoskillit cook`**: Interactive development session. Sees all three skill tiers @@ -110,7 +110,7 @@ AutoSkillit supports four session modes with different tool and skill visibility `$ claude`); `/open-kitchen` reveals kitchen tools. - **`$ autoskillit order`**: Pipeline orchestrator session. Kitchen is pre-opened at startup. - The authenticated evidence-reader brokers remain hidden among the 74 registered MCP tools + The authenticated evidence-reader brokers remain hidden among the 76 registered MCP tools because only a separately launched reader child receives their binding. All skill tiers are accessible. The orchestrator delegates work through `run_skill` (headless sessions) and `run_cmd` (shell commands). @@ -123,6 +123,23 @@ AutoSkillit supports four session modes with different tool and skill visibility This prevents recursive session nesting and keeps the orchestrator as a pure routing engine. See **[Skill Visibility](../skills/visibility.md)** for the full tier breakdown and configuration. +## Join Contract and Batch Admission + +A skill that declares `semantic_requirements.join.required: true` enters a join-bound session the moment Claude loads it. The hook layer carries the join policy and the batch ledger: + +1. **`declare_join_batch`** opens one parent/wave with resolved assignment labels, validates the loaded skill's `join.required` and `child_spawn_cardinality` against the projection manifest, and returns a fresh `join_batch_id`. +2. **JoinLedger** keys membership and outcomes by `(session_id, top_level_parent, join_batch_id, assignment, tool_use_id)`. Persistence uses `fcntl.flock` + atomic `os.replace` (`hooks/_join_ledger.py`). +3. **Claim → Settle → Stop lifecycle** — + * `join_claim_guard` (PreToolUse, matcher `Agent`) atomically claims one declared assignment per top-level direct `Agent` `tool_use_id`. + * `join_settle_guard` (PostToolUse + PostToolUseFailure) maps the upstream event to one of `success / failure / timeout / cancelled / interruption / missing` and records the outcome on the claimed handle. Empty results are mapped to `missing`, never `success`. + * `join_followup_guard` (matcherless PreToolUse) denies non-`Agent` side-effecting calls while a wave is unresolved. + * `join_stop_guard` (Stop, exit code 2) blocks Claude from completing until the wave is `complete`. +4. **Backend admission** — `BackendCapabilities.fixed_set_join_capable` is statically `True` only for Claude Code, and only when the full guard set is registered in the same commit. Codex returns `unsupported_operation(REQUIRED_JOIN)` at admission; current Codex has wait-any/mailbox semantics, not fixed-set fan-in. +5. **Session binding monotonicity** — `skill_load_post_hook.py` writes a JSON envelope with OR-accumulated `join_required`. A later join-false Skill load does not downgrade an established binding. A missing or unreadable projection manifest fails closed by forcing `join_required: true` so dispatch guards refuse all join-bearing work. +6. **Repository force-inactive option** — `agent_backend.force_claude_agent_teams_inactive` (default False) neutralizes `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` and detects conflicting entries in the target repository's `.claude/settings.json` / `.claude/settings.local.json`. Repositories with the option disabled remain byte-for-byte unchanged. + +The session flag carries join policy; the manifest carries projection identity; the ledger carries wave state. Each is read by the matching hook family. None of them is a duplicate authority — they are three projections of one decision. + ## Durable Codex Cook Sessions Codex cook history uses a per-attempt view rather than exposing the complete diff --git a/docs/execution/tool-access.md b/docs/execution/tool-access.md index e81e8ef2bc..697c3aefb5 100644 --- a/docs/execution/tool-access.md +++ b/docs/execution/tool-access.md @@ -1,6 +1,6 @@ # MCP Tool Access Control -AutoSkillit provides 74 MCP tools across overlapping visibility surfaces that control which +AutoSkillit provides 75 MCP tools across overlapping visibility surfaces that control which session types can see each tool. Visibility determines addressability; each tool still enforces its own authority contract. @@ -140,7 +140,7 @@ missing kitchen visibility. ## Complete MCP Tool Access Control Map -All 74 tools with their access level, tags, source file, and functional category. +All 75 tools with their access level, tags, source file, and functional category. **Tag abbreviations**: AS = `autoskillit`, K = `kitchen`, HL = `headless`, ER = `evidence-reader`, GH = `github`, CI = `ci`, CL = `clone`, @@ -159,7 +159,8 @@ TL = `telemetry`, FL = `fleet` | `reload_session` | AS | `server/tools_kitchen.py` | | `configure_fleet` | AS | `server/tools_config.py` | | `configure_order` | AS | `server/tools_config.py` | -| `lock_ingredients` | AS | `server/tools_config.py` | +| `lock_ingredients` | AS | `server/tools_kitchen.py` | +| `declare_join_batch` | AS, K | `server/tools_kitchen.py` | Opens one declared-batch JoinLedger for the next wave; see `JoinLedger` lifecycle. Claude-only when `fixed_set_join_capable`. | --- @@ -299,7 +300,7 @@ dynamically gated until opening completes. The bounded client snapshot and fresh/resume behavior are documented in [Claude startup readiness](claude-startup-readiness.md). -**Total: 74 registered tools**. The 51 kitchen-tagged tools include seven of the eight +**Total: 75 registered tools**. The 51 kitchen-tagged tools include seven of the eight headless tools. The two authenticated evidence-reader brokers are excluded from the kitchen, free-range, and fleet counts. diff --git a/docs/faq.md b/docs/faq.md index 93cc43cd89..b35f827a42 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -101,3 +101,63 @@ Open an issue in the GitHub repository. AutoSkillit also has a built-in `report_bug` MCP tool that the `pipeline-summary` skill calls automatically when an overnight pipeline surfaces a bug. The tool deduplicates against existing open issues by fingerprint. + +### Does AutoSkillit force Claude agent teams off? + +No, not by default. The `agent_backend.force_claude_agent_teams_inactive` +configuration option defaults to **false**, scoped per-repository (project +configuration overrides user-level `.claude/settings.json`). When the +operator sets it to **true** on a target repo, AutoSkillit strips +`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` from the launch environment before +every Claude launch (interactive, resume, skill-session, and food-truck +builders) and rewrites any conflicting `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` +entry in the target repo's `.claude/settings.json` or `.claude/settings.local.json`. +The pre-spawn checkpoint fail-closes: when the effective environment still +carries a truthy team value, the launch refuses with an explicit reason. + +### When do teams help, and when is the join contract required? + +The force-inactive policy is **not** a global rule — it exists because +join-bearing skills need an unguarded, declared-batch surface that +team-mode routing defeats. Legitimate team workflows (named teammate +dispatch, team-name routing, background tasks) continue to work in any +session that has not loaded a join-bearing skill. A session that loads a +join-bearing skill is permanently bound for the rest of its lifetime: a +later load of a non-join skill cannot downgrade the binding. To re-enable +team workflows in a session, the operator must start a fresh session after +the join-bearing load. + +### Can Codex run join-bearing skills? + +Not today. Codex's static capability attestation reports +`fixed_set_join_capable=False`. When a skill declares +`semantic_requirements.join.required: true`, the `declare_join_batch` MCP +tool refuses with `unsupported_operation(REQUIRED_JOIN)` and the skill +cannot be admitted. Codex support requires the harness to expose a +fixed-set fan-in primitive; until then, the backend gate is the single +honest source. + +### What happens when I try a named Claude dispatch in a join-bound session? + +It is denied before child creation. The `background_exec_guard` +PreToolUse hook reads the session binding and rejects any Agent call with +`name`, `team_name`, or `run_in_background` selectors while +`join_required=true`. The denial names the rejected selectors and points +the operator at the `declare_join_batch` gateway — declare a wave with +resolved assignment labels and re-dispatch as ordinary unnamed foreground +Agent calls. The `ScheduleWakeup` deferral hook is denied for the same +reason: deferral cannot produce the declared-batch evidence the join +contract requires. + +### What's the difference between `join_required` and `team_name`? + +`join_required` is the semantic authority over the **parent's** dispatch +boundary: a parent that loads a join-bearing skill must use the +declared-batch fan-in path; the join contract gates its child routing. +`team_name` is a Claude-only runtime selector that names a teammate under +agent teams. They are not interchangeable: a join-bearing parent cannot +dispatch via `team_name` (the dispatch guard denies it), and a non-join +parent that names a teammate under agent teams is not bound by the join +contract at all — it is the legitimate team workflow path described +above. The two surfaces never overlap in the same session. + diff --git a/docs/safety/hooks.md b/docs/safety/hooks.md index 81ea333456..97094d28bf 100644 --- a/docs/safety/hooks.md +++ b/docs/safety/hooks.md @@ -1,13 +1,13 @@ # Hooks -AutoSkillit registers 46 Claude Code hook scripts: 35 PreToolUse, 9 PostToolUse, -and 2 SessionStart. Every script is stdlib-only Python so it can run before the +AutoSkillit registers 51 Claude Code hook scripts: 37 PreToolUse, 11 PostToolUse, +2 SessionStart, and 1 Stop. Every script is stdlib-only Python so it can run before the project virtualenv is on the path. Scripts live in `src/autoskillit/hooks/` and are bound to event types in `src/autoskillit/hook_registry.py` via the `HOOK_REGISTRY` list of `HookDef` entries; `generate_hooks_json()` then materializes the canonical `hooks.json` that Claude Code reads. -## PreToolUse hooks (35) +## PreToolUse hooks (37) ### `branch_protection_guard.py` **Guarded tools:** `merge_worktree`, `push_to_remote` @@ -413,7 +413,7 @@ closed when their identity is malformed or the record cannot be written, while malformed JSON and unrelated tools remain fail-open. Codex and headless terminal authority do not use this bridge. -## PostToolUse hooks (9) +## PostToolUse hooks (10) ### `pretty_output_hook.py` **Guarded tools:** all AutoSkillit MCP tools diff --git a/scripts/check_tool_annotations.py b/scripts/check_tool_annotations.py index ec88cbd7ac..2284500bc7 100644 --- a/scripts/check_tool_annotations.py +++ b/scripts/check_tool_annotations.py @@ -12,7 +12,10 @@ from pathlib import Path TOOLS_DIR = Path(__file__).resolve().parent.parent / "src" / "autoskillit" / "server" / "tools" -READ_ONLY_EXCEPTIONS = {"open_kitchen": False} +# REQ-ARCH-ANNOTATION-E1: open_kitchen and declare_join_batch are the only +# effectful tools; the former opens the kitchen session and the latter opens +# a join batch ledger entry. Every other tool must remain readOnlyHint=True. +READ_ONLY_EXCEPTIONS = {"open_kitchen": False, "declare_join_batch": False} def check() -> list[str]: diff --git a/src/autoskillit/agents/plan-registry-tracer.md b/src/autoskillit/agents/plan-registry-tracer.md index 9d26e6dc8c..88ececcaf8 100644 --- a/src/autoskillit/agents/plan-registry-tracer.md +++ b/src/autoskillit/agents/plan-registry-tracer.md @@ -49,7 +49,7 @@ LSP tracks typed references but misses field names embedded as **string literals `tree-sitter-python` (v0.25) and `tree-sitter-language-pack` are installed as project dev dependencies. Run via `python3` in Bash. Example — find a field name inside registry dicts/frozensets: ```python -python3 << 'PYEOF' +python3 << "PYEOF" import tree_sitter_python as tspython from tree_sitter import Language, Parser from pathlib import Path @@ -58,25 +58,30 @@ import os FIELD = "TARGET_FIELD_NAME" # replace with actual field parser = Parser(Language(tspython.language())) + def find_in_file(fpath): source = fpath.read_bytes() tree = parser.parse(source) hits = [] + def walk(node): if node.type == "string": text = node.text.decode().strip('"').strip("'") if FIELD in text: ctx = node.parent.type if node.parent else "?" - hits.append((node.start_point[0]+1, text, ctx)) + hits.append((node.start_point[0] + 1, text, ctx)) if node.type == "keyword_argument": name = node.child_by_field_name("name") if name and FIELD in name.text.decode(): - hits.append((node.start_point[0]+1, name.text.decode(), "keyword_argument")) - for c in node.children: walk(c) + hits.append((node.start_point[0] + 1, name.text.decode(), "keyword_argument")) + for c in node.children: + walk(c) + walk(tree.root_node) for line, text, ctx in hits: print(f" {fpath}:{line} — '{text}' (inside {ctx})") + root = os.environ.get("CODEBASE_ROOT", ".") for f in Path(root, "src").rglob("*.py"): find_in_file(f) diff --git a/src/autoskillit/cli/session/_session_launch.py b/src/autoskillit/cli/session/_session_launch.py index 1d74171ab5..efe733d967 100644 --- a/src/autoskillit/cli/session/_session_launch.py +++ b/src/autoskillit/cli/session/_session_launch.py @@ -90,6 +90,7 @@ def prepare_interactive_launch( tools: Sequence[str] = (), add_dirs: Sequence[Path | str | ValidatedAddDir] = (), generated_home: Path | None = None, + force_inactive_agent_teams: bool = False, ) -> PreparedInteractiveLaunch: """Probe an exact executable before sealing the capability-complete session env.""" @@ -122,6 +123,7 @@ def prepare_interactive_launch( add_dirs=add_dirs, generated_home=generated_home, tools=tools, + force_inactive_agent_teams=force_inactive_agent_teams, ) final = resolve_executable_launch_binding( binary_name=backend.binary_name(), diff --git a/src/autoskillit/config/_config_dataclasses.py b/src/autoskillit/config/_config_dataclasses.py index 2b2a4f5aa6..a138f4afb6 100644 --- a/src/autoskillit/config/_config_dataclasses.py +++ b/src/autoskillit/config/_config_dataclasses.py @@ -671,6 +671,12 @@ class AgentBackendConfig: backend: str = "claude-code" step_overrides: dict[str, str] = field(default_factory=dict) recipe_overrides: dict[str, dict[str, str]] = field(default_factory=dict) + # Repository-scoped toggle: when True, the Claude launcher neutralizes + # CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS in both the process env and the + # target repository's .claude/settings*.json files before spawn. Defaults + # to False — repositories with the option disabled remain byte-for-byte + # unchanged. Independent from join.required. Refs #4575. + force_claude_agent_teams_inactive: bool = False def __post_init__(self) -> None: if not self.backend: diff --git a/src/autoskillit/config/ingredient_defaults.py b/src/autoskillit/config/ingredient_defaults.py index b295930745..3dfb799c82 100644 --- a/src/autoskillit/config/ingredient_defaults.py +++ b/src/autoskillit/config/ingredient_defaults.py @@ -137,6 +137,7 @@ "configure_fleet", "configure_order", "lock_ingredients", + "declare_join_batch", ), ), ) diff --git a/src/autoskillit/core/tool_registry.py b/src/autoskillit/core/tool_registry.py index ce20babf14..d4e82b109a 100644 --- a/src/autoskillit/core/tool_registry.py +++ b/src/autoskillit/core/tool_registry.py @@ -83,6 +83,7 @@ "configure_order", "create_and_publish_branch", "create_unique_branch", + "declare_join_batch", "disable_quota_guard", "dispatch_food_truck", "enable_exploration", @@ -678,6 +679,12 @@ def _run_skill() -> ToolDef: roles={"_autoskillit_exploration_request_token": ToolParamRole.ORCHESTRATOR_SCOPING}, ), _tool("lock_ingredients", ("locked", "pipeline_id", "unlock")), + _tool( + "declare_join_batch", + ("skill_name", "assignments", "session_id", "top_level_parent"), + required=("skill_name", "assignments", "session_id"), + wire_types={"assignments": ToolWireType.ARRAY}, + ), _tool("reload_session"), _tool("record_pipeline_step", ("pipeline_id", "op", "dependencies", "step_name")), _tool("get_pr_reviews", ("pr_number", "cwd", "repo"), required=("pr_number", "cwd")), diff --git a/src/autoskillit/core/types/_type_backend.py b/src/autoskillit/core/types/_type_backend.py index 5be3f4545b..5cfc73cdbd 100644 --- a/src/autoskillit/core/types/_type_backend.py +++ b/src/autoskillit/core/types/_type_backend.py @@ -236,6 +236,16 @@ class BackendCapabilities: # Interactive hook trust behavior. Automated builders retain their explicit # bypass policy; interactive launchers translate this policy into CLI flags. hook_trust_policy: HookTrustPolicy = HookTrustPolicy.AUTOMATED + # True only when the backend natively provides fixed-set join semantics: + # a declared batch declaration tool, an Agent PreToolUse claim guard, + # PostToolUse and PostToolUseFailure settlers, an unresolved-follow-up + # gate, and a Stop completion gate are all installed and capability-attested. + # This is a static declaration that must be paired with unconditional + # registration of every required hook in HOOK_REGISTRY in the same commit. + # Codex does NOT satisfy this contract — its wait-any/mailbox semantics + # cannot realize exact-set fixed membership, so its `fixed_set_join_capable` + # must remain False until the active harness exposes a real fixed-set primitive. + fixed_set_join_capable: bool = False ALL_PROJECT_LOCAL_SKILL_SEARCH_DIRS: tuple[str, ...] = ( @@ -406,6 +416,7 @@ def model_class(model: str) -> str: protected_recipe_delivery_capable=False, recipe_delivery_budget=None, hook_trust_policy=HookTrustPolicy.AUTOMATED, + fixed_set_join_capable=True, ) @@ -498,6 +509,7 @@ class SkillSessionConfig: native_shell_capture_decision: NativeShellCaptureDecision | None = None managed_lineage_ref: ManagedHeadlessSessionLineageRef | None = None managed_attempt_id: str | None = None + force_inactive_agent_teams: bool = False def __post_init__(self) -> None: managed_values = ( diff --git a/src/autoskillit/core/types/_type_constants_registries.py b/src/autoskillit/core/types/_type_constants_registries.py index 25e1453e1a..8c2d53cff7 100644 --- a/src/autoskillit/core/types/_type_constants_registries.py +++ b/src/autoskillit/core/types/_type_constants_registries.py @@ -211,6 +211,7 @@ "configure_fleet", "configure_order", "lock_ingredients", # NEW (#3357) + "declare_join_batch", # NEW (REQ-JOIN-001 — REQ-JOIN-002, #4575) } ) diff --git a/src/autoskillit/core/types/_type_protocols_backend.py b/src/autoskillit/core/types/_type_protocols_backend.py index d0e9d9ecfb..ad58ff34a3 100644 --- a/src/autoskillit/core/types/_type_protocols_backend.py +++ b/src/autoskillit/core/types/_type_protocols_backend.py @@ -242,6 +242,8 @@ def build_resume_cmd( managed_attempt_id: str | None = None, include_scope_discipline: bool = False, skill_session: bool = False, + force_inactive_agent_teams: bool = False, + project_root: Path | str | None = None, ) -> CmdSpec: ... def build_skill_session_cmd( @@ -249,6 +251,9 @@ def build_skill_session_cmd( skill_command: str, cwd: str, config: SkillSessionConfig, + *, + force_inactive_agent_teams: bool = False, + project_root: Path | str | None = None, ) -> CmdSpec: ... def build_food_truck_cmd( @@ -274,6 +279,8 @@ def build_food_truck_cmd( native_shell_capture_decision: NativeShellCaptureDecision | None = None, managed_lineage_ref: ManagedHeadlessSessionLineageRef | None = None, managed_attempt_id: str | None = None, + force_inactive_agent_teams: bool = False, + project_root: Path | str | None = None, ) -> CmdSpec: ... def build_interactive_cmd( @@ -290,6 +297,8 @@ def build_interactive_cmd( env_extras: Mapping[str, str] | None = None, required_env: frozenset[str] | None = None, tools: Sequence[str] = (), + force_inactive_agent_teams: bool = False, + project_root: Path | str | None = None, ) -> CmdSpec: ... def validate_session_layout( diff --git a/src/autoskillit/execution/backends/_backend_cmd_builder_base.py b/src/autoskillit/execution/backends/_backend_cmd_builder_base.py index 6581b7ad35..eb01487567 100644 --- a/src/autoskillit/execution/backends/_backend_cmd_builder_base.py +++ b/src/autoskillit/execution/backends/_backend_cmd_builder_base.py @@ -267,6 +267,7 @@ def _apply_config(self, config: SkillSessionConfig) -> dict[str, Any]: "native_shell_capture_decision": config.native_shell_capture_decision, "managed_lineage_ref": config.managed_lineage_ref, "managed_attempt_id": config.managed_attempt_id, + "force_inactive_agent_teams": config.force_inactive_agent_teams, } diff --git a/src/autoskillit/execution/backends/_explorer_dispatch.py b/src/autoskillit/execution/backends/_explorer_dispatch.py index f6148d793c..eca6c876d9 100644 --- a/src/autoskillit/execution/backends/_explorer_dispatch.py +++ b/src/autoskillit/execution/backends/_explorer_dispatch.py @@ -28,9 +28,12 @@ "leaves spawn peers.\n" "4. Run only selected, scope-disjoint, dependency-ready tasks concurrently and keep " "dependency chains sequential.\n" - "5. Join every dispatched leaf, preserve conflicts and unresolved frontiers, then merge " - "evidence. Retain final synthesis and every artifact or repository write in the parent " - "session." + "5. For each join-required wave, declare one batch through the same gateway as the join " + "contract, named Agent calls without name/team_name/run_in_background, and release " + "follow-up effects only after every expected direct tool_use_id is settled. Preserve " + "conflicts and unresolved frontiers, then merge evidence. Retain final synthesis and " + "every artifact or repository write in the parent session.\n" + "6. Join every dispatched leaf through the same declared batch gateway before synthesis." ) @@ -83,7 +86,11 @@ def _task_prompt( class _NativeExplorationDispatchRenderer: conventions: ExplorationDispatchConventions - def _native_call(self, definition: AgentDef, prompt: str) -> str: + def _native_call( + self, + definition: AgentDef, + prompt: str, + ) -> str: role = f"{self.conventions.role_prefix}{definition.name}" arguments = [f"{self.conventions.role_argument}={json.dumps(role)}"] if self.conventions.description_argument is not None: @@ -117,20 +124,26 @@ def render( definitions = _canonical_definitions(migrated) replacements: dict[str, str] = {} definition_digests: dict[str, str] = {} - for vector in migrated: + assignment_labels: dict[str, str] = {} + for index, vector in enumerate(migrated): assert vector.role is not None definition = definitions[vector.role] definition_digest = agent_definition_digest(definition) + assignment_label = f"explorer-{vector.task.task_id}" prompt = _task_prompt( vector, router_plan_digest=plan.digest, role_definition_digest=definition_digest, launch_context_ref=launch_context_ref, ) - native_call = self._native_call(definition, prompt) + native_call = self._native_call( + definition, + prompt, + ) task_id = vector.task.task_id replacements[vector.id] = ( f"Candidate exploration task {task_id!r}:\n" + f"Resolved vector assignment label: {assignment_label!r} (index {index}).\n" f"Execute if and only if {task_id!r} is in " "selected_exploration_task_ids:\n" f"{native_call}\n" @@ -138,6 +151,7 @@ def render( "is not a failure." ) definition_digests[vector.id] = definition_digest + assignment_labels[vector.id] = assignment_label context_ref = launch_context_ref or "runtime-bound" provisioning = ( f"\n\n{self.conventions.provisioning_preamble}" @@ -150,7 +164,8 @@ def render( f"profile: {migrated[0].profile.value}\n" f"depends_on: none\n" f"scope: {','.join(migrated[0].task.scope) or 'repository'}\n" - f"launch_context_ref: {context_ref}" + f"launch_context_ref: {context_ref}\n" + f"resolved_assignment_labels: {sorted(assignment_labels.values())}" ) return ExplorationDispatchMaterialization( replacements=replacements, diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index b66a17dd96..08544fbdb8 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -62,10 +62,12 @@ SessionSummary, SkillExecutionRole, SkillSemanticAdaptationResult, + SkillSemanticOperation, SkillSemanticPlan, SkillSessionConfig, ValidatedAddDir, YAMLError, + atomic_write, build_agent_env, claude_code_log_path, claude_code_project_dir, @@ -113,6 +115,242 @@ _ANNOTATION_SUPPORT_MIN = Version(CLAUDE_ANNOTATION_SUPPORT_MIN_VERSION) +#: Documented Claude Code env-var that enables/disables the agent-teams +#: surface. Confirmed via code.claude.com/docs/en/agent-teams as the only +#: public toggle. The repository-scoped force-inactive setting removes or +#: overrides this env var before every Claude launch and any conflicting +#: entry in the target repo's .claude/settings*.json files. +CLAUDE_AGENT_TEAMS_ENV_VAR: str = "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS" + + +def _neutralize_agent_teams_env(env: dict[str, str]) -> None: + """Remove ``CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`` from ``env`` in place.""" + env.pop(CLAUDE_AGENT_TEAMS_ENV_VAR, None) + + +#: Repository-local settings files consulted for env-based team re-enable. +_AGENT_TEAMS_SETTINGS_CANDIDATE_NAMES = (".claude/settings.json", ".claude/settings.local.json") + + +def _agent_teams_settings_candidates(root: Path) -> tuple[Path, ...]: + """Return the absolute candidate settings paths under ``root``.""" + return tuple(root / name for name in _AGENT_TEAMS_SETTINGS_CANDIDATE_NAMES) + + +def detect_repository_agent_teams_setting( + project_root: Path | str | None, +) -> tuple[str | None, str]: + """Return (effective_value, source_path) for any conflicting settings file. + + Per Claude Code's documented settings precedence, ``env.`` entries + in ``.claude/settings.json`` or ``.claude/settings.local.json`` apply + after user-level settings and can re-enable teams even when the + launcher process env has the var unset. + + Returns ``(None, "")`` when no conflicting entry is found. The caller + must combine the launcher-env scan with this file scan and refuse the + launch when neither confirms an inactive effective state. + """ + if project_root is None: + return (None, "") + root = Path(project_root).expanduser().resolve() + for candidate in _agent_teams_settings_candidates(root): + try: + content = candidate.read_text(encoding="utf-8") + except (FileNotFoundError, OSError): + continue + try: + parsed = json.loads(content) + except (ValueError, TypeError): + continue + if not isinstance(parsed, dict): + continue + env = parsed.get("env") + if not isinstance(env, dict): + continue + value = env.get(CLAUDE_AGENT_TEAMS_ENV_VAR) + if isinstance(value, str): + return (value, str(candidate)) + return (None, "") + + +def find_malformed_agent_teams_settings( + project_root: Path | str | None, +) -> list[str]: + """Return paths of settings files that exist but cannot be parsed. + + When ``force_inactive_agent_teams=True`` is requested, a malformed + settings file is a fail-closed condition: Claude Code may still parse + the file permissively and re-enable teams. Returns an empty list when + the project_root is None or no settings files are malformed. + """ + if project_root is None: + return [] + root = Path(project_root).expanduser().resolve() + malformed: list[str] = [] + for candidate in _agent_teams_settings_candidates(root): + try: + content = candidate.read_text(encoding="utf-8") + except FileNotFoundError: + continue + except OSError: + # Unreadable file: treat as malformed for fail-closed purposes. + malformed.append(str(candidate)) + continue + try: + parsed = json.loads(content) + except (ValueError, TypeError): + malformed.append(str(candidate)) + continue + if not isinstance(parsed, dict): + malformed.append(str(candidate)) + continue + env = parsed.get("env") + if env is not None and not isinstance(env, dict): + malformed.append(str(candidate)) + return malformed + + +#: Truthy values that re-enable Claude agent teams if present in the env. +_AGENT_TEAMS_TRUTHY = frozenset({"1", "true", "yes", "on"}) + + +def _active_agent_teams(value: str) -> bool: + """Return True if the string value would re-enable agent teams.""" + return value.strip().lower() in _AGENT_TEAMS_TRUTHY + + +def neutralize_repository_agent_teams_settings(project_root: Path | str | None) -> int: + """Strip conflicting ``env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`` entries. + + Returns the number of settings files modified. Each file is rewritten + after the offending key is removed. Refuses to rewrite when the file + is malformed or unreadable. + """ + if project_root is None: + return 0 + root = Path(project_root).expanduser().resolve() + modified = 0 + for candidate in _agent_teams_settings_candidates(root): + try: + content = candidate.read_text(encoding="utf-8") + except (FileNotFoundError, OSError): + continue + try: + parsed = json.loads(content) + except (ValueError, TypeError): + continue + if not isinstance(parsed, dict): + continue + env = parsed.get("env") + if not isinstance(env, dict): + continue + if CLAUDE_AGENT_TEAMS_ENV_VAR not in env: + continue + del env[CLAUDE_AGENT_TEAMS_ENV_VAR] + try: + new_content = json.dumps(parsed, indent=2, sort_keys=True) + except (ValueError, TypeError): + continue + atomic_write(candidate, new_content) + modified += 1 + return modified + + +def _resolve_project_root_for_inactive_check(project_root: Path | str | None) -> None: + """Refuse a launch when force_inactive is requested without project_root. + + Without ``project_root``, ``assert_agent_teams_inactive`` cannot read + the target repo's ``.claude/settings*.json`` files, so the only path + it can confirm is the resolved launcher env. The plan's Step 5 (3) + requires a positive confirmation of BOTH the env and the settings + files; passing ``None`` is a fail-open bypass. + """ + if project_root is None: + raise RuntimeError( + "force_inactive_agent_teams=True requires project_root so the " + "settings file scan can confirm inactivity" + ) + + +def _interactive_invocation_environment_policy( + env: Mapping[str, str], + project_root: Path | str | None, +) -> list[str]: + """Content-policy errors for an interactive Claude launch. + + The interactive cook/order checkpoint must positively confirm that the + effective environment will leave Claude agent teams inactive. Returns + a list of human-readable error strings (empty list when no violation + is detected). The launch layer surfaces these as pre-spawn failures. + + The policy matches what the per-builder assertions check — the launch + env must not carry a truthy ``CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`` + value, and any conflicting entry in the target repository's + ``.claude/settings*.json`` files would re-enable teams under Claude's + documented settings precedence. + """ + errors: list[str] = [] + env_value = env.get(CLAUDE_AGENT_TEAMS_ENV_VAR) + if isinstance(env_value, str) and _active_agent_teams(env_value): + errors.append( + f"{CLAUDE_AGENT_TEAMS_ENV_VAR}={env_value!r} is set in the launch " + f"environment; Claude agent teams would be active at launch" + ) + malformed = find_malformed_agent_teams_settings(project_root) + if malformed: + errors.append( + f"settings file(s) could not be parsed and may re-enable teams: " + f"{', '.join(malformed)}; repair or remove the malformed file before launching" + ) + file_value, file_path = detect_repository_agent_teams_setting(project_root) + if file_value is not None and _active_agent_teams(file_value): + errors.append( + f"{CLAUDE_AGENT_TEAMS_ENV_VAR}={file_value!r} is set in " + f"{file_path}; Claude agent teams would be re-enabled by " + "repository settings precedence" + ) + return errors + + +def assert_agent_teams_inactive( + env: Mapping[str, str], + project_root: Path | str | None, + *, + force_inactive: bool, +) -> None: + """Verify that the effective environment will result in inactive agent teams. + + Raises ``RuntimeError`` when ``force_inactive`` is True but neither the + process env nor the target repository's settings files positively + confirm an inactive policy. This is the pre-spawn refusal surface. + + A malformed settings file is also a fail-closed condition: Claude Code + may still parse the file permissively and re-enable teams. + """ + if not force_inactive: + return + env_value = env.get(CLAUDE_AGENT_TEAMS_ENV_VAR) + if isinstance(env_value, str) and _active_agent_teams(env_value): + raise RuntimeError( + f"force_inactive_agent_teams requested but {CLAUDE_AGENT_TEAMS_ENV_VAR} " + f"is set to {env_value!r} in the launch env" + ) + malformed = find_malformed_agent_teams_settings(project_root) + if malformed: + raise RuntimeError( + f"force_inactive_agent_teams requested but settings file(s) could not " + f"be parsed and may re-enable teams: {', '.join(malformed)}. " + "Repair or remove the malformed file before launching." + ) + file_value, file_path = detect_repository_agent_teams_setting(project_root) + if file_value is not None and _active_agent_teams(file_value): + raise RuntimeError( + f"force_inactive_agent_teams requested but {CLAUDE_AGENT_TEAMS_ENV_VAR} " + f"is set to {file_value!r} in {file_path}" + ) + + def _claude_host_attestation_env( installed_version: Version | None, ) -> dict[str, str]: @@ -585,12 +823,18 @@ def build_headless_cmd( env_extras: Mapping[str, str] | None = None, base: Mapping[str, str] | None = None, required: frozenset[str] | None = None, + force_inactive_agent_teams: bool = False, + project_root: Path | str | None = None, ) -> CmdSpec: cmd = ["claude", ClaudeFlags.PRINT, prompt, ClaudeFlags.DANGEROUSLY_SKIP_PERMISSIONS] if model: cmd += [ClaudeFlags.MODEL, self.translate_model(model)] env = dict(build_agent_env(base=base, extras=env_extras, required=required)) env.update(_HEADLESS_ENV_HARDENING) + if force_inactive_agent_teams: + _neutralize_agent_teams_env(env) + _resolve_project_root_for_inactive_check(project_root) + assert_agent_teams_inactive(env, project_root, force_inactive=True) return CmdSpec(cmd=tuple(cmd), env=env) def build_interactive_cmd( @@ -607,6 +851,8 @@ def build_interactive_cmd( env_extras: Mapping[str, str] | None = None, required_env: frozenset[str] | None = None, tools: Sequence[str] = (), + force_inactive_agent_teams: bool = False, + project_root: Path | str | None = None, ) -> CmdSpec: """Build a Claude interactive session command. @@ -685,6 +931,31 @@ def build_interactive_cmd( extras=merged, required=required_env, ) + if force_inactive_agent_teams: + if executable is not None: + # The executable binding captures its launch_environment at + # probe time, before any neutralization. Combining the two + # makes the binding stale; callers must resolve a fresh + # executable from the neutralized env instead. + raise ValueError( + "force_inactive_agent_teams=True cannot be combined with " + "executable binding; resolve a fresh executable from the " + "neutralized env first" + ) + # ``build_agent_env`` returns a read-only ``MappingProxyType``; + # neutralize on a single mutable copy and re-derive both the + # assertion and the launch env from it. + neutralized_env = dict(effective_env) + _neutralize_agent_teams_env(neutralized_env) + settings_root = str(project_root) if project_root is not None else None + _resolve_project_root_for_inactive_check(settings_root) + assert_agent_teams_inactive( + neutralized_env, + settings_root, + force_inactive=True, + ) + neutralize_repository_agent_teams_settings(settings_root) + effective_env = neutralized_env if executable is not None and dict(effective_env) != dict(executable.launch_environment): raise ValueError("interactive environment changed after executable binding") partial = builder.build() @@ -709,6 +980,8 @@ def build_resume_cmd( managed_attempt_id: str | None = None, include_scope_discipline: bool = False, skill_session: bool = False, + force_inactive_agent_teams: bool = False, + project_root: Path | str | None = None, ) -> CmdSpec: del ( native_shell_capture_decision, @@ -738,6 +1011,10 @@ def build_resume_cmd( env.update(_HEADLESS_ENV_HARDENING) if skill_session: env.update(_CLAUDE_SKILL_SESSION_HARDENING) + if force_inactive_agent_teams: + _neutralize_agent_teams_env(env) + _resolve_project_root_for_inactive_check(project_root) + assert_agent_teams_inactive(env, project_root, force_inactive=True) return CmdSpec( cmd=tuple(cmd), env=env, @@ -767,6 +1044,8 @@ def build_skill_session_cmd( resume_session_id: str = "", resume_checkpoint: SessionCheckpoint | None = None, resume_message: str | None = None, + force_inactive_agent_teams: bool = False, + project_root: Path | str | None = None, ) -> CmdSpec: if config is not None: cfg = self._apply_config(config) @@ -787,6 +1066,7 @@ def build_skill_session_cmd( resume_checkpoint = cfg["resume_checkpoint"] resume_message = cfg["resume_message"] sandbox_mode = cfg["sandbox_mode"] # noqa: F841 + force_inactive_agent_teams = cfg["force_inactive_agent_teams"] _has_prefix = ( bool(profile_name) @@ -857,6 +1137,8 @@ def build_skill_session_cmd( env_extras=extras, base=filtered_base, required=SKILL_SESSION_REQUIRED_ENV | _CLAUDE_SKILL_SESSION_HARDENING.keys(), + force_inactive_agent_teams=force_inactive_agent_teams, + project_root=cwd, ) cmd: list[str] = [*spec.cmd] if plugin_binding is not None: @@ -898,6 +1180,8 @@ def build_food_truck_cmd( native_shell_capture_decision: NativeShellCaptureDecision | None = None, managed_lineage_ref: ManagedHeadlessSessionLineageRef | None = None, managed_attempt_id: str | None = None, + force_inactive_agent_teams: bool = False, + project_root: Path | str | None = None, ) -> CmdSpec: del ( native_shell_capture_decision, @@ -957,6 +1241,8 @@ def build_food_truck_cmd( env_extras=extras, base=filtered_base, required=ORCHESTRATOR_SESSION_REQUIRED_ENV, + force_inactive_agent_teams=force_inactive_agent_teams, + project_root=project_root, ) cmd: list[str] = [*spec.cmd] @@ -1029,6 +1315,20 @@ def validate_skill_content(self, content: str) -> list[str]: def adapt_skill_semantics(self, plan: SkillSemanticPlan) -> SkillSemanticAdaptationResult: """Adapt portable skill requirements to Claude Code instructions.""" + if ( + plan.join is not None + and plan.join.required + and not self.capabilities.fixed_set_join_capable + ): + return SkillSemanticAdaptationResult( + unsupported_operation=SkillSemanticOperation.REQUIRED_JOIN, + diagnostic=( + "Claude Code cannot support join.required=true: the runtime " + "does not have the declared-batch, claim guard, success/failure " + "settlers, unresolved-follow-up gate, and Stop completion gate " + "all capability-attested. Refuse the skill at admission." + ), + ) role_mapping = {role.name: role.name for role in plan.logical_roles} sibling_targets = { sibling.name: f"/autoskillit:{sibling.name}" for sibling in plan.sibling_skills @@ -1071,7 +1371,13 @@ def adapt_skill_semantics(self, plan: SkillSemanticPlan) -> SkillSemanticAdaptat if plan.concurrency is not None and plan.concurrency.required: fragments.append("Issue all independent child calls in one message so they overlap.") if plan.join is not None and plan.join.required: - fragments.append("Join every spawned child before parent synthesis.") + fragments.append( + "Before this wave, call declare_join_batch with the loaded skill " + "name and one assignment label per direct child. Then issue every " + "member as one ordinary unnamed foreground Agent(subagent_type=...) " + "call in a single message. Retain every direct result. Only after " + "the ledger reports complete do you synthesize or allow Stop." + ) if plan.evidence is not None and plan.evidence.required: boundary = "independent " if plan.evidence.independent else "" fragments.append(f"Require {boundary}evidence from each child result.") @@ -1132,8 +1438,21 @@ def list_plugins(self) -> list[dict[str, Any]]: return [] def validate_interactive_invocation(self, spec: CmdSpec) -> list[str]: - del spec - return [] + """Verify the interactive launch spec's effective environment policy. + + When the spec carries a request to keep Claude agent teams inactive, + this checkpoint positively confirms that neither the resolved env + nor the target repository's ``.claude/settings*.json`` files + re-enable ``CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS``. The plan's + Step 5.4 mandates this content-policy surface here in addition to + the per-builder enforcement. + """ + env = dict(spec.env) if spec is not None else {} + project_root: Path | str | None = None + cwd = spec.cwd if spec is not None else None + if cwd is not None: + project_root = cwd + return _interactive_invocation_environment_policy(env, project_root) def ensure_pre_launch( self, diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index a478594bdb..25e3444223 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -81,6 +81,7 @@ SessionSummary, SkillExecutionRole, SkillSemanticAdaptationResult, + SkillSemanticOperation, SkillSemanticPlan, SkillSessionConfig, ValidatedAddDir, @@ -1509,6 +1510,7 @@ def capabilities(self) -> BackendCapabilities: protected_recipe_delivery_capable=False, recipe_delivery_budget=CODEX_RECIPE_DELIVERY_BUDGET, hook_trust_policy=HookTrustPolicy.REVIEW_EACH_SESSION, + fixed_set_join_capable=False, ) @property @@ -1598,7 +1600,9 @@ def build_headless_cmd( *, model: str | None = None, add_dirs: Sequence[str] = (), + force_inactive_agent_teams: bool = False, # no-op: Codex has no team concept env_extras: Mapping[str, str] | None = None, + project_root: Path | str | None = None, ) -> CmdSpec: cmd = _codex_exec_base(sandbox="workspace-write") if model: @@ -1625,8 +1629,10 @@ def build_skill_session_cmd( plugin_binding: PluginLaunchBinding | None = None, output_format: OutputFormat = OutputFormat.JSON, add_dirs: Sequence[ValidatedAddDir] = (), + force_inactive_agent_teams: bool = False, # no-op: Codex has no team concept exit_after_stop_delay_ms: int = 0, stream_idle_timeout_ms: int = 0, + project_root: Path | str | None = None, scenario_step_name: str = "", temp_dir_relpath: str | None = None, allowed_write_prefix: str = "", @@ -1807,10 +1813,12 @@ def build_food_truck_cmd( temp_dir_relpath: str | None = None, allowed_write_prefix: str = "", allowed_write_prefixes: tuple[str, ...] = (), + force_inactive_agent_teams: bool = False, # no-op: Codex has no team concept sentinel_contract: str = "", resume_message: str | None = None, native_shell_capture_decision: NativeShellCaptureDecision | None = None, managed_lineage_ref: ManagedHeadlessSessionLineageRef | None = None, + project_root: Path | str | None = None, managed_attempt_id: str | None = None, ) -> CmdSpec: projected_codex_home = _codex_home_from_plugin_binding(plugin_binding) @@ -1928,6 +1936,8 @@ def build_interactive_cmd( env_extras: Mapping[str, str] | None = None, required_env: frozenset[str] | None = None, tools: Sequence[str] = (), + force_inactive_agent_teams: bool = False, # no-op: Codex has no team concept + project_root: Path | str | None = None, ) -> CmdSpec: if tools: logger.warning( @@ -2043,6 +2053,8 @@ def build_resume_cmd( managed_attempt_id: str | None = None, include_scope_discipline: bool = False, skill_session: bool = False, + force_inactive_agent_teams: bool = False, # no-op: Codex has no team concept + project_root: Path | str | None = None, ) -> CmdSpec: del skill_session if not resume_session_id.strip(): @@ -2305,16 +2317,26 @@ def validate_skill_content(self, content: str) -> list[str]: def adapt_skill_semantics(self, plan: SkillSemanticPlan) -> SkillSemanticAdaptationResult: """Adapt portable skill requirements to Codex collaboration instructions.""" - role_mapping = { - role.name: ( - role.name.removeprefix("autoskillit:") - if role.name.startswith("autoskillit:") - else "worker" - if role.name == "delegated-worker" - else role.name + if plan.join is not None and plan.join.required: + result = SkillSemanticAdaptationResult( + unsupported_operation=SkillSemanticOperation.REQUIRED_JOIN, + diagnostic=( + "Codex exposes wait-any/mailbox-activity semantics rather than " + "fixed-set fan-in. Skills declaring join.required=true cannot be " + "honestly realized on this backend and must be refused at admission." + ), ) - for role in plan.logical_roles - } + result.validate_for(plan, backend=self.name) + raise AssertionError("unreachable") # validate_for raises unconditionally + role_mapping: dict[str, str] = {} + for role in plan.logical_roles: + if role.name.startswith("autoskillit:"): + native = role.name.removeprefix("autoskillit:") + elif role.name == "delegated-worker": + native = "worker" + else: + native = role.name + role_mapping[role.name] = native sibling_targets = {sibling.name: f"${sibling.name}" for sibling in plan.sibling_skills} model_policy: dict[str, tuple[str, str | None]] = {} fragments = [ @@ -2351,11 +2373,8 @@ def adapt_skill_semantics(self, plan: SkillSemanticPlan) -> SkillSemanticAdaptat ) if plan.concurrency is not None and plan.concurrency.required: fragments.append("Spawn all independent children before awaiting any result.") - if plan.join is not None and plan.join.required: - fragments.append( - "Use wait_agent with the exact returned child IDs; deliver every independent " - "successful child terminal result before parent synthesis." - ) + # NOTE: plan.join.required is refused at admission above via the + # unsupported_operation path; this fragment is unreachable by design. if plan.evidence is not None and plan.evidence.required: boundary = "independent " if plan.evidence.independent else "" fragments.append(f"Require {boundary}evidence from each child result.") diff --git a/src/autoskillit/execution/headless/_headless_helpers.py b/src/autoskillit/execution/headless/_headless_helpers.py index c84f8559c7..c1187ff04e 100644 --- a/src/autoskillit/execution/headless/_headless_helpers.py +++ b/src/autoskillit/execution/headless/_headless_helpers.py @@ -4,6 +4,7 @@ import dataclasses import os +from collections.abc import Mapping from pathlib import Path from typing import TYPE_CHECKING @@ -77,7 +78,25 @@ def assert_interactive_ordering( Always scans the raw cmd tuple — never trusts origin metadata alone, since CmdOrigin is a public dataclass and callers could set it on a misordered cmd. + + The cook/order pre-spawn checkpoint also verifies that the spec's effective + environment will leave Claude agent teams inactive. The plan's Step 5.4 + bundles this content-policy check with the ordering validation so that + every interactive launch surfaces both shape and environment violations + from a single gate. """ + if isinstance(getattr(spec, "env", None), Mapping): + from autoskillit.execution.backends.claude import ( + _interactive_invocation_environment_policy, + ) + + policy_errors = _interactive_invocation_environment_policy( + spec.env, getattr(spec, "cwd", None) + ) + if policy_errors: + raise ValueError( + "interactive launch environment policy violations: " + "; ".join(policy_errors) + ) if value_bearing_flags is None: value_bearing_flags = _ALL_VALUE_BEARING_FLAGS cmd = spec.cmd diff --git a/src/autoskillit/execution/headless/_headless_launch.py b/src/autoskillit/execution/headless/_headless_launch.py index 83287b418d..b3bdfa7702 100644 --- a/src/autoskillit/execution/headless/_headless_launch.py +++ b/src/autoskillit/execution/headless/_headless_launch.py @@ -132,6 +132,7 @@ async def _run_headless_attempt( stream_parser: StreamParser, backend_resume_session_id: str, lifecycle_observation_enabled: bool, + force_inactive_agent_teams: bool = False, on_launch_resolved: Callable[[ResolvedLaunchContract], None] | None = None, managed_lineage_observer: _ManagedLineageObserver | None = None, managed_attempt_id: str | None = None, @@ -158,6 +159,7 @@ async def _run_headless_attempt( provider_extras=provider_extras, observer=managed_lineage_observer, managed_attempt_id=managed_attempt_id, + force_inactive_agent_teams=force_inactive_agent_teams, ) launch_contract = launch_resolver.finalize(attempt_preparation, adapter) if expected_launch_contract is not None: @@ -230,6 +232,7 @@ async def _attempt_contract_nudge( launch_preparation: LaunchPreparation | None = None, expected_launch_contract: ResolvedLaunchContract | None = None, on_launch_resolved: Callable[[ResolvedLaunchContract], None] | None = None, + force_inactive_agent_teams: bool = False, ) -> SkillResult | None: """Resume once to recover omitted structured tokens or the completion marker.""" if backend is None or not backend.capabilities.session_resume_capable: @@ -327,6 +330,8 @@ def build_nudge_spec( ), managed_attempt_id=attempt_id, skill_session=True, + force_inactive_agent_teams=force_inactive_agent_teams, + project_root=cwd, ) plugin_identity = _binding_identity(binding) @@ -351,6 +356,7 @@ def build_nudge_spec( provider_extras=effective_extras or None, observer=managed_lineage_observer, managed_attempt_id=managed_attempt_id, + force_inactive_agent_teams=force_inactive_agent_teams, ) launch_contract = launch_resolver.finalize(nudge_preparation, adapter) if expected_launch_contract is not None: diff --git a/src/autoskillit/execution/headless/_managed/_launch_adapter.py b/src/autoskillit/execution/headless/_managed/_launch_adapter.py index 73bb953dae..616b6c0915 100644 --- a/src/autoskillit/execution/headless/_managed/_launch_adapter.py +++ b/src/autoskillit/execution/headless/_managed/_launch_adapter.py @@ -66,12 +66,14 @@ def __init__( provider_extras: Mapping[str, str] | None, observer: _ManagedLineageObserver | None, managed_attempt_id: str | None, + force_inactive_agent_teams: bool = False, ) -> None: self._build_spec = build_spec self._binding = binding self._provider_extras = provider_extras self._observer = observer self._managed_attempt_id = managed_attempt_id + self._force_inactive_agent_teams = force_inactive_agent_teams self.secret_environment: Mapping[str, str] = {} self.inherited_fds: tuple[int, ...] = () @@ -104,6 +106,7 @@ def build(self, preparation: LaunchPreparation) -> LaunchAdapterResult: "secret_keys": secret_keys, "process_idle_timeout_ms": spec.process_idle_timeout_ms, "inherited_fd_count": len(spec.inherited_fds), + "force_inactive_agent_teams": self._force_inactive_agent_teams, } adapter_digest = hashlib.sha256( json.dumps(adapter_payload, sort_keys=True, separators=(",", ":")).encode() @@ -164,6 +167,7 @@ def _skill_launch_spec_builder( network_access: bool, native_shell_capture_decision: NativeShellCaptureDecision | None, managed_lineage_ref: ManagedHeadlessSessionLineageRef | None, + force_inactive_agent_teams: bool = False, ) -> _BuildSpec: """Bind stable skill-command inputs while leaving attempt identity late-bound.""" @@ -197,8 +201,15 @@ def build( native_shell_capture_decision=native_shell_capture_decision, managed_lineage_ref=managed_lineage_ref, managed_attempt_id=managed_attempt_id, + force_inactive_agent_teams=force_inactive_agent_teams, + ) + return backend.build_skill_session_cmd( + skill_command, + cwd, + config, + force_inactive_agent_teams=force_inactive_agent_teams, + project_root=cwd, ) - return backend.build_skill_session_cmd(skill_command, cwd, config) return build @@ -224,6 +235,7 @@ def _food_truck_launch_spec_builder( resume_message: str | None, native_shell_capture_decision: NativeShellCaptureDecision | None, managed_lineage_ref: ManagedHeadlessSessionLineageRef | None, + force_inactive_agent_teams: bool = False, ) -> _BuildSpec: """Bind food-truck inputs while finalizing semantic capability per binding.""" @@ -266,6 +278,8 @@ def build( native_shell_capture_decision=native_shell_capture_decision, managed_lineage_ref=managed_lineage_ref, managed_attempt_id=managed_attempt_id, + force_inactive_agent_teams=force_inactive_agent_teams, + project_root=attempt_cwd, ) return build diff --git a/src/autoskillit/hook_registry.py b/src/autoskillit/hook_registry.py index e84d875c47..d1abff7e5b 100644 --- a/src/autoskillit/hook_registry.py +++ b/src/autoskillit/hook_registry.py @@ -17,13 +17,23 @@ from autoskillit.core import installed_plugin_cache_dir, pkg_root +# Events that do not require a tool-name matcher pattern (Stop fires once +# per turn; SessionStart fires before any tool call). +_MATCHERLESS_EVENT_TYPES: frozenset[str] = frozenset({"SessionStart", "Stop", "PreToolUse"}) + @dataclass(frozen=True, slots=True) class HookDef: """A single hook group: event type, matcher pattern, and ordered script list.""" matcher: str = "" - event_type: Literal["PreToolUse", "PostToolUse", "SessionStart"] = "PreToolUse" + event_type: Literal[ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "SessionStart", + "Stop", + ] = "PreToolUse" scripts: list[str] = field(default_factory=list) timeout_seconds: int | None = None session_scope: Literal["any", "headless_only", "interactive_only"] = "any" @@ -45,7 +55,7 @@ class HookDef: self_reclaims_resources: frozenset[str] = field(default_factory=frozenset) def __post_init__(self) -> None: - if self.event_type != "SessionStart" and not self.matcher: + if self.event_type not in _MATCHERLESS_EVENT_TYPES and not self.matcher: raise ValueError( f"HookDef with event_type={self.event_type!r} requires a non-empty matcher" ) @@ -348,10 +358,53 @@ def __post_init__(self) -> None: HookDef( matcher=r"Bash|Agent|ScheduleWakeup", scripts=["guards/background_exec_guard.py"], - session_scope="headless_only", + session_scope="any", mechanism="deny", enforcement_strength={"claude_code": "hard", "codex": "works-as-is"}, ), + HookDef( + matcher="Agent", + scripts=["guards/join_claim_guard.py"], + session_scope="any", + codex_status="not-applicable", + mechanism="deny", + enforcement_strength={"claude_code": "hard", "codex": "not-applicable"}, + ), + HookDef( + matcher="", + scripts=["guards/join_followup_guard.py"], + session_scope="any", + codex_status="not-applicable", + mechanism="deny", + enforcement_strength={"claude_code": "hard", "codex": "not-applicable"}, + ), + HookDef( + matcher="Agent", + event_type="PostToolUse", + scripts=["guards/join_settle_guard.py"], + session_scope="any", + codex_status="not-applicable", + mechanism="side-effect", + enforcement_strength={"claude_code": "hard", "codex": "not-applicable"}, + ), + HookDef( + matcher="Agent", + event_type="PostToolUseFailure", + scripts=["guards/join_settle_guard.py"], + session_scope="any", + codex_status="not-applicable", + mechanism="side-effect", + enforcement_strength={"claude_code": "hard", "codex": "not-applicable"}, + ), + HookDef( + matcher="", + event_type="Stop", + scripts=["guards/join_stop_guard.py"], + session_scope="any", + codex_status="not-applicable", + mechanism="deny", + enforcement_strength={"claude_code": "hard", "codex": "not-applicable"}, + ), HookDef( matcher=r"(mcp__.*autoskillit.*__)?dispatch_food_truck", scripts=[ @@ -540,6 +593,10 @@ def __post_init__(self) -> None: "github_mutation_guard.py", "fabricated_completion_guard.py", "exploration_request_identity_guard.py", + "join_claim_guard.py", # NEW (#4575, #4520) + "join_settle_guard.py", # NEW (#4575, #4520) + "join_stop_guard.py", # NEW (#4575, #4520) + "join_followup_guard.py", # NEW (#4575, #4520) } ) @@ -803,11 +860,17 @@ def compute_registry_hash( def _build_hook_entry(hook_def: HookDef, hook_commands: list[dict]) -> dict: """Build the per-entry dict for a hook definition. - SessionStart entries omit the 'matcher' key; all others include it. + Always-matcherless events (``SessionStart``, ``Stop``) omit the + ``matcher`` key entirely — Claude Code's documented matcherless event + schema has no matcher field. ``PreToolUse`` is treated as matcherless + only when its ``matcher`` is empty (the matcherless PreToolUse entries + added with REQ-JOIN-005). All other events include ``matcher``. This is the single authoritative formatter for both hooks.json and settings.json generation. """ - if hook_def.event_type == "SessionStart": + if hook_def.event_type in {"SessionStart", "Stop"}: + return {"hooks": hook_commands} + if hook_def.event_type == "PreToolUse" and not hook_def.matcher: return {"hooks": hook_commands} return {"matcher": hook_def.matcher, "hooks": hook_commands} diff --git a/src/autoskillit/hooks/_hook_settings.py b/src/autoskillit/hooks/_hook_settings.py index bf4b2bfffc..e3b0c62095 100644 --- a/src/autoskillit/hooks/_hook_settings.py +++ b/src/autoskillit/hooks/_hook_settings.py @@ -425,3 +425,92 @@ def write_quota_log_event(event: dict, log_dir: Path | None, *, caller: str = "" except Exception as exc: if caller: print(f"{caller}: failed to write quota log event: {exc}", file=sys.stderr) + + +#: Bounded set of allowed join-diagnostic record keys. Anything else is +#: stripped before write so child bodies, prompts, secrets, and private +#: task IDs never land in the diagnostic sink. +DIAGNOSTIC_KEYS: frozenset[str] = frozenset( + { + "ts", + "session_id", + "top_level_parent", + "join_batch_id", + "assignment", + "tool_use_id", + "skill_name", + "semantic_digest", + "adaptation_digest", + "artifact_digest", + "artifact_incarnation", + "selector_presence", + "activation_source", + "launch_policy_state", + "status", + "public_child_id", + "team_name", + "execution_mode", + "wave_outcome", + "gate", + "binding_valid", + } +) + + +def write_join_diagnostic(record: dict, *, caller: str = "") -> None: + """Append one bounded join-gate diagnostic record to ``join_diagnostics.jsonl``. + + The record is redacted to ``DIAGNOSTIC_KEYS`` before write. + Child bodies, prompts, secrets, and private task IDs are never persisted. + No-ops when the resolved log dir is None. + """ + bounded = {key: value for key, value in record.items() if key in DIAGNOSTIC_KEYS} + bounded.setdefault("ts", datetime.now(UTC).isoformat()) + log_dir = resolve_quota_log_dir(caller=caller or "join_diagnostic") + if log_dir is None: + return + try: + log_dir.mkdir(parents=True, exist_ok=True) + line = json.dumps(bounded, sort_keys=True) + "\n" + with open(log_dir / "join_diagnostics.jsonl", "a", encoding="utf-8") as f: + f.write(line) + except Exception as exc: + if caller: + print(f"{caller}: failed to write join diagnostic: {exc}", file=sys.stderr) + + +def read_session_binding() -> dict[str, object] | None: + """Read the ``AUTOSKILLIT_JOIN_FLAG_PATH`` flag file as JSON. + + Returns the parsed dict on success, or ``None`` when the path is unset, + the file is unreadable, the JSON is malformed, or the top-level value + is not a dict. OSError on read is treated as 'no binding present' so + callers can fall through to the env-mirror ``AUTOSKILLIT_JOIN_REQUIRED``. + """ + flag_path = os.environ.get("AUTOSKILLIT_JOIN_FLAG_PATH", "").strip() + if not flag_path: + return None + try: + with open(flag_path, encoding="utf-8") as handle: + raw = handle.read() + except OSError: + return None + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return None + return parsed if isinstance(parsed, dict) else None + + +def session_join_required() -> bool: + """True when the session flag reports ``join_required=true`` or the + ``AUTOSKILLIT_JOIN_REQUIRED=1`` env mirror is set. + + The flag is consulted first; the env mirror is the documented fallback + for hooks that lose access to the binding file (e.g. when the + filesystem error is transient). Either signal alone is sufficient. + """ + binding = read_session_binding() + if binding is not None and bool(binding.get("join_required", False)): + return True + return os.environ.get("AUTOSKILLIT_JOIN_REQUIRED") == "1" diff --git a/src/autoskillit/hooks/_join_ledger.py b/src/autoskillit/hooks/_join_ledger.py new file mode 100644 index 0000000000..733622389d --- /dev/null +++ b/src/autoskillit/hooks/_join_ledger.py @@ -0,0 +1,493 @@ +"""Shared cross-process join ledger for declared-batch fixed-set fan-in. + +Used by the ``declare_join_batch`` MCP tool and the join-aware hook +scripts (``guards/join_claim_guard.py``, ``guards/join_settle_guard.py``, +``guards/join_stop_guard.py``). All callers MUST go through this helper; +hook scripts may not import ``autoskillit.*`` because they run in the +harness's stdlib-only environment. + +Persistence: + * Atomic cross-process locking via ``fcntl.flock`` on a sibling lock file. + * Atomic replacement via ``os.replace`` from a same-directory tempfile. + * JSON envelope, one record per session+top-level-parent+batch tuple. + +Failure mode: every read/write/claim/settle that cannot proceed safely +returns a structured failure. A corrupted or unreadable ledger is NEVER +treated as "no active wave" — that would let children run unobserved. +""" + +from __future__ import annotations + +import contextlib +import fcntl +import json +import os +import secrets +import string +import tempfile +import time +from collections.abc import Generator, Iterable +from pathlib import Path +from typing import Any + +LEDGER_FILENAME = "join_ledger.json" +LOCK_FILENAME = "join_ledger.lock" + +#: Terminal outcomes for a claimed direct handle. +OUTCOME_PENDING = "pending" +OUTCOME_SUCCESS = "success" +OUTCOME_FAILURE = "failure" +OUTCOME_TIMEOUT = "timeout" +OUTCOME_CANCELLED = "cancelled" +OUTCOME_INTERRUPTION = "interruption" +OUTCOME_MISSING = "missing" + +#: Aggregate wave outcomes (set when all handles settle). +WAVE_PENDING = "pending" +WAVE_COMPLETE = "complete" +WAVE_PARTIAL_TIMEOUT = "partial_timeout" +WAVE_FAILURE = "failure" +WAVE_CANCELLED = "cancelled" +WAVE_INTERRUPTION = "interruption" +WAVE_MISSING_CHILD = "missing_child" +#: Some entries succeeded and some settled non-success in a terminal way +#: that is not captured by any single priority outcome above (e.g. some +#: ``success`` and some ``missing``). The wave did not fully complete, so +#: downstream consumers must treat this as non-success. +WAVE_PARTIAL = "partial" + +_NON_SUCCESS_WAVE_OUTCOMES: frozenset[str] = frozenset( + { + WAVE_PARTIAL_TIMEOUT, + WAVE_FAILURE, + WAVE_CANCELLED, + WAVE_INTERRUPTION, + WAVE_MISSING_CHILD, + WAVE_PARTIAL, + } +) + +_BATCH_ID_ALPHABET = string.ascii_lowercase + string.digits + + +def _new_batch_id() -> str: + """Return a fresh opaque batch id distinct from ``AdmissionBatchId``.""" + return "".join(secrets.choice(_BATCH_ID_ALPHABET) for _ in range(24)) + + +def ledger_paths(flag_dir: Path) -> tuple[Path, Path]: + """Return (ledger_path, lock_path) inside the flag directory.""" + return (flag_dir / LEDGER_FILENAME, flag_dir / LOCK_FILENAME) + + +def resolve_flag_dir(project_root: Path) -> Path: + """Return ``/.autoskillit/temp`` — the canonical join flag dir. + + The four join guards and the ``declare_join_batch`` tool all consult the + same on-disk ledger; this helper is the single source of truth for the + flag directory layout so a future rename only requires one edit. + """ + return project_root / ".autoskillit" / "temp" + + +@contextlib.contextmanager +def _flock(lock_path: Path) -> Generator[int, None, None]: + """Acquire an exclusive ``fcntl.flock`` on ``lock_path`` for this process. + + Each hook process must open the lock file independently rather than + inherit an fd — a lock is released only when every duplicate fd to it + closes. Raises ``OSError`` on contention or filesystem failure. + """ + lock_path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR | os.O_CLOEXEC, 0o644) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + yield fd + finally: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + except OSError: + pass + os.close(fd) + + +def _read_locked(ledger_path: Path) -> dict[str, Any]: + try: + raw = ledger_path.read_text(encoding="utf-8") + except FileNotFoundError: + return {"schema_version": 1, "sessions": {}} + except OSError: + raise + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, ValueError) as exc: + raise _CorruptedLedger(f"join ledger is not valid JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise _CorruptedLedger("join ledger top level must be an object") + sessions = parsed.get("sessions") + if not isinstance(sessions, dict): + raise _CorruptedLedger("join ledger sessions must be an object") + parsed.setdefault("schema_version", 1) + return parsed + + +def _atomic_write_locked(ledger_path: Path, payload: dict[str, Any]) -> None: + """Write the ledger content via an atomic tempfile + ``os.replace``.""" + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + tmp_fd, tmp_path = tempfile.mkstemp( + prefix=".join_ledger.", suffix=".tmp", dir=str(ledger_path.parent) + ) + try: + with os.fdopen(tmp_fd, "wb") as f: + f.write(encoded) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, ledger_path) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +class _CorruptedLedger(Exception): + """Raised when the on-disk ledger cannot be parsed safely.""" + + +class JoinLedgerError(Exception): + """Base class for join-ledger failures visible to MCP/hook callers.""" + + +def declare_batch( + flag_dir: Path, + *, + session_id: str, + top_level_parent: str, + skill_name: str, + artifact_digest: str, + assignments: Iterable[str], + now: float | None = None, +) -> dict[str, Any]: + """Open one parent/wave ledger with the given resolved assignments. + + Returns the new batch record (including its ``join_batch_id``). Refuses + when another wave is already open for the same session+parent. + """ + if not session_id or not top_level_parent: + raise JoinLedgerError("declare_batch requires session_id and top_level_parent") + if not skill_name: + raise JoinLedgerError("declare_batch requires skill_name") + if not artifact_digest: + raise JoinLedgerError("declare_batch requires artifact_digest") + labels = tuple(assignments) + if not labels: + raise JoinLedgerError( + "declare_batch requires a non-empty ordered set of assignment labels" + ) + if len(set(labels)) != len(labels): + raise JoinLedgerError("declare_batch assignment labels must be unique") + + ledger_path, lock_path = ledger_paths(flag_dir) + ts = now if now is not None else time.time() + with _flock(lock_path): + payload = _read_locked(ledger_path) + sessions = payload["sessions"] + session_record = sessions.get(session_id) + if not isinstance(session_record, dict): + session_record = {"top_level_parents": {}} + sessions[session_id] = session_record + parents = session_record.get("top_level_parents") + if not isinstance(parents, dict): + parents = {} + session_record["top_level_parents"] = parents + parent_record = parents.get(top_level_parent) + if isinstance(parent_record, dict): + active = parent_record.get("active_batch") + if isinstance(active, dict): + # Only refuse when the prior wave is still pending (not + # terminal). A complete/failed/etc wave is replaced by the + # new declaration. + if active.get("wave_outcome", WAVE_PENDING) == WAVE_PENDING: + raise JoinLedgerError( + f"another wave is already open for {session_id!r}/{top_level_parent!r}: " + f"join_batch_id={active.get('join_batch_id')!r}" + ) + join_batch_id = _new_batch_id() + batch_record: dict[str, Any] = { + "join_batch_id": join_batch_id, + "skill_name": skill_name, + "artifact_digest": artifact_digest, + "session_id": session_id, + "top_level_parent": top_level_parent, + "assignments": [ + {"label": label, "tool_use_id": None, "outcome": OUTCOME_PENDING, "ts": ts} + for label in labels + ], + "opened_at": ts, + "wave_outcome": WAVE_PENDING, + "settled_at": None, + } + parents[top_level_parent] = {"active_batch": batch_record} + _atomic_write_locked(ledger_path, payload) + return batch_record + + +def claim_assignment( + flag_dir: Path, + *, + session_id: str, + top_level_parent: str, + tool_use_id: str, + agent_id: str | None = None, +) -> dict[str, Any] | None: + """Atomically claim the next unclaimed assignment for the active wave. + + Returns the claimed assignment record, or ``None`` when the parent has + no open wave (the agent_id-bearing call is exempt; the caller should + not invoke us from inside a claimed child's subagent context). Refuses + to claim a duplicate tool_use_id or when all assignments are taken. + """ + if not session_id or not top_level_parent or not tool_use_id: + raise JoinLedgerError( + "claim_assignment requires session_id, top_level_parent, tool_use_id" + ) + if agent_id: + # Caller mistake — exempt join re-evaluation inside a child's context. + return None + + ledger_path, lock_path = ledger_paths(flag_dir) + try: + with _flock(lock_path): + payload = _read_locked(ledger_path) + sessions = payload["sessions"] + session_record = sessions.get(session_id) + if not isinstance(session_record, dict): + return None + parents = session_record.get("top_level_parents", {}) + parent_record = parents.get(top_level_parent) if isinstance(parents, dict) else None + if not isinstance(parent_record, dict): + return None + batch = parent_record.get("active_batch") + if not isinstance(batch, dict): + return None + assignments = batch.get("assignments") + if not isinstance(assignments, list): + return None + # Detect duplicate claims before taking a new slot. A + # duplicate tool_use_id is invalid regardless of whether the + # prior entry is still pending or already settled — emitting + # the same id twice would corrupt downstream join bookkeeping. + for entry in assignments: + if isinstance(entry, dict) and entry.get("tool_use_id") == tool_use_id: + raise JoinLedgerError( + f"tool_use_id {tool_use_id!r} already claimed for this wave" + ) + for entry in assignments: + if isinstance(entry, dict) and entry.get("tool_use_id") is None: + entry["tool_use_id"] = tool_use_id + entry["outcome"] = OUTCOME_PENDING + entry["ts"] = time.time() + _atomic_write_locked(ledger_path, payload) + return entry + raise JoinLedgerError( + f"no unclaimed assignment available for tool_use_id {tool_use_id!r}" + ) + except _CorruptedLedger as exc: + raise JoinLedgerError(f"join ledger is unreadable: {exc}") from exc + except OSError as exc: + raise JoinLedgerError(f"join ledger IO error during claim: {exc}") from exc + + +def settle_assignment( + flag_dir: Path, + *, + session_id: str, + top_level_parent: str, + tool_use_id: str, + outcome: str, + now: float | None = None, +) -> dict[str, Any]: + """Mark a claimed handle terminal and compute the aggregate wave outcome. + + Identical duplicate (tool_use_id, outcome) events are idempotent. + Conflicting terminal events for the same handle fail closed. + """ + if outcome not in ( + OUTCOME_SUCCESS, + OUTCOME_FAILURE, + OUTCOME_TIMEOUT, + OUTCOME_CANCELLED, + OUTCOME_INTERRUPTION, + OUTCOME_MISSING, + ): + raise JoinLedgerError(f"invalid outcome {outcome!r}") + ledger_path, lock_path = ledger_paths(flag_dir) + ts = now if now is not None else time.time() + try: + with _flock(lock_path): + payload = _read_locked(ledger_path) + sessions = payload["sessions"] + session_record = sessions.get(session_id) + if not isinstance(session_record, dict): + raise JoinLedgerError(f"no session record for {session_id!r}") + parents = session_record.get("top_level_parents", {}) + parent_record = parents.get(top_level_parent) if isinstance(parents, dict) else None + if not isinstance(parent_record, dict): + raise JoinLedgerError(f"no parent record for {top_level_parent!r}") + batch = parent_record.get("active_batch") + if not isinstance(batch, dict): + raise JoinLedgerError("no active wave to settle") + assignments = batch.get("assignments") + if not isinstance(assignments, list): + raise JoinLedgerError("wave assignments malformed") + target: dict[str, Any] | None = None + for entry in assignments: + if isinstance(entry, dict) and entry.get("tool_use_id") == tool_use_id: + target = entry + break + if target is None: + raise JoinLedgerError(f"tool_use_id {tool_use_id!r} was not claimed by this wave") + existing_outcome = target.get("outcome") + if existing_outcome == outcome: + # Idempotent identical duplicate — accept without rewriting. + return batch + if existing_outcome != OUTCOME_PENDING and existing_outcome != outcome: + raise JoinLedgerError( + f"conflicting terminal outcome for {tool_use_id!r}: " + f"existing={existing_outcome!r}, new={outcome!r}" + ) + target["outcome"] = outcome + target["ts"] = ts + + # Compute the aggregate wave outcome. + aggregate = _aggregate_wave_outcome(assignments) + if aggregate != WAVE_PENDING: + batch["wave_outcome"] = aggregate + batch["settled_at"] = ts + _atomic_write_locked(ledger_path, payload) + except _CorruptedLedger as exc: + raise JoinLedgerError(f"join ledger is unreadable: {exc}") from exc + except OSError as exc: + raise JoinLedgerError(f"join ledger IO error during settle: {exc}") from exc + return batch + + +def _aggregate_wave_outcome(assignments: list[object]) -> str: + """Return the deterministic aggregate outcome for the wave.""" + if not assignments: + return WAVE_MISSING_CHILD + outcomes: list[str] = [] + for entry in assignments: + if isinstance(entry, dict): + outcomes.append(str(entry.get("outcome", OUTCOME_PENDING))) + if any(o == OUTCOME_PENDING for o in outcomes): + return WAVE_PENDING + if all(o == OUTCOME_SUCCESS for o in outcomes): + return WAVE_COMPLETE + if any(o == OUTCOME_INTERRUPTION for o in outcomes): + return WAVE_INTERRUPTION + if any(o == OUTCOME_CANCELLED for o in outcomes): + return WAVE_CANCELLED + if any(o == OUTCOME_TIMEOUT for o in outcomes): + return WAVE_PARTIAL_TIMEOUT + if any(o == OUTCOME_FAILURE for o in outcomes): + return WAVE_FAILURE + if all(o == OUTCOME_MISSING for o in outcomes): + return WAVE_MISSING_CHILD + # All entries are in a terminal state, but the outcomes are mixed in + # a way no priority rule above captures (e.g. some SUCCESS alongside + # MISSING or FAILURE). The wave did not fully complete; report partial. + return WAVE_PARTIAL + + +def active_batch( + flag_dir: Path, + *, + session_id: str, + top_level_parent: str, +) -> dict[str, Any] | None: + """Return the active batch record or ``None`` when none is open. + + Returns a failure envelope ``{"_corrupted": True, ...}`` when the + ledger cannot be parsed — callers MUST treat this as fail-closed. + """ + ledger_path, lock_path = ledger_paths(flag_dir) + try: + with _flock(lock_path): + payload = _read_locked(ledger_path) + except _CorruptedLedger as exc: + return {"_corrupted": True, "error": str(exc)} + except OSError as exc: + return {"_corrupted": True, "error": str(exc)} + sessions = payload.get("sessions", {}) + session_record = sessions.get(session_id) if isinstance(sessions, dict) else None + if not isinstance(session_record, dict): + return None + parents = session_record.get("top_level_parents", {}) + parent_record = parents.get(top_level_parent) if isinstance(parents, dict) else None + if not isinstance(parent_record, dict): + return None + batch = parent_record.get("active_batch") + if not isinstance(batch, dict): + return None + return batch + + +def can_release_stop( + flag_dir: Path, + *, + session_id: str, + top_level_parent: str, + session_binding: dict[str, Any] | None, +) -> tuple[bool, str]: + """Return ``(allow_stop, reason)`` for the Stop completion gate. + + When the session binding has no join-bearing skill loaded, Stop is + unconditionally allowed (no-op guard). Otherwise the active batch — + if any — must be ``complete`` to release the success path; partial, + failed, cancelled, interrupted, or missing waves block Stop with + a deterministic reason. + """ + binding_required = bool(session_binding and session_binding.get("join_required")) + if not binding_required: + return (True, "no join-bearing skill loaded in this session") + batch = active_batch(flag_dir, session_id=session_id, top_level_parent=top_level_parent) + if batch is None: + return (False, "join-bearing skill loaded but no declared wave for this parent") + if batch.get("_corrupted"): + return (False, f"join ledger is unreadable: {batch.get('error')}") + wave_outcome = batch.get("wave_outcome", WAVE_PENDING) + if wave_outcome == WAVE_COMPLETE: + return (True, "active wave is complete") + if wave_outcome in _NON_SUCCESS_WAVE_OUTCOMES: + return (False, f"active wave settled non-success: {wave_outcome}") + return (False, f"active wave is unresolved: {wave_outcome}") + + +__all__ = [ + "JoinLedgerError", + "LEDGER_FILENAME", + "LOCK_FILENAME", + "OUTCOME_PENDING", + "OUTCOME_SUCCESS", + "OUTCOME_FAILURE", + "OUTCOME_TIMEOUT", + "OUTCOME_CANCELLED", + "OUTCOME_INTERRUPTION", + "OUTCOME_MISSING", + "WAVE_PENDING", + "WAVE_COMPLETE", + "WAVE_PARTIAL_TIMEOUT", + "WAVE_FAILURE", + "WAVE_CANCELLED", + "WAVE_INTERRUPTION", + "WAVE_MISSING_CHILD", + "WAVE_PARTIAL", + "active_batch", + "can_release_stop", + "claim_assignment", + "declare_batch", + "ledger_paths", + "settle_assignment", +] diff --git a/src/autoskillit/hooks/formatters/pretty_output_hook.py b/src/autoskillit/hooks/formatters/pretty_output_hook.py index c71313997f..9aa3790794 100644 --- a/src/autoskillit/hooks/formatters/pretty_output_hook.py +++ b/src/autoskillit/hooks/formatters/pretty_output_hook.py @@ -202,6 +202,7 @@ def _response_spill_notice(metadata: dict) -> str: "get_exploration_page", # bounded evidence page JSON, generic renders correctly "resume_exploration_context", # bounded evidence page JSON, generic renders correctly "delegate_evidence_reader", # compact JSON delegation envelope (#4585) + "declare_join_batch", # structured success/refusal result, generic renders correctly "read_authorized_artifact", "get_authorized_artifact_page", } diff --git a/src/autoskillit/hooks/guards/background_exec_guard.py b/src/autoskillit/hooks/guards/background_exec_guard.py index 745b1b7ad3..35ee12faa9 100644 --- a/src/autoskillit/hooks/guards/background_exec_guard.py +++ b/src/autoskillit/hooks/guards/background_exec_guard.py @@ -1,17 +1,63 @@ #!/usr/bin/env python3 -"""PreToolUse hook — blocks run_in_background=true in skill sessions (ADR-0001). +"""PreToolUse hook — blocks run_in_background=true in skill sessions (ADR-0001) +and enforces Claude required-join dispatch boundaries when the session has +loaded a join-bearing skill (REQ-JOIN-005, REQ-BACK-005). Background execution causes race conditions and lost results. Skill sessions must use foreground execution only. Multiple foreground tool calls in a single message execute concurrently without this risk. + +Join-bound sessions additionally reject: + * ``name`` or ``team_name`` selectors (which spawn teammates when agent + teams are active — confirmed via code.claude.com/docs/en/agent-teams); + * ``run_in_background=true`` (the original ADR-0001 prohibition); + * ``ScheduleWakeup`` (deferral/stall escape hatch). +The guard reads the session flag as JSON; ``join_required=true`` activates the +join-bound deny set. When the binding flag is configured but unreadable +or malformed, the guard defaults to non-join semantics rather than promoting +to ``join_required=true`` — the launch policy and active session binding are +authoritative, and a transient file-system error during hook invocation must +not lock the agent out of legitimate work. """ +from __future__ import annotations + import json import os import sys +_HOOKS_DIR = str(__file__).rsplit("/", 1)[0].rsplit("/", 1)[0] +if _HOOKS_DIR not in sys.path: + sys.path.insert(0, _HOOKS_DIR) + +from _hook_settings import ( # type: ignore[import-not-found] # noqa: E402 + session_join_required, +) + BACKGROUND_EXEC_DENY_TRIGGER: str = "run_in_background=true is prohibited in skill sessions" SCHEDULE_WAKEUP_DENY_TRIGGER: str = "ScheduleWakeup is prohibited in skill sessions" +JOIN_DENY_TRIGGER: str = ( + "required-join session forbids named/teammate dispatch — declare a wave " + "via declare_join_batch and use unnamed foreground Agent(...) calls" +) + + +def _governed_skill_session() -> bool: + """Whether this hook is acting in a governed Claude skill session. + + Active for Claude-code sessions outside the orchestrator/fleet tiers, so + orchestrator, fleet, and Codex sessions are excluded from governance. + """ + backend = os.environ.get("AUTOSKILLIT_AGENT_BACKEND", "").strip() + if backend == "codex": + return False + if backend not in ("", "claude-code"): + return False + raw_session_type = os.environ.get("AUTOSKILLIT_SESSION_TYPE", "") + session_type = raw_session_type.lower() + if session_type in ("orchestrator", "fleet"): + return False + return True def main() -> None: @@ -20,28 +66,89 @@ def main() -> None: except (json.JSONDecodeError, ValueError, OSError): sys.exit(0) # fail-open on malformed input - # Interactive sessions always pass - if os.environ.get("AUTOSKILLIT_HEADLESS") != "1": - sys.exit(0) + in_subagent_context = bool(data.get("agent_id")) - # Headless: resolve session type, fail-closed to skill session raw_session_type = os.environ.get("AUTOSKILLIT_SESSION_TYPE", "") session_type = raw_session_type.lower() if session_type in ("orchestrator", "fleet"): sys.exit(0) # permitted tiers - _unrecognized_tier = bool(session_type) and session_type != "skill" + + is_governed = _governed_skill_session() + headless = os.environ.get("AUTOSKILLIT_HEADLESS") == "1" tool_input = data.get("tool_input") if not isinstance(tool_input, dict): sys.exit(0) # fail-open: missing or malformed tool_input tool_name = data.get("tool_name") - deny_trigger = ( - SCHEDULE_WAKEUP_DENY_TRIGGER - if tool_name == "ScheduleWakeup" - else BACKGROUND_EXEC_DENY_TRIGGER - ) + + # --- Join-bound session enforcement (Claude, all session types) --- + # Inside a claimed child's own subagent context, exempt join re-evaluation: + # blocking them would self-lock every join. + join_required = is_governed and not in_subagent_context and session_join_required() + + if join_required and tool_name == "Agent": + selector = [] + if tool_input.get("name"): + selector.append("name") + if tool_input.get("team_name"): + selector.append("team_name") + if tool_input.get("run_in_background"): + selector.append("run_in_background") + if selector: + denial_reason = ( + f"{JOIN_DENY_TRIGGER} (selectors rejected: {', '.join(selector)}; " + "background execution and teammate routing are prohibited in a " + "join-bound session — declare a wave via declare_join_batch and " + "issue every member as one ordinary unnamed foreground Agent call)." + ) + payload = json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": denial_reason, + } + } + ) + sys.stdout.write(payload + "\n") + sys.exit(0) + + # --- Join-bound ScheduleWakeup rejection (independent of headless state) --- + # Deferral/stall is an escape hatch that could let a wave close with an + # empty child set. Reject ScheduleWakeup whenever the session reports a + # join-bearing skill load, even in interactive Claude sessions that have + # not entered the headless tier. + if is_governed and join_required and tool_name == "ScheduleWakeup": + denial_reason = ( + f"{SCHEDULE_WAKEUP_DENY_TRIGGER} (ADR-0001) — ScheduleWakeup is " + "prohibited in a join-bound session because deferral cannot " + "produce the declared-batch evidence the join contract requires." + ) + payload = json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": denial_reason, + } + } + ) + sys.stdout.write(payload + "\n") + sys.exit(0) + + if not headless: + # Interactive non-governed sessions fall through after the join check. + sys.exit(0) + + # --- ADR-0001 background/SessionWakeup gate (headless only) --- if tool_name == "ScheduleWakeup" or tool_input.get("run_in_background"): + deny_trigger = ( + SCHEDULE_WAKEUP_DENY_TRIGGER + if tool_name == "ScheduleWakeup" + else BACKGROUND_EXEC_DENY_TRIGGER + ) + _unrecognized_tier = bool(session_type) and session_type != "skill" denial_reason = ( f"{deny_trigger} (ADR-0001). " "Background execution causes race conditions and lost results. " diff --git a/src/autoskillit/hooks/guards/join_claim_guard.py b/src/autoskillit/hooks/guards/join_claim_guard.py new file mode 100644 index 0000000000..604fdb441f --- /dev/null +++ b/src/autoskillit/hooks/guards/join_claim_guard.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""PreToolUse guard — atomically claim one declared assignment per direct Agent call. + +When the session flag (or ``AUTOSKILLIT_JOIN_REQUIRED=1``) reports +``join_required=true``, every top-level direct ``Agent`` tool_use_id must +claim one slot in the active wave declared by ``declare_join_batch``. +The claim is recorded in the shared join ledger (``_join_ledger.py``). + +Denial reasons: + * no active wave for this (session_id, top_level_parent) pair, + * duplicate claim for the same tool_use_id, + * no unclaimed assignment available, + * tool call carries ``name``, ``team_name``, or ``run_in_background`` + (delegated to ``background_exec_guard`` for the join selectors), + * tool input lacks a usable ``tool_use_id`` (we cannot correlate). + +Nested descendants (``agent_id`` present in the payload) are exempt — +the agent_id belongs to a claimed child's own context. Tool calls made +inside a claimed child's subagent context are exempt from this check; +blocking them would self-lock every join. + +Stdlib-only — no autoskillit imports. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +_HOOKS_DIR = str(Path(__file__).resolve().parent.parent) +if _HOOKS_DIR not in sys.path: + sys.path.insert(0, _HOOKS_DIR) + +from _hook_settings import ( # type: ignore[import-not-found] # noqa: E402 + session_join_required, + write_join_diagnostic, +) +from _hook_utils import find_project_root # type: ignore[import-not-found] # noqa: E402 +from _join_ledger import ( # type: ignore[import-not-found] # noqa: E402 + JoinLedgerError, + claim_assignment, + resolve_flag_dir, +) + +JOIN_CLAIM_DENY_TRIGGER: str = ( + "required-join session requires a declared batch with an unclaimed assignment" +) + + +def _resolve_session_id(data: dict[str, object]) -> str: + sid = data.get("session_id", "") + return sid if isinstance(sid, str) else "" + + +def main() -> None: + try: + data = json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError, OSError): + sys.exit(0) + + if not isinstance(data, dict): + sys.exit(0) + if data.get("agent_id"): + # Inside a claimed child's own subagent context — exempt. + sys.exit(0) + + if not session_join_required(): + sys.exit(0) + + tool_name = data.get("tool_name") + if tool_name != "Agent": + sys.exit(0) + + tool_input = data.get("tool_input") + if not isinstance(tool_input, dict): + sys.exit(0) + + tool_use_id = data.get("tool_use_id") or tool_input.get("id") or "" + if not isinstance(tool_use_id, str) or not tool_use_id: + denial_reason = ( + f"{JOIN_CLAIM_DENY_TRIGGER}: Agent tool_use_id was not provided by the harness." + ) + payload = json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": denial_reason, + } + } + ) + sys.stdout.write(payload + "\n") + sys.exit(0) + + flag_dir = resolve_flag_dir(find_project_root()) + session_id = _resolve_session_id(data) + top_level_parent = "top_level" + if not session_id: + # join_required=true is established; missing session_id is a + # fail-closed condition — we cannot attribute this Agent call + # to a declared batch, so deny rather than silently pass. + write_join_diagnostic( + { + "gate": "join_claim_guard", + "tool_use_id": tool_use_id, + "status": "deny", + "denial_reason": "missing_session_id", + }, + caller="join_claim_guard", + ) + denial_reason = ( + f"{JOIN_CLAIM_DENY_TRIGGER}: session_id was not provided by the harness " + "for an Agent call in a join-required session." + ) + payload = json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": denial_reason, + } + } + ) + sys.stdout.write(payload + "\n") + sys.exit(0) + + try: + claimed = claim_assignment( + flag_dir, + session_id=session_id, + top_level_parent=top_level_parent, + tool_use_id=tool_use_id, + ) + except (JoinLedgerError, OSError) as exc: + write_join_diagnostic( + { + "gate": "join_claim_guard", + "session_id": session_id, + "top_level_parent": top_level_parent, + "tool_use_id": tool_use_id, + "status": "deny", + "selector_presence": ["missing_or_invalid_tool_use_id"], + }, + caller="join_claim_guard", + ) + denial_reason = f"{JOIN_CLAIM_DENY_TRIGGER}: {exc}" + payload = json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": denial_reason, + } + } + ) + sys.stdout.write(payload + "\n") + # Exit 0 with a structured deny payload — Claude Code treats the + # tool call as DENY (non-zero would be treated as non-blocking). + # The exception was already translated by the ledger into a + # JoinLedgerError when possible; OSError here means the ledger + # could not confirm state, and the safe default is to deny. + sys.exit(0) + + if claimed is None: + write_join_diagnostic( + { + "gate": "join_claim_guard", + "session_id": session_id, + "top_level_parent": top_level_parent, + "tool_use_id": tool_use_id, + "status": "deny_no_open_wave", + }, + caller="join_claim_guard", + ) + denial_reason = ( + f"{JOIN_CLAIM_DENY_TRIGGER}: no declared batch is open for this turn. " + "Call declare_join_batch with one assignment label per direct child first." + ) + payload = json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": denial_reason, + } + } + ) + sys.stdout.write(payload + "\n") + sys.exit(0) + + write_join_diagnostic( + { + "gate": "join_claim_guard", + "session_id": session_id, + "top_level_parent": top_level_parent, + "tool_use_id": tool_use_id, + "join_batch_id": claimed.get("join_batch_id", ""), + "assignment": claimed.get("label", ""), + "status": "claim", + }, + caller="join_claim_guard", + ) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/autoskillit/hooks/guards/join_followup_guard.py b/src/autoskillit/hooks/guards/join_followup_guard.py new file mode 100644 index 0000000000..a01f3efeb2 --- /dev/null +++ b/src/autoskillit/hooks/guards/join_followup_guard.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""PreToolUse guard — deny non-Agent follow-up effects while a wave is unresolved. + +When the session flag (or ``AUTOSKILLIT_JOIN_REQUIRED=1``) reports +``join_required=true``, a top-level parent turn (no ``agent_id`` in the +hook payload) may not issue any non-Agent tool call before every +expected direct ``Agent`` handle has settled. The natural tool calls +inside a claimed child's own subagent context (``agent_id`` present) are +exempt — blocking them would self-lock every join. + +The guard is matcherless so it runs on every PreToolUse event regardless +of tool name. Exit code 2 prevents Claude from proceeding and continues +the conversation per the Claude Code hooks contract. + +Stdlib-only — no autoskillit imports. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +_HOOKS_DIR = str(Path(__file__).resolve().parent.parent) +if _HOOKS_DIR not in sys.path: + sys.path.insert(0, _HOOKS_DIR) + +from _hook_settings import ( # type: ignore[import-not-found] # noqa: E402 + session_join_required, + write_join_diagnostic, +) +from _hook_utils import find_project_root # type: ignore[import-not-found] # noqa: E402 +from _join_ledger import ( # type: ignore[import-not-found] # noqa: E402 + active_batch, + resolve_flag_dir, +) + +JOIN_FOLLOWUP_DENY_TRIGGER: str = ( + "required-join wave is unresolved: top-level parent may not invoke non-Agent " + "follow-up effects before every declared Agent handle settles" +) + + +def _resolve_session_id(data: dict[str, object]) -> str: + sid = data.get("session_id", "") + return sid if isinstance(sid, str) else "" + + +def _is_unresolved(batch: dict[str, object]) -> bool: + """Return True when the wave is active but not yet ``complete``.""" + if batch.get("_corrupted"): + return True + wave_outcome = batch.get("wave_outcome", "pending") + return wave_outcome != "complete" + + +def _denial_reason(tool_name: str) -> str: + return ( + f"required-join wave is unresolved: top-level parent may not invoke " + f"{tool_name!r} before every declared Agent handle settles. " + "Wait for the JoinLedger wave_outcome to reach 'complete' before " + "issuing side-effecting follow-up tools." + ) + + +def main() -> None: + try: + data = json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError, OSError): + sys.exit(0) + + if not isinstance(data, dict): + sys.exit(0) + if data.get("agent_id"): + sys.exit(0) + + if not session_join_required(): + sys.exit(0) + + tool_name = data.get("tool_name") + if not isinstance(tool_name, str) or tool_name == "Agent": + sys.exit(0) + + session_id = _resolve_session_id(data) + if not session_id: + # join_required=true is established; missing session_id is a + # fail-closed condition — we cannot attribute this follow-up tool + # to a declared batch, so deny rather than silently pass. + write_join_diagnostic( + { + "gate": "join_followup_guard", + "tool_use_id": data.get("tool_use_id", "") if isinstance(data, dict) else "", + "status": "block", + "denial_reason": "missing_session_id", + }, + caller="join_followup_guard", + ) + sys.stdout.write( + json.dumps( + { + "decision": "block", + "reason": ( + "required-join wave is unresolved: top-level parent may not invoke " + f"{tool_name!r} before every declared Agent handle settles " + "(session_id missing — cannot attribute to declared batch)." + ), + } + ) + + "\n" + ) + sys.exit(2) + + top_level_parent = "top_level" + flag_dir = resolve_flag_dir(find_project_root()) + batch = active_batch( + flag_dir, + session_id=session_id, + top_level_parent=top_level_parent, + ) + + if batch is None or not _is_unresolved(batch): + sys.exit(0) + + write_join_diagnostic( + { + "gate": "join_followup_guard", + "session_id": session_id, + "top_level_parent": top_level_parent, + "tool_use_id": data.get("tool_use_id", "") if isinstance(data, dict) else "", + "wave_outcome": batch.get("wave_outcome", ""), + "status": "block", + "selector_presence": [tool_name], + }, + caller="join_followup_guard", + ) + sys.stdout.write(json.dumps({"decision": "block", "reason": _denial_reason(tool_name)}) + "\n") + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/src/autoskillit/hooks/guards/join_settle_guard.py b/src/autoskillit/hooks/guards/join_settle_guard.py new file mode 100644 index 0000000000..a239fcac6c --- /dev/null +++ b/src/autoskillit/hooks/guards/join_settle_guard.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""PostToolUse / PostToolUseFailure settlement — record claimed handle outcomes. + +When ``join_required=true`` in the session flag (or env mirror), every +claimed direct ``Agent`` handle must be settled with one of: + + * ``success`` — substantive public result evidence received; + * ``failure`` — explicit terminal failure; + * ``timeout`` — declared timeout exceeded; + * ``cancelled`` — user cancellation; + * ``interruption`` — user interrupt; + * ``missing`` — no terminal evidence at all. + +Identical duplicate (tool_use_id, outcome) events are idempotent. +Conflicting terminal events for the same handle fail closed. + +Empty / non-substantive results are mapped to ``missing`` and never +silently converted to success. + +Stdlib-only — no autoskillit imports. +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path + +_HOOKS_DIR = str(Path(__file__).resolve().parent.parent) +if _HOOKS_DIR not in sys.path: + sys.path.insert(0, _HOOKS_DIR) + +from _hook_settings import ( # type: ignore[import-not-found] # noqa: E402 + session_join_required, + write_join_diagnostic, +) +from _hook_utils import find_project_root # type: ignore[import-not-found] # noqa: E402 +from _join_ledger import ( # type: ignore[import-not-found] # noqa: E402 + OUTCOME_CANCELLED, + OUTCOME_FAILURE, + OUTCOME_INTERRUPTION, + OUTCOME_MISSING, + OUTCOME_SUCCESS, + OUTCOME_TIMEOUT, + JoinLedgerError, + resolve_flag_dir, + settle_assignment, +) + + +def _resolve_outcome(event_type: str, payload: dict[str, object]) -> str | None: + """Map an upstream event to the canonical outcome, or None to skip.""" + if event_type == "PostToolUse": + tool_input = payload.get("tool_input") + if not isinstance(tool_input, dict): + return OUTCOME_MISSING + if payload.get("is_error") is True or payload.get("error") is True: + return OUTCOME_FAILURE + tool_response = payload.get("tool_response") + if not tool_response: + return OUTCOME_MISSING + return OUTCOME_SUCCESS + if event_type == "PostToolUseFailure": + reason = payload.get("reason") or payload.get("error") + text = str(reason).casefold() if isinstance(reason, str) else "" + if "timeout" in text: + return OUTCOME_TIMEOUT + if "cancel" in text: + return OUTCOME_CANCELLED + if "interrupt" in text: + return OUTCOME_INTERRUPTION + return OUTCOME_FAILURE + return None + + +def main() -> None: + event_type = os.environ.get("AUTOSKILLIT_HOOK_EVENT", "").strip() + try: + data = json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError, OSError): + sys.exit(0) + + if not isinstance(data, dict): + sys.exit(0) + # Subagent contexts are exempt: the claimed child owns its own + # settlement surface; re-evaluating the gate here would self-lock + # every join. Mirrors the agent_id exemption in claim/followup guards. + if data.get("agent_id"): + sys.exit(0) + + if not session_join_required(): + sys.exit(0) + + tool_name = data.get("tool_name") + if tool_name != "Agent": + sys.exit(0) + + outcome = _resolve_outcome(event_type, data) + if outcome is None: + sys.exit(0) + + tool_use_id = data.get("tool_use_id") or "" + if not isinstance(tool_use_id, str) or not tool_use_id: + sys.exit(0) + + sid = data.get("session_id", "") + if not isinstance(sid, str) or not sid: + # join_required=true is established; missing session_id means + # we cannot record the settlement. Emit a structured diagnostic + # so the missing record is observable instead of silent. + write_join_diagnostic( + { + "gate": "join_settle_guard", + "tool_use_id": tool_use_id, + "outcome": outcome, + "status": "settle_skipped", + "denial_reason": "missing_session_id", + }, + caller="join_settle_guard", + ) + sys.exit(0) + + flag_dir = resolve_flag_dir(find_project_root()) + top_level_parent = "top_level" + batch = None + # Retry transient OSError up to 3 attempts with brief backoff. The + # ledger acquires an exclusive fcntl.flock; contention surfaces as + # OSError and is normally resolved on a follow-up attempt. A contract + # error (JoinLedgerError) is NOT retried — the ledger is the authority + # for wave state and retrying would only re-surface the same refusal. + last_exc: Exception | None = None + for attempt in range(3): + try: + batch = settle_assignment( + flag_dir, + session_id=sid, + top_level_parent=top_level_parent, + tool_use_id=tool_use_id, + outcome=outcome, + ) + last_exc = None + break + except JoinLedgerError as exc: + last_exc = exc + break + except OSError as exc: + last_exc = exc + if attempt < 2: + time.sleep(0.05 * (attempt + 1)) + continue + if batch is None: + write_join_diagnostic( + { + "gate": "join_settle_guard", + "session_id": sid, + "tool_use_id": tool_use_id, + "status": "settle_refused", + "selector_presence": [outcome], + "denial_reason": "ledger_io_or_contract_error", + }, + caller="join_settle_guard", + ) + sys.stderr.write(f"join_settle_guard: settlement refused: {last_exc}\n") + # Fail closed: after retries the ledger write still failed. PostToolUse + # exit 2 does NOT replay (per Claude Code hooks contract), so the + # wave remains pending. The diagnostic record makes the failure + # observable to operators via join_diagnostics.jsonl. + sys.exit(2) + + write_join_diagnostic( + { + "gate": "join_settle_guard", + "session_id": sid, + "tool_use_id": tool_use_id, + "join_batch_id": batch.get("join_batch_id", ""), + "wave_outcome": batch.get("wave_outcome", ""), + "status": outcome, + }, + caller="join_settle_guard", + ) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/autoskillit/hooks/guards/join_stop_guard.py b/src/autoskillit/hooks/guards/join_stop_guard.py new file mode 100644 index 0000000000..d51631a9fd --- /dev/null +++ b/src/autoskillit/hooks/guards/join_stop_guard.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Stop completion gate — block success/Stop until the active wave is complete. + +When the session flag (or ``AUTOSKILLIT_JOIN_REQUIRED=1``) reports +``join_required=true``, the Stop event may only release Claude when the +ledger shows a fully-complete wave. Partial, failed, cancelled, +interrupted, missing, or unresolved waves block Stop with a +deterministic reason so the existing AutoSkillit success/completion marker +cannot be emitted prematurely. + +In a clean session (no join-bearing skill loaded) this guard is a no-op. + +``Stop`` is the correct gate surface — per official documentation it +fires once per turn and exit code 2 prevents Claude from stopping while +continuing the conversation. This blocks premature completion between +waves as well as at the end of the whole conversation. + +Stdlib-only — no autoskillit imports. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +_HOOKS_DIR = str(Path(__file__).resolve().parent.parent) +if _HOOKS_DIR not in sys.path: + sys.path.insert(0, _HOOKS_DIR) + +from _hook_settings import ( # type: ignore[import-not-found] # noqa: E402 + read_session_binding, + write_join_diagnostic, +) +from _hook_utils import find_project_root # type: ignore[import-not-found] # noqa: E402 +from _join_ledger import ( # type: ignore[import-not-found] # noqa: E402 + can_release_stop, + resolve_flag_dir, +) + + +def main() -> None: + try: + sys.stdin.read() # Stop hook payload is informational; we read & discard. + except OSError: + pass + + binding = read_session_binding() + if not binding or not binding.get("join_required"): + sys.exit(0) + + sid = os.environ.get("AUTOSKILLIT_SESSION_ID", "").strip() + if not sid: + # Mirror the same env key the claim/settle guards read. + sid = os.environ.get("AUTOSKILLIT_JOIN_SESSION_ID", "").strip() + if not sid: + # join_required=true is established; missing session_id is a + # fail-closed condition — we cannot verify wave completion, so + # block Stop rather than silently release. + write_join_diagnostic( + { + "gate": "join_stop_guard", + "status": "block", + "denial_reason": "missing_session_id", + }, + caller="join_stop_guard", + ) + sys.stdout.write( + json.dumps( + { + "decision": "block", + "reason": ( + "required-join session has no session_id; cannot verify " + "wave completion before Stop. Set AUTOSKILLIT_SESSION_ID " + "or AUTOSKILLIT_JOIN_SESSION_ID." + ), + } + ) + + "\n" + ) + sys.exit(2) + + top_level_parent = os.environ.get("AUTOSKILLIT_JOIN_PARENT", "top_level").strip() + flag_dir = resolve_flag_dir(find_project_root()) + allow_stop, reason = can_release_stop( + flag_dir, + session_id=sid, + top_level_parent=top_level_parent, + session_binding=binding, + ) + write_join_diagnostic( + { + "gate": "join_stop_guard", + "session_id": sid, + "top_level_parent": top_level_parent, + "status": "allow" if allow_stop else "block", + "binding_valid": bool(binding.get("binding_valid", True)), + "wave_outcome": reason, + }, + caller="join_stop_guard", + ) + if allow_stop: + sys.exit(0) + + # Per Claude docs, exit code 2 prevents Claude from stopping and + # continues the conversation. We use stdout to communicate the reason + # to the harness. + sys.stdout.write( + json.dumps( + { + "decision": "block", + "reason": reason, + } + ) + + "\n" + ) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/src/autoskillit/hooks/guards/skill_load_guard.py b/src/autoskillit/hooks/guards/skill_load_guard.py index 1f57045738..7da9b21201 100644 --- a/src/autoskillit/hooks/guards/skill_load_guard.py +++ b/src/autoskillit/hooks/guards/skill_load_guard.py @@ -82,6 +82,28 @@ def _atomic_write_flag(path: Path, content: str) -> None: raise +def _write_auto_exempt(path: Path) -> None: + """Mark a session as auto-exempted after repeated denials. + + Composes with an existing JSON binding by setting the ``auto_exempt`` + boolean rather than overwriting the file. If the flag is unreadable as + JSON, it is replaced with a minimal exempt-only envelope so the guard + fails open for that session. + """ + try: + existing_raw = path.read_text(encoding="utf-8") + existing = json.loads(existing_raw) + except (FileNotFoundError, OSError, json.JSONDecodeError, ValueError): + existing = None + if isinstance(existing, dict): + merged = dict(existing) + merged["auto_exempt"] = True + payload = json.dumps(merged, sort_keys=True) + else: + payload = json.dumps({"schema_version": 1, "auto_exempt": True}, sort_keys=True) + _atomic_write_flag(path, payload) + + _DENY_MESSAGE: str = ( "SKILL LOADING REQUIRED. You MUST call the Skill tool to load the skill " "instructions before using any other tools. Call ToolSearch with query " @@ -126,11 +148,19 @@ def main() -> None: temp_dir = project_root / ".autoskillit" / "temp" flag_path = temp_dir / f"skill_guard_{session_id}.flag" if flag_path.exists(): - sys.exit(0) + # The guard only needs to know that Skill was loaded; treat any + # non-empty file as evidence. The companion join enforcement reads + # the flag as JSON. + try: + content = flag_path.read_text(encoding="utf-8") + except OSError: + content = "" + if content.strip(): + sys.exit(0) if _check_deny_count(temp_dir, session_id): try: - _atomic_write_flag(flag_path, "__auto_exempt__") + _write_auto_exempt(flag_path) sys.stderr.write( f"skill_load_guard: auto-exempted session {session_id} after " f"{DENY_THRESHOLD} denials (possible deadlock)\n" diff --git a/src/autoskillit/hooks/registry.sha256 b/src/autoskillit/hooks/registry.sha256 index 5da45de403..3335d3ee0f 100644 --- a/src/autoskillit/hooks/registry.sha256 +++ b/src/autoskillit/hooks/registry.sha256 @@ -1 +1 @@ -411ff7aefa84bbee03daf37f22df63a9cb2f2c26b95d8eb351c3136cb6395eb8 +c4f96b42c9783a169ebf0772bfe88f0b2817852b722744c45dc2908426909141 diff --git a/src/autoskillit/hooks/skill_load_post_hook.py b/src/autoskillit/hooks/skill_load_post_hook.py index c06b20972f..bde4019bb5 100644 --- a/src/autoskillit/hooks/skill_load_post_hook.py +++ b/src/autoskillit/hooks/skill_load_post_hook.py @@ -46,6 +46,141 @@ def _atomic_write(path: Path, content: str) -> None: raise +def _read_existing_flag(path: Path) -> dict[str, object] | None: + """Return the existing flag content as a parsed JSON dict, or None if absent/invalid.""" + try: + raw = path.read_text(encoding="utf-8") + except (FileNotFoundError, OSError): + return None + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(parsed, dict): + return None + return parsed + + +def _merge_existing_entry( + existing: dict[str, object], + new_entry: dict[str, object], +) -> dict[str, object]: + """OR-accumulate the join bit across loaded skills and append the new entry. + + Loaded-skill entries are immutable; the ``join_required`` boolean in the + flag is the OR of every loaded skill's ``join_required`` value. A later + join-false load does NOT downgrade an established required-join binding. + """ + loaded_obj = existing.get("loaded_skills", []) + loaded: list[dict[str, object]] = list(loaded_obj) if isinstance(loaded_obj, list) else [] + loaded.append(new_entry) + existing_join = bool(existing.get("join_required", False)) + new_join = bool(new_entry.get("join_required", False)) + result = dict(existing) + result["loaded_skills"] = loaded + result["join_required"] = existing_join or new_join + return result + + +def _resolve_manifest_path(project_root: Path) -> Path | None: + """Locate the projection manifest sidecar for the active plugin install. + + Resolution order: + 1. ``AUTOSKILLIT_PROJECTION_MANIFEST_PATH`` env var (explicit override). + 2. Sibling ``.{plugin_dir}.autoskillit-projection.json`` files under the + project's ``.claude/plugins/installed/`` and ``.claude/`` trees, plus + the project root itself. + """ + explicit = os.environ.get("AUTOSKILLIT_PROJECTION_MANIFEST_PATH", "").strip() + if explicit: + candidate = Path(explicit) + if candidate.exists(): + return candidate + candidates: list[Path] = [] + for base in ( + project_root / ".claude", + project_root / ".autoskillit" / "plugins", + ): + if not base.exists(): + continue + for installed in base.rglob("*"): + if not installed.is_dir(): + continue + manifest = installed.parent / f".{installed.name}.autoskillit-projection.json" + if manifest.exists(): + candidates.append(manifest) + manifest = base / f".{base.name}.autoskillit-projection.json" + if manifest.exists(): + candidates.append(manifest) + return candidates[0] if candidates else None + + +def _read_manifest_entry( + manifest_path: Path, + skill_name: str, +) -> dict[str, object] | None: + """Return the manifest entry for ``skill_name`` or ``None`` when absent/invalid.""" + try: + raw = manifest_path.read_text(encoding="utf-8") + except (FileNotFoundError, OSError): + return None + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(parsed, dict): + return None + skills = parsed.get("skills") + if not isinstance(skills, dict): + return None + entry = skills.get(skill_name) + return entry if isinstance(entry, dict) else None + + +def _build_entry_from_manifest( + skill_name: str, + manifest_entry: dict[str, object] | None, + ts: str, +) -> dict[str, object]: + """Build one loaded-skill entry from the projection manifest. + + When the manifest entry is present, every documented field is sourced + from it verbatim. When absent, all semantic/identity fields are forced + to ``join_required: true`` and the binding is marked invalid so + downstream guards fail closed. + """ + if manifest_entry is None: + return { + "skill_name": skill_name, + "ts": ts, + "join_required": True, + "child_spawn_cardinality": {}, + "semantic_digest": "", + "adaptation_digest": "", + "artifact_digest": "", + "artifact_incarnation": "", + "binding_valid": False, + "binding_error": "manifest entry not found", + } + cardinality_raw = manifest_entry.get("child_spawn_cardinality", {}) + cardinality: dict[str, object] = ( + dict(cardinality_raw) if isinstance(cardinality_raw, dict) else {} + ) + return { + "skill_name": skill_name, + "ts": ts, + "join_required": bool(manifest_entry.get("join_required", False)), + "child_spawn_cardinality": cardinality, + "semantic_digest": str(manifest_entry.get("semantic_digest", "")), + "adaptation_digest": str(manifest_entry.get("adaptation_digest", "")), + "projected_digest": str(manifest_entry.get("projected_digest", "")), + "canonical_digest": str(manifest_entry.get("canonical_digest", "")), + "artifact_digest": str(manifest_entry.get("artifact_digest", "")), + "artifact_incarnation": str(manifest_entry.get("artifact_incarnation", "")), + "binding_valid": True, + } + + def main() -> None: try: data = json.loads(sys.stdin.read()) @@ -82,9 +217,32 @@ def main() -> None: if not session_id: sys.exit(0) - flag_path = find_project_root() / ".autoskillit" / "temp" / f"skill_guard_{session_id}.flag" + project_root = find_project_root() + flag_path = project_root / ".autoskillit" / "temp" / f"skill_guard_{session_id}.flag" + + existing = _read_existing_flag(flag_path) + + ts = datetime.now(UTC).isoformat() + manifest_path = _resolve_manifest_path(project_root) + if manifest_path is not None: + manifest_entry = _read_manifest_entry(manifest_path, skill_name) + else: + manifest_entry = None + new_entry = _build_entry_from_manifest(skill_name, manifest_entry, ts) + + merged = ( + _merge_existing_entry(existing or {}, new_entry) + if existing + else { + "schema_version": 1, + "session_id": session_id, + "join_required": bool(new_entry.get("join_required", False)), + "binding_valid": bool(new_entry.get("binding_valid", False)), + "loaded_skills": [new_entry], + } + ) try: - _atomic_write(flag_path, skill_name) + _atomic_write(flag_path, json.dumps(merged, sort_keys=True)) except Exception as exc: sys.stderr.write(f"skill_load_post_hook: failed to write flag {flag_path}: {exc}\n") diff --git a/src/autoskillit/server/tools/tools_kitchen.py b/src/autoskillit/server/tools/tools_kitchen.py index 938b34ae85..30e8d0da86 100644 --- a/src/autoskillit/server/tools/tools_kitchen.py +++ b/src/autoskillit/server/tools/tools_kitchen.py @@ -58,11 +58,20 @@ try_retire_tracker, unregister_active_kitchen, ) + +# REQ-ARCH-001: the declare_join_batch tool handler must be importable from +# server/tools without circular imports. The hooks layer is intentionally +# package-flat (no package-level __init__ re-exporting the join ledger or +# diagnostic writer), so these symbols are pulled into the module top-level +# rather than re-deferred inside the handler. +from autoskillit.execution import get_backend from autoskillit.fleet import ( FleetSemaphore, discover_campaign_state_files, reap_stale_dispatches_async, ) +from autoskillit.hooks._hook_settings import write_join_diagnostic +from autoskillit.hooks._join_ledger import JoinLedgerError, declare_batch, resolve_flag_dir from autoskillit.pipeline import ( KITCHEN_EFFECT_RECIPE_SERVING, KITCHEN_EFFECT_RESPONSE_ENFORCEMENT, @@ -2138,6 +2147,188 @@ async def reload_session() -> str: return json.dumps({"status": "error", "error": f"{type(exc).__name__}: {exc}"}) +def _declare_join_batch_handler( + skill_name: str, + assignments: list[str], + session_id: str, + project_root: Path, + top_level_parent: str | None = None, +) -> dict[str, object]: + """Core logic for the declare_join_batch tool — testable without FastMCP.""" + # Imports are hoisted to module level (see top of file). + + flag_dir = resolve_flag_dir(project_root) + flag_dir.mkdir(parents=True, exist_ok=True) + flag_path = flag_dir / f"skill_guard_{session_id}.flag" + binding: dict[str, object] = {} + if flag_path.exists(): + try: + binding = json.loads(flag_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, ValueError, OSError): + binding = {} + if not isinstance(binding, dict): + binding = {} + + # Fail-closed validation: a join-bearing session binding, a loaded skill + # entry, and the backend's fixed-set-join capability must all line up + # before we open a wave. + if not bool(binding.get("join_required", False)): + return { + "success": False, + "error": "declare_join_batch requires a join-bearing session binding", + } + loaded = binding.get("loaded_skills", []) + if not isinstance(loaded, list) or not any( + isinstance(entry, dict) and entry.get("skill_name") == skill_name for entry in loaded + ): + return { + "success": False, + "error": f"declare_join_batch: skill {skill_name!r} is not loaded in this session", + } + backend_name = ( + os.environ.get("AUTOSKILLIT_AGENT_BACKEND", "claude-code").strip() or "claude-code" + ) + backend = None + try: + backend = get_backend(backend_name) + except (ImportError, AttributeError, ValueError, RuntimeError, OSError): + logger.warning("declare_join_batch_backend_lookup_failed", exc_info=True) + backend = None + if backend is None or not getattr(backend.capabilities, "fixed_set_join_capable", False): + return { + "success": False, + "error": ( + f"declare_join_batch: backend {backend_name!r} does not attest " + "fixed_set_join_capable" + ), + } + # Check backend supports the requested assignments against the manifest's + # declared child_spawn_cardinality. + manifest_cardinality: dict[str, object] = {} + for entry in loaded: + if isinstance(entry, dict) and entry.get("skill_name") == skill_name: + card = entry.get("child_spawn_cardinality", {}) + if isinstance(card, dict): + manifest_cardinality = card + break + # Sum declared per-role counts. Any string entry (for_each) makes the + # total indeterminate, so we skip the strict check in that case. + declared_count: int | None = None + has_static_count = all(isinstance(v, int) for v in manifest_cardinality.values()) + if has_static_count and manifest_cardinality: + declared_count = sum(manifest_cardinality.values()) # type: ignore[arg-type] + if declared_count is not None: + if len(assignments) != declared_count: + return { + "success": False, + "error": ( + f"declare_join_batch: skill {skill_name!r} declares " + f"count={declared_count}; received {len(assignments)} assignments" + ), + } + + artifact_digest = str(binding.get("artifact_digest", "")) or _derive_artifact_digest(binding) + parent = top_level_parent or "top_level" + try: + batch = declare_batch( + flag_dir, + session_id=session_id, + top_level_parent=parent, + skill_name=skill_name, + artifact_digest=artifact_digest, + assignments=assignments, + ) + except JoinLedgerError as exc: + _emit_join_diagnostic( + { + "gate": "declare_join_batch", + "session_id": session_id, + "top_level_parent": parent, + "skill_name": skill_name, + "status": "declare_refused", + } + ) + return {"success": False, "error": str(exc)} + _emit_join_diagnostic( + { + "gate": "declare_join_batch", + "session_id": session_id, + "top_level_parent": parent, + "join_batch_id": batch.get("join_batch_id", ""), + "skill_name": skill_name, + "status": "declared", + } + ) + return {"success": True, "join_batch_id": batch.get("join_batch_id"), "wave": batch} + + +def _emit_join_diagnostic(record: dict[str, object]) -> None: + """Bounded MCP-side diagnostic emission. Falls back to stderr on failure. + + ``write_join_diagnostic`` already redacts to ``DIAGNOSTIC_KEYS``; the + caller passes the raw record and lets the canonical filter run. + """ + try: + write_join_diagnostic(record, caller="declare_join_batch") + except (ImportError, AttributeError, ValueError, RuntimeError, OSError) as exc: + logger.warning( + "declare_join_batch_diagnostic_emission_failed", + exc_info=True, + error=str(exc), + ) + + +def _derive_artifact_digest(binding: dict[str, object]) -> str: + """Reconstruct the artifact digest for the most recent loaded skill.""" + loaded = binding.get("loaded_skills", []) + if isinstance(loaded, list) and loaded: + last = loaded[-1] + if isinstance(last, dict): + candidate = last.get("artifact_digest") + if isinstance(candidate, str) and candidate: + return candidate + return "" + + +@mcp.tool( + tags={"autoskillit"}, + annotations={"readOnlyHint": False}, + meta={"anthropic/alwaysLoad": False}, +) +@_cancellation_shield() +@track_response_size("declare_join_batch") +async def declare_join_batch( + skill_name: str, + assignments: list[str], + session_id: str, + top_level_parent: str | None = None, + ctx: Context = CurrentContext(), +) -> str: + """Open one declared batch ledger for the next wave of direct children. + + Validates that the loaded skill, the session flag binding, and the + artifact identity are all consistent. Returns the new ``join_batch_id`` + on success; a structured refusal on conflict. + + Never raises. + """ + try: + from autoskillit.server import _get_ctx # circular-break + + tool_ctx = _get_ctx() + result = _declare_join_batch_handler( + skill_name=skill_name, + assignments=assignments, + session_id=session_id, + project_root=tool_ctx.project_dir, + top_level_parent=top_level_parent, + ) + except Exception as exc: + logger.error("declare_join_batch unhandled exception", exc_info=True) + return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) + return json.dumps(result, sort_keys=True) + + def _register_active_recipe_kitchen(ctx: ToolContext) -> None: """Publish one kitchen to both process and recipe-generation lifecycles.""" from autoskillit.server._recipe_generation import activate_kitchen # circular-break diff --git a/src/autoskillit/skills_extended/arch-lens-c4-container/SKILL.md b/src/autoskillit/skills_extended/arch-lens-c4-container/SKILL.md index b6db4088e9..6973ecd2c0 100644 --- a/src/autoskillit/skills_extended/arch-lens-c4-container/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-c4-container/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # C4 Container Architecture Lens diff --git a/src/autoskillit/skills_extended/arch-lens-concurrency/SKILL.md b/src/autoskillit/skills_extended/arch-lens-concurrency/SKILL.md index a3092e2f76..4fd20b2163 100644 --- a/src/autoskillit/skills_extended/arch-lens-concurrency/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-concurrency/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Concurrency Architecture Lens diff --git a/src/autoskillit/skills_extended/arch-lens-data-lineage/SKILL.md b/src/autoskillit/skills_extended/arch-lens-data-lineage/SKILL.md index fe37c52706..9c679a2508 100644 --- a/src/autoskillit/skills_extended/arch-lens-data-lineage/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-data-lineage/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Data Lineage Architecture Lens diff --git a/src/autoskillit/skills_extended/arch-lens-deployment/SKILL.md b/src/autoskillit/skills_extended/arch-lens-deployment/SKILL.md index e65801f8f3..a1c3c461eb 100644 --- a/src/autoskillit/skills_extended/arch-lens-deployment/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-deployment/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Deployment/Physical Architecture Lens diff --git a/src/autoskillit/skills_extended/arch-lens-development/SKILL.md b/src/autoskillit/skills_extended/arch-lens-development/SKILL.md index 47ef072714..961a67493f 100644 --- a/src/autoskillit/skills_extended/arch-lens-development/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-development/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Development Architecture Lens diff --git a/src/autoskillit/skills_extended/arch-lens-error-resilience/SKILL.md b/src/autoskillit/skills_extended/arch-lens-error-resilience/SKILL.md index f4d568bb0a..2584f00551 100644 --- a/src/autoskillit/skills_extended/arch-lens-error-resilience/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-error-resilience/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Error/Resilience Architecture Lens diff --git a/src/autoskillit/skills_extended/arch-lens-module-dependency/SKILL.md b/src/autoskillit/skills_extended/arch-lens-module-dependency/SKILL.md index 2ab6f492bc..d37d98c0f8 100644 --- a/src/autoskillit/skills_extended/arch-lens-module-dependency/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-module-dependency/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Module Dependency Architecture Lens diff --git a/src/autoskillit/skills_extended/arch-lens-operational/SKILL.md b/src/autoskillit/skills_extended/arch-lens-operational/SKILL.md index 83d8a9e2df..87e21d57cd 100644 --- a/src/autoskillit/skills_extended/arch-lens-operational/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-operational/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Operational Architecture Lens diff --git a/src/autoskillit/skills_extended/arch-lens-process-flow/SKILL.md b/src/autoskillit/skills_extended/arch-lens-process-flow/SKILL.md index a94513ca83..afb69ce1df 100644 --- a/src/autoskillit/skills_extended/arch-lens-process-flow/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-process-flow/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Process Flow Architecture Lens diff --git a/src/autoskillit/skills_extended/arch-lens-repository-access/SKILL.md b/src/autoskillit/skills_extended/arch-lens-repository-access/SKILL.md index 49a4e9a453..b92e482d96 100644 --- a/src/autoskillit/skills_extended/arch-lens-repository-access/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-repository-access/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Repository/Data Access Architecture Lens diff --git a/src/autoskillit/skills_extended/arch-lens-scenarios/SKILL.md b/src/autoskillit/skills_extended/arch-lens-scenarios/SKILL.md index 2ef9ddf23a..e6e93bfc9d 100644 --- a/src/autoskillit/skills_extended/arch-lens-scenarios/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-scenarios/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Scenarios Architecture Lens diff --git a/src/autoskillit/skills_extended/arch-lens-security/SKILL.md b/src/autoskillit/skills_extended/arch-lens-security/SKILL.md index 0756f1f3eb..ccfb1f81e4 100644 --- a/src/autoskillit/skills_extended/arch-lens-security/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-security/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Security Architecture Lens diff --git a/src/autoskillit/skills_extended/arch-lens-state-lifecycle/SKILL.md b/src/autoskillit/skills_extended/arch-lens-state-lifecycle/SKILL.md index da8426126d..9a9d0c6f8c 100644 --- a/src/autoskillit/skills_extended/arch-lens-state-lifecycle/SKILL.md +++ b/src/autoskillit/skills_extended/arch-lens-state-lifecycle/SKILL.md @@ -20,6 +20,19 @@ semantic_version: 1 semantic_requirements: sibling_skills: - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: exploration_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # State Lifecycle Architecture Lens diff --git a/src/autoskillit/skills_extended/audit-claims/SKILL.md b/src/autoskillit/skills_extended/audit-claims/SKILL.md index 56e5faef15..ddf8338f77 100644 --- a/src/autoskillit/skills_extended/audit-claims/SKILL.md +++ b/src/autoskillit/skills_extended/audit-claims/SKILL.md @@ -279,7 +279,8 @@ This is not optional. Do not proceed to Step 5 without stating this. ```python decision_findings = [f for f in all_findings if f.get("requires_decision")] actionable_findings = [ - f for f in all_findings + f + for f in all_findings if not f.get("requires_decision") and f["severity"] in ("critical", "warning") ] diff --git a/src/autoskillit/skills_extended/audit-docs/SKILL.md b/src/autoskillit/skills_extended/audit-docs/SKILL.md index 0d0156fec4..8e56a88e05 100644 --- a/src/autoskillit/skills_extended/audit-docs/SKILL.md +++ b/src/autoskillit/skills_extended/audit-docs/SKILL.md @@ -15,6 +15,21 @@ hooks: - type: command command: 'echo ''[SKILL: audit-docs] Auditing documentation for staleness and drift...''' once: true +semantic_version: 1 +semantic_requirements: + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: audit_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Documentation Audit Skill diff --git a/src/autoskillit/skills_extended/elaborate-phase/SKILL.md b/src/autoskillit/skills_extended/elaborate-phase/SKILL.md index 0235160d31..888ef8e838 100644 --- a/src/autoskillit/skills_extended/elaborate-phase/SKILL.md +++ b/src/autoskillit/skills_extended/elaborate-phase/SKILL.md @@ -19,6 +19,19 @@ semantic_requirements: - name: dry-walkthrough - name: implement-worktree - name: make-plan + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: assessment_vectors + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Phase Elaboration Skill diff --git a/src/autoskillit/skills_extended/exp-lens-benchmark-representativeness/SKILL.md b/src/autoskillit/skills_extended/exp-lens-benchmark-representativeness/SKILL.md index c65652029d..acce175346 100644 --- a/src/autoskillit/skills_extended/exp-lens-benchmark-representativeness/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-benchmark-representativeness/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-validity-threats - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Benchmark Representativeness Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-causal-assumptions/SKILL.md b/src/autoskillit/skills_extended/exp-lens-causal-assumptions/SKILL.md index e42acd4bdb..f39b7f2d2f 100644 --- a/src/autoskillit/skills_extended/exp-lens-causal-assumptions/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-causal-assumptions/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-validity-threats - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Causal Assumptions Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-comparator-construction/SKILL.md b/src/autoskillit/skills_extended/exp-lens-comparator-construction/SKILL.md index 1cee8db415..986a9e66c8 100644 --- a/src/autoskillit/skills_extended/exp-lens-comparator-construction/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-comparator-construction/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-fair-comparison - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Comparator Construction Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-error-budget/SKILL.md b/src/autoskillit/skills_extended/exp-lens-error-budget/SKILL.md index dd90e31294..02acb4d005 100644 --- a/src/autoskillit/skills_extended/exp-lens-error-budget/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-error-budget/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-variance-stability - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Error Budget Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-estimand-clarity/SKILL.md b/src/autoskillit/skills_extended/exp-lens-estimand-clarity/SKILL.md index dff535f4d1..73f5513f0c 100644 --- a/src/autoskillit/skills_extended/exp-lens-estimand-clarity/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-estimand-clarity/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-measurement-validity - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Estimand Clarity Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-exploratory-confirmatory/SKILL.md b/src/autoskillit/skills_extended/exp-lens-exploratory-confirmatory/SKILL.md index 1a44b333fb..4e5b96af9f 100644 --- a/src/autoskillit/skills_extended/exp-lens-exploratory-confirmatory/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-exploratory-confirmatory/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-severity-testing - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Exploratory-Confirmatory Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-fair-comparison/SKILL.md b/src/autoskillit/skills_extended/exp-lens-fair-comparison/SKILL.md index ae75743f95..17bce93667 100644 --- a/src/autoskillit/skills_extended/exp-lens-fair-comparison/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-fair-comparison/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-sensitivity-robustness - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Fair Comparison Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-governance-risk/SKILL.md b/src/autoskillit/skills_extended/exp-lens-governance-risk/SKILL.md index f38b71a60d..765c6feeeb 100644 --- a/src/autoskillit/skills_extended/exp-lens-governance-risk/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-governance-risk/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-validity-threats - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Governance Risk Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-iterative-learning/SKILL.md b/src/autoskillit/skills_extended/exp-lens-iterative-learning/SKILL.md index 222c038c23..9bfee79071 100644 --- a/src/autoskillit/skills_extended/exp-lens-iterative-learning/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-iterative-learning/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-sensitivity-robustness - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Iterative Learning Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-measurement-validity/SKILL.md b/src/autoskillit/skills_extended/exp-lens-measurement-validity/SKILL.md index d4863df371..696e997aa5 100644 --- a/src/autoskillit/skills_extended/exp-lens-measurement-validity/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-measurement-validity/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-estimand-clarity - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Measurement Validity Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-pipeline-integrity/SKILL.md b/src/autoskillit/skills_extended/exp-lens-pipeline-integrity/SKILL.md index 997eac6d46..f298c6dfc9 100644 --- a/src/autoskillit/skills_extended/exp-lens-pipeline-integrity/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-pipeline-integrity/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-reproducibility-artifacts - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Pipeline Integrity Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-randomization-blocking/SKILL.md b/src/autoskillit/skills_extended/exp-lens-randomization-blocking/SKILL.md index 57ca6c5cbe..1b9917fd08 100644 --- a/src/autoskillit/skills_extended/exp-lens-randomization-blocking/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-randomization-blocking/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-unit-interference - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Randomization & Blocking Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-reproducibility-artifacts/SKILL.md b/src/autoskillit/skills_extended/exp-lens-reproducibility-artifacts/SKILL.md index 6f151595d5..5517b1db1e 100644 --- a/src/autoskillit/skills_extended/exp-lens-reproducibility-artifacts/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-reproducibility-artifacts/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-variance-stability - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Reproducibility Artifacts Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-sensitivity-robustness/SKILL.md b/src/autoskillit/skills_extended/exp-lens-sensitivity-robustness/SKILL.md index d769fdc6d7..157bf5acba 100644 --- a/src/autoskillit/skills_extended/exp-lens-sensitivity-robustness/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-sensitivity-robustness/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-iterative-learning - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Sensitivity & Robustness Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-severity-testing/SKILL.md b/src/autoskillit/skills_extended/exp-lens-severity-testing/SKILL.md index 52e472e88d..57f4531798 100644 --- a/src/autoskillit/skills_extended/exp-lens-severity-testing/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-severity-testing/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-validity-threats - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Severity Testing Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-unit-interference/SKILL.md b/src/autoskillit/skills_extended/exp-lens-unit-interference/SKILL.md index dc017ad179..1d4982b193 100644 --- a/src/autoskillit/skills_extended/exp-lens-unit-interference/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-unit-interference/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-randomization-blocking - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Unit Interference Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-validity-threats/SKILL.md b/src/autoskillit/skills_extended/exp-lens-validity-threats/SKILL.md index b03c72927f..8a4e3f4b9c 100644 --- a/src/autoskillit/skills_extended/exp-lens-validity-threats/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-validity-threats/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-severity-testing - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Validity Threats Experimental Design Lens diff --git a/src/autoskillit/skills_extended/exp-lens-variance-stability/SKILL.md b/src/autoskillit/skills_extended/exp-lens-variance-stability/SKILL.md index a17413ee3b..fa68615bae 100644 --- a/src/autoskillit/skills_extended/exp-lens-variance-stability/SKILL.md +++ b/src/autoskillit/skills_extended/exp-lens-variance-stability/SKILL.md @@ -21,6 +21,19 @@ semantic_requirements: - name: exp-lens-reproducibility-artifacts - name: make-experiment-diag - name: mermaid + logical_roles: + - name: delegated-worker + purpose: perform the named independent responsibility and return bounded evidence + child_spawns: + - role: delegated-worker + for_each: design_dimensions + concurrency: + required: true + join: + required: true + evidence: + required: true + independent: true --- # Variance Stability Experimental Design Lens diff --git a/src/autoskillit/skills_extended/resolve-claims-review/SKILL.md b/src/autoskillit/skills_extended/resolve-claims-review/SKILL.md index fb7e219cee..01f227c585 100644 --- a/src/autoskillit/skills_extended/resolve-claims-review/SKILL.md +++ b/src/autoskillit/skills_extended/resolve-claims-review/SKILL.md @@ -115,7 +115,12 @@ fi Read config: ```python import yaml, pathlib -cfg = yaml.safe_load(pathlib.Path(".autoskillit/config.yaml").read_text()) if pathlib.Path(".autoskillit/config.yaml").exists() else {} + +cfg = ( + yaml.safe_load(pathlib.Path(".autoskillit/config.yaml").read_text()) + if pathlib.Path(".autoskillit/config.yaml").exists() + else {} +) cr_cfg = cfg.get("claims_review", {}) validation_command = cr_cfg.get("validation_command", None) validation_timeout = cr_cfg.get("validation_timeout", 120) @@ -207,7 +212,8 @@ Include `critical` and `warning` only. Skip `info` findings. ```python import re -DIMENSION_PATTERN = re.compile(r'^\[(?:critical|warning|info)\]\s+(\S+):\s+') + +DIMENSION_PATTERN = re.compile(r"^\[(?:critical|warning|info)\]\s+(\S+):\s+") ``` Apply `DIMENSION_PATTERN` to each comment body to extract the dimension label. diff --git a/src/autoskillit/skills_extended/resolve-research-review/SKILL.md b/src/autoskillit/skills_extended/resolve-research-review/SKILL.md index 2e48b64c3b..861d1b98d1 100644 --- a/src/autoskillit/skills_extended/resolve-research-review/SKILL.md +++ b/src/autoskillit/skills_extended/resolve-research-review/SKILL.md @@ -97,7 +97,12 @@ feature_branch=$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD) Read config: ```python import yaml, pathlib -cfg = yaml.safe_load(pathlib.Path(".autoskillit/config.yaml").read_text()) if pathlib.Path(".autoskillit/config.yaml").exists() else {} + +cfg = ( + yaml.safe_load(pathlib.Path(".autoskillit/config.yaml").read_text()) + if pathlib.Path(".autoskillit/config.yaml").exists() + else {} +) rr_cfg = cfg.get("research_review", {}) validation_command = rr_cfg.get("validation_command", None) validation_timeout = rr_cfg.get("validation_timeout", 120) @@ -188,7 +193,8 @@ Include `critical` and `warning` only. Skip `info` findings. ```python import re -DIMENSION_PATTERN = re.compile(r'^\[(?:critical|warning|info)\]\s+(\S+):\s+') + +DIMENSION_PATTERN = re.compile(r"^\[(?:critical|warning|info)\]\s+(\S+):\s+") ``` Apply `DIMENSION_PATTERN` to each comment body to extract the dimension label. diff --git a/src/autoskillit/skills_extended/resolve-review/SKILL.md b/src/autoskillit/skills_extended/resolve-review/SKILL.md index 74a1c02284..b987ac41c9 100644 --- a/src/autoskillit/skills_extended/resolve-review/SKILL.md +++ b/src/autoskillit/skills_extended/resolve-review/SKILL.md @@ -293,7 +293,9 @@ for thread in all_thread_nodes: for reply in comments_in_thread[1:]: if RESOLVED_MARKER_RE.search(reply.get("body", "")): already_replied_ids.add(first_comment_id) - log(f"Skipping comment {first_comment_id} — already resolved by prior resolve-review run") + log( + f"Skipping comment {first_comment_id} — already resolved by prior resolve-review run" + ) break ``` diff --git a/src/autoskillit/skills_extended/review-design/SKILL.md b/src/autoskillit/skills_extended/review-design/SKILL.md index 802a0ac60b..6b1d9da903 100644 --- a/src/autoskillit/skills_extended/review-design/SKILL.md +++ b/src/autoskillit/skills_extended/review-design/SKILL.md @@ -586,21 +586,28 @@ One synthesis pass (no subagent — orchestrator synthesizes directly): warning_threshold = active_dimensions * WARNING_BUDGET_PER_DIM # L1 fail-fast path: only STRUCTURAL defects trigger STOP - l1_criticals = [f for f in critical_findings if f.dimension in {"estimand_clarity", "hypothesis_falsifiability"}] + l1_criticals = [ + f + for f in critical_findings + if f.dimension in {"estimand_clarity", "hypothesis_falsifiability"} + ] # Tag ADDRESSABLE L1 criticals as REQUIRED (scope: hypothesis_falsifiability only) for f in l1_criticals: if f.fixability == "ADDRESSABLE": f.priority = "REQUIRED" # Scope guard: only STRUCTURAL/None fixability triggers STOP (see _STRUCTURAL_FIXABILITY_VALUES) structural_stop_triggers = [ - f for f in l1_criticals + f + for f in l1_criticals if f.fixability in _STRUCTURAL_FIXABILITY_VALUES # {"STRUCTURAL", None} ] # Red-team STOP path: red_team has no fixability concept — dimension-only match is # intentional here (unlike L1 criticals which gate on fixability via # _STRUCTURAL_FIXABILITY_VALUES). The severity cap is applied upstream. - stop_triggers = structural_stop_triggers + [f for f in critical_findings if f.dimension == "red_team"] + stop_triggers = structural_stop_triggers + [ + f for f in critical_findings if f.dimension == "red_team" + ] if stop_triggers: verdict = "STOP" diff --git a/src/autoskillit/skills_extended/review-pr/SKILL.md b/src/autoskillit/skills_extended/review-pr/SKILL.md index fd86deed76..18967346d9 100644 --- a/src/autoskillit/skills_extended/review-pr/SKILL.md +++ b/src/autoskillit/skills_extended/review-pr/SKILL.md @@ -223,10 +223,7 @@ Check for the marker using: ```python RESOLVED_MARKER_RE = re.compile(r"', maxsplit=1)[ 1 diff --git a/tests/skills/test_semantic_join_inventory.py b/tests/skills/test_semantic_join_inventory.py new file mode 100644 index 0000000000..c43940b07d --- /dev/null +++ b/tests/skills/test_semantic_join_inventory.py @@ -0,0 +1,120 @@ +"""Parsed-behavior inventory for join-required declarations. + +The plan (§Step 6) requires a failing inventory test that uses parsed +behavior — not prose markers — to assert every child-spawning / +exploration consumer that joins results declares +``semantic_requirements.join.required: true``. + +The test enumerates all bundled skill SKILL.md files plus exploration +sidecars, parses the YAML frontmatter, and walks the semantic_requirements +block. Any skill whose ``child_spawns`` block is non-empty, or whose body +matches a structural child-spawning marker (Agent(, Task(, spawn_agent, +Dispatch(), must declare ``join.required: true``. + +This guards against the regression path that left 45 skills enforcing +join only via prose. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from autoskillit.core.io import load_yaml + +SKILLS_DIR = Path(__file__).parents[2] / "src/autoskillit/skills_extended" + +CHILD_SPAWN_BODY_MARKERS: tuple[str, ...] = ( + "Agent(", + "Task(", + "spawn_agent", + "Dispatch(", +) + +_FRONT_RE = re.compile(r"^---\s*$") + + +pytestmark = [pytest.mark.small] + + +def _frontmatter(text: str) -> dict: + """Parse YAML frontmatter between the first pair of ``---`` delimiters.""" + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + end = next( + (i for i, ln in enumerate(lines[1:], 1) if ln.strip() == "---"), + None, + ) + if end is None: + return {} + return load_yaml("\n".join(lines[1:end])) + + +def _has_child_spawn_markers(body: str) -> bool: + """True when the body uses child-dispatch markers (not prose phrases).""" + return any(marker in body for marker in CHILD_SPAWN_BODY_MARKERS) + + +def _iter_skill_files() -> list[Path]: + return sorted(SKILLS_DIR.rglob("SKILL.md")) + + +@pytest.mark.layer("skills") +@pytest.mark.small +def test_child_spawning_skills_declare_join_required() -> None: + """Every skill that spawns children must declare ``join.required: true``.""" + offenders: list[str] = [] + for path in _iter_skill_files(): + text = path.read_text(encoding="utf-8") + fm = _frontmatter(text) + sem = fm.get("semantic_requirements", {}) + if not isinstance(sem, dict): + sem = {} + child_spawns = sem.get("child_spawns") or [] + join = sem.get("join") or {} + if isinstance(join, dict): + join_required = bool(join.get("required", False)) + else: + join_required = False + if child_spawns: + if not join_required: + offenders.append( + f"{path.relative_to(SKILLS_DIR)} declares child_spawns without join.required" + ) + continue + body_after_fm = text.split("---", 2)[-1] if _FRONT_RE.match(text) else text + if _has_child_spawn_markers(body_after_fm) and not join_required: + offenders.append( + f"{path.relative_to(SKILLS_DIR)} uses child-dispatch markers without join.required" + ) + assert not offenders, ( + "Skills must declare semantic_requirements.join.required: true " + "when they spawn children:\n - " + "\n - ".join(offenders) + ) + + +@pytest.mark.layer("skills") +@pytest.mark.small +def test_zero_prose_only_join_consumers() -> None: + """No skill declares join in prose without the structured authority.""" + offenders: list[str] = [] + for path in _iter_skill_files(): + text = path.read_text(encoding="utf-8") + fm = _frontmatter(text) + sem = fm.get("semantic_requirements", {}) + if not isinstance(sem, dict): + sem = {} + join = sem.get("join") or {} + join_required = bool(join.get("required", False)) if isinstance(join, dict) else False + body = text.split("---", 2)[-1] if _FRONT_RE.match(text) else text + has_prose_join = ( + "joining every child is required" in body or "join every spawned child" in body.lower() + ) + if has_prose_join and not join_required: + offenders.append(path.relative_to(SKILLS_DIR).as_posix()) + assert not offenders, ( + "Prose-only join consumers (must declare join.required: true): " + ", ".join(offenders) + ) diff --git a/tests/skills_extended/test_explorer_adoption_inventory.py b/tests/skills_extended/test_explorer_adoption_inventory.py index 743997adda..784deb8143 100644 --- a/tests/skills_extended/test_explorer_adoption_inventory.py +++ b/tests/skills_extended/test_explorer_adoption_inventory.py @@ -1529,13 +1529,18 @@ def test_migrated_phase_d_bodies_have_no_raw_agent_authoring_syntax( ("backend", "native_prefix"), [ (ClaudeCodeBackend(), 'Agent(subagent_type="autoskillit:'), - (CodexBackend(), 'spawn_agent(agent_type="'), ], ) def test_all_actual_migrated_phase_d_vectors_render_each_native_backend_form( backend: ClaudeCodeBackend | CodexBackend, native_prefix: str, ) -> None: + # The Codex parameterization was dropped here because every skill in + # _PHASE_D_INVENTORY declares join.required=true and Codex refuses + # join-bearing skills at admission (REQUIRED_JOIN — + # wait-any/mailbox-activity). This native-form contract is therefore + # only verified against Claude; Codex's refusal is covered by the + # backend semantic-authenticity contract tests. active = frozenset(ExplorationVectorApplicabilityId) migrated_count = 0 @@ -1582,27 +1587,38 @@ def test_investigate_projects_adaptive_semantic_collections_and_guarded_candidat "delegated-worker", ) - for backend in (ClaudeCodeBackend(), CodexBackend()): - projected = _project_phase_d_skill( + projected = _project_phase_d_skill( + skill, + ClaudeCodeBackend(), + frozenset(ExplorationVectorApplicabilityId), + ) + assert projected.count("Candidate exploration task") == 15 + assert projected.count("selected_exploration_task_ids") >= 16 + assert "rendered marker or call count is never a spawn obligation" in projected + assert projected.index("selected_reasoning_responsibilities") < projected.rindex( + "Backend-adapted semantic execution contract" + ) + assert projected.index("selected_web_research_topics") < projected.rindex( + "Backend-adapted semantic execution contract" + ) + assert "Call spawn_agent 1 time" not in projected + assert "Issue 1 Agent(" not in projected + assert "Minimum 2 batches" not in projected + assert "minimum of 5 parallel" not in projected + assert "Mandatory web research" not in projected + assert "spawn 2–3 independent validator" not in projected + + # investigate declares join.required=true; Codex refuses join-bearing + # skills at admission (REQUIRED_JOIN — wait-any/mailbox-activity), so + # the projection must fail closed rather than render adapted content. + from autoskillit.core.types._type_exceptions import SkillContractError + + with pytest.raises(SkillContractError, match="wait-any/mailbox-activity"): + _project_phase_d_skill( skill, - backend, + CodexBackend(), frozenset(ExplorationVectorApplicabilityId), ) - assert projected.count("Candidate exploration task") == 15 - assert projected.count("selected_exploration_task_ids") >= 16 - assert "rendered marker or call count is never a spawn obligation" in projected - assert projected.index("selected_reasoning_responsibilities") < projected.rindex( - "Backend-adapted semantic execution contract" - ) - assert projected.index("selected_web_research_topics") < projected.rindex( - "Backend-adapted semantic execution contract" - ) - assert "Call spawn_agent 1 time" not in projected - assert "Issue 1 Agent(" not in projected - assert "Minimum 2 batches" not in projected - assert "minimum of 5 parallel" not in projected - assert "Mandatory web research" not in projected - assert "spawn 2–3 independent validator" not in projected @pytest.mark.parametrize("skill_name", sorted(_PHASE_D_INVENTORY)) @@ -1617,27 +1633,18 @@ def test_actual_migrated_phase_d_applicability_controls_native_dispatch( ) applicabilities = {vector.applicability for vector in migrated} + from autoskillit.core.types._type_exceptions import SkillContractError + for selected in applicabilities: active = frozenset({ExplorationVectorApplicabilityId.ALWAYS, selected}) - projected = _project_phase_d_skill(skill, CodexBackend(), active) - for vector in migrated: - body = _marker_body(projected, vector) - is_active = ( - vector.applicability is ExplorationVectorApplicabilityId.ALWAYS - or vector.applicability is selected - ) - if is_active: - assert f'spawn_agent(agent_type="{vector.role}"' in body, ( - skill_name, - vector.id, - selected.value, - ) - else: - assert "not applicable to the current invocation" in body, ( - skill_name, - vector.id, - selected.value, - ) + # Every skill in this inventory with a non-empty `migrated` set + # declares join.required=true; Codex refuses join-bearing skills + # at admission (REQUIRED_JOIN — wait-any/mailbox-activity) + # regardless of which applicability profile is active, so + # per-vector active/inactive body content can no longer be + # observed on Codex here. + with pytest.raises(SkillContractError, match="wait-any/mailbox-activity"): + _project_phase_d_skill(skill, CodexBackend(), active) def test_architecture_selectors_filesystem_inventory_and_native_matrix_are_exact() -> None: diff --git a/tests/workspace/test_project_local_overrides.py b/tests/workspace/test_project_local_overrides.py index 121d77f263..efb7db1504 100644 --- a/tests/workspace/test_project_local_overrides.py +++ b/tests/workspace/test_project_local_overrides.py @@ -685,7 +685,7 @@ def test_prepare_skill_projection_authenticates_project_root_not_managed_add_dir ) monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") - backend = get_backend("codex") + backend = get_backend("claude-code") plugin_authority, preparation = prepare_skill_projection( project_root=project_root, cwd=cwd, diff --git a/tests/workspace/test_session_skills_codex.py b/tests/workspace/test_session_skills_codex.py index 1dabfa5468..b27b73a39e 100644 --- a/tests/workspace/test_session_skills_codex.py +++ b/tests/workspace/test_session_skills_codex.py @@ -2,7 +2,6 @@ from __future__ import annotations -import hashlib import json from dataclasses import replace from pathlib import Path @@ -120,11 +119,12 @@ def _managed( session_id: str, *, backend, + names: frozenset[str] | None = None, role: SkillExecutionRole = SkillExecutionRole.SESSION, ): from autoskillit.workspace import compile_session_skill_catalog - catalog, context = _catalog_context(manager, backend=backend, role=role) + catalog, context = _catalog_context(manager, backend=backend, names=names, role=role) compilation = compile_session_skill_catalog(catalog, backend) return manager.managed_session(session_id, compilation, context) @@ -145,7 +145,9 @@ def codex_env(): def test_codex_init_session_creates_skills_subdir(make_session_skill_manager, codex_env) -> None: mgr = make_session_skill_manager() - session_path = _materialize(mgr, "sid", backend=codex_env.backend) + session_path = _materialize( + mgr, "sid", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ) skill_files = list( (session_path / ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR).glob("*/SKILL.md") ) @@ -156,6 +158,7 @@ def test_codex_init_session_creates_skills_subdir(make_session_skill_manager, co def test_codex_materializes_exact_guarded_investigate_document( make_session_skill_manager, ) -> None: + """Codex must refuse to project the join-bearing 'investigate' skill (rectify-join).""" from autoskillit.core import ExplorationVectorApplicabilityId from autoskillit.workspace import ( DefaultSkillResolver, @@ -178,33 +181,9 @@ def test_codex_materializes_exact_guarded_investigate_document( active_exploration_applicabilities=frozenset(ExplorationVectorApplicabilityId), parent_sandbox_mode="read-only", ) - expected = project_agent_skill_document(invocation.root, context) - add_dir = manager.materialize_invocation("investigate-materialized", invocation, context) - written = ( - add_dir / ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR / "investigate" / "SKILL.md" - ).read_text(encoding="utf-8") - - assert written == expected.content - assert expected.semantic_payload["child_spawns"] == ( - { - "role": "delegated-worker", - "for_each": "selected_reasoning_responsibilities", - }, - { - "role": "autoskillit:web-evidence-researcher", - "for_each": "selected_web_research_topics", - }, - ) - fragments = expected.adaptation_payload["instruction_fragments"] - assert any("selected_reasoning_responsibilities" in item for item in fragments) - assert any("selected_web_research_topics" in item for item in fragments) - assert expected.semantic_digest in written - assert expected.adaptation_digest in written - assert hashlib.sha256(written.encode()).hexdigest() == expected.projected_digest - assert "Candidate exploration task" in written - assert "selected_exploration_task_ids" in written - assert "Call spawn_agent 1 time" not in written + with pytest.raises(SkillContractError, match="wait-any/mailbox-activity"): + project_agent_skill_document(invocation.root, context) def test_materialization_forwards_only_server_explorer_binding_env( @@ -215,7 +194,7 @@ def test_materialization_forwards_only_server_explorer_binding_env( catalog, context = _catalog_context( manager, backend=codex_env.backend, - names=frozenset({"investigate"}), + names=frozenset({"make-arch-diag"}), ) binding_env = { "semantic-code-navigator": { @@ -245,7 +224,7 @@ def test_materialization_mints_explorer_binding_between_prelaunch_and_setup( catalog, context = _catalog_context( manager, backend=codex_env.backend, - names=frozenset({"investigate"}), + names=frozenset({"make-arch-diag"}), ) events: list[str] = [] binding_env = { @@ -314,12 +293,12 @@ def test_codex_generated_home_links_projected_catalog_into_discovery_root( mgr, "sid", backend=codex_env.backend, - names=frozenset({"investigate"}), + names=frozenset({"make-arch-diag"}), ) add_dir_path = Path(str(add_dir)) - projected = add_dir_path / "skills" / "investigate" - discoverable = add_dir_path.parent / "skills" / "investigate" + projected = add_dir_path / "skills" / "make-arch-diag" + discoverable = add_dir_path.parent / "skills" / "make-arch-diag" assert discoverable.is_symlink() assert not discoverable.readlink().is_absolute() @@ -330,7 +309,7 @@ def test_codex_generated_home_preserves_existing_profile_skill_on_collision( make_session_skill_manager, codex_env, ) -> None: - profile_content = "---\nname: investigate\ndescription: profile copy\n---\n" + profile_content = "---\nname: make-arch-diag\ndescription: profile copy\n---\n" def setup_session_dir( session_dir: Path, @@ -339,7 +318,7 @@ def setup_session_dir( execution_role: SkillExecutionRole = SkillExecutionRole.SESSION, ) -> None: del parent_sandbox_mode, execution_role - profile_skill = session_dir / "skills" / "investigate" + profile_skill = session_dir / "skills" / "make-arch-diag" profile_skill.mkdir(parents=True) (profile_skill / "SKILL.md").write_text(profile_content) @@ -349,22 +328,24 @@ def setup_session_dir( mgr, "sid", backend=codex_env.backend, - names=frozenset({"investigate"}), + names=frozenset({"make-arch-diag"}), ) add_dir_path = Path(str(add_dir)) - discoverable = add_dir_path.parent / "skills" / "investigate" + discoverable = add_dir_path.parent / "skills" / "make-arch-diag" assert not discoverable.is_symlink() assert (discoverable / "SKILL.md").read_text() == profile_content - assert (add_dir_path / "skills" / "investigate" / "SKILL.md").is_file() + assert (add_dir_path / "skills" / "make-arch-diag" / "SKILL.md").is_file() def test_codex_init_session_delegates_to_setup_session_dir( make_session_skill_manager, codex_env ) -> None: mgr = make_session_skill_manager() - session_path = _materialize(mgr, "sid", backend=codex_env.backend) + session_path = _materialize( + mgr, "sid", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ) codex_env.backend.setup_session_dir.assert_called_once_with( Path(str(session_path)).parent, parent_sandbox_mode="workspace-write", @@ -380,6 +361,7 @@ def test_codex_managed_orchestrator_materializes_exact_catalog( catalog, _ = _catalog_context( mgr, backend=codex_env.backend, + names=frozenset({"sous-chef"}), role=SkillExecutionRole.ORCHESTRATOR, ) @@ -387,6 +369,7 @@ def test_codex_managed_orchestrator_materializes_exact_catalog( mgr, "orchestrator", backend=codex_env.backend, + names=frozenset({"sous-chef"}), role=SkillExecutionRole.ORCHESTRATOR, ) as managed: projected_root = Path(managed.skills_dir.path) / "skills" @@ -409,6 +392,7 @@ def test_codex_managed_orchestrator_rejects_discovery_collision( catalog, _ = _catalog_context( mgr, backend=codex_env.backend, + names=frozenset({"sous-chef"}), role=SkillExecutionRole.ORCHESTRATOR, ) collision_name = catalog.skills[0].name @@ -430,6 +414,7 @@ def setup_session_dir( mgr, "orchestrator", backend=codex_env.backend, + names=frozenset({"sous-chef"}), role=SkillExecutionRole.ORCHESTRATOR, ): pass @@ -445,7 +430,9 @@ def test_codex_init_session_returns_validated_add_dir( make_session_skill_manager, codex_env ) -> None: mgr = make_session_skill_manager() - result = _materialize(mgr, "sid", backend=codex_env.backend) + result = _materialize( + mgr, "sid", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ) assert isinstance(result, ValidatedAddDir) assert str(result).endswith("/sid/add-dir") @@ -466,7 +453,9 @@ def test_codex_init_session_calls_ensure_pre_launch(make_session_skill_manager, codex_env.backend.ensure_pre_launch.return_value = PreLaunchReadiness((), {}) mgr = make_session_skill_manager() - skills_dir = _materialize(mgr, "sid", backend=codex_env.backend) + skills_dir = _materialize( + mgr, "sid", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ) codex_env.backend.ensure_pre_launch.assert_called_once_with( session_dir=Path(str(skills_dir)).parent ) @@ -482,7 +471,7 @@ def test_codex_init_session_raises_when_pre_launch_fails( mgr = make_session_skill_manager() with pytest.raises(RuntimeError, match="Pre-launch check failed"): - _materialize(mgr, "sid", backend=codex_env.backend) + _materialize(mgr, "sid", backend=codex_env.backend, names=frozenset({"make-arch-diag"})) def test_profile_skills_are_projected_into_session_dir(tmp_path, monkeypatch) -> None: @@ -702,7 +691,9 @@ def test_managed_codex_home_uses_private_empty_inert_rollout_links( ephemeral_root=tmp_path / "ephemeral", codex_root=codex_root, ) - with _managed(mgr, "0123456789abcdef", backend=codex_env.backend) as managed: + with _managed( + mgr, "0123456789abcdef", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ) as managed: assert isinstance(managed, ManagedSessionHome) assert managed.generated_home == codex_root / "0123456789abcdef" assert managed.skills_dir == ValidatedAddDir(path=str(managed.generated_home / "add-dir")) @@ -742,7 +733,9 @@ def test_persistent_backend_declares_its_own_inert_paths( ) persistent_root = tmp_path / "persistent" / "custom-sessions" mgr = make_session_skill_manager(codex_root=persistent_root) - with _managed(mgr, "0123456789abcdef", backend=backend) as managed: + with _managed( + mgr, "0123456789abcdef", backend=backend, names=frozenset({"make-arch-diag"}) + ) as managed: records = managed.generated_home / "records" assert records.is_symlink() assert records.resolve(strict=True).is_dir() @@ -778,7 +771,9 @@ def test_managed_codex_home_rolls_back_every_published_owner_on_initialization_f codex_env.backend.validate_session_layout.return_value = [expected] with pytest.raises(RuntimeError, match=expected): - with _managed(mgr, "0123456789abcdef", backend=codex_env.backend): + with _managed( + mgr, "0123456789abcdef", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ): pytest.fail("initialization failure must occur before managed_session yields") assert not (codex_root / "0123456789abcdef").exists() @@ -806,7 +801,9 @@ def recording_rmtree(path: Path, *args, **kwargs) -> None: monkeypatch.setattr(session_skills.shutil, "rmtree", recording_rmtree) with pytest.raises(KeyboardInterrupt, match="stop"): - with _managed(mgr, "0123456789abcdef", backend=codex_env.backend) as managed: + with _managed( + mgr, "0123456789abcdef", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ) as managed: assert managed.generated_home.exists() raise KeyboardInterrupt("stop") @@ -867,7 +864,9 @@ def fail_acquire( ) with pytest.raises(OSError, match="lease open failed"): - with _managed(mgr, "0123456789abcdef", backend=codex_env.backend): + with _managed( + mgr, "0123456789abcdef", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ): pytest.fail("lease failure must precede yield") assert not (codex_root / "0123456789abcdef").exists() @@ -904,7 +903,9 @@ def recording_acquire( classmethod(recording_acquire), ) - with _managed(mgr, "0123456789abcdef", backend=codex_env.backend): + with _managed( + mgr, "0123456789abcdef", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ): pass assert len(calls) == 1 @@ -918,7 +919,9 @@ def test_unowned_cleanup_refuses_a_contended_generated_home( codex_root = tmp_path / "persistent" / "codex-sessions" owner = make_session_skill_manager(codex_root=codex_root) contender = make_session_skill_manager(codex_root=codex_root) - _materialize(owner, "0123456789abcdef", backend=codex_env.backend) + _materialize( + owner, "0123456789abcdef", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ) assert contender.cleanup_session("0123456789abcdef") is False assert (codex_root / "0123456789abcdef").is_dir() @@ -1012,7 +1015,9 @@ def fail_delete(path: Path) -> bool: raise _DeletionFailure("delete failed") with pytest.raises(_DeletionFailure, match="delete failed"): - with _managed(mgr, "0123456789abcdef", backend=codex_env.backend): + with _managed( + mgr, "0123456789abcdef", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ): monkeypatch.setattr( session_skills, "_remove_and_verify", @@ -1037,7 +1042,9 @@ def fail_after_release(lease: session_skills._SessionLease) -> None: raise _ReleaseFailure("release failed") with pytest.raises(_ReleaseFailure, match="release failed"): - with _managed(mgr, "0123456789abcdef", backend=codex_env.backend): + with _managed( + mgr, "0123456789abcdef", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ): monkeypatch.setattr( session_skills._SessionLease, "release", @@ -1066,7 +1073,9 @@ def fail_after_release(lease: session_skills._SessionLease) -> None: raise _ReleaseFailure("release failed") with pytest.raises(BaseExceptionGroup) as caught: - with _managed(mgr, "0123456789abcdef", backend=codex_env.backend): + with _managed( + mgr, "0123456789abcdef", backend=codex_env.backend, names=frozenset({"make-arch-diag"}) + ): monkeypatch.setattr(session_skills, "_remove_and_verify", fail_delete) monkeypatch.setattr( session_skills._SessionLease,