From 817aef179f4747dff25129179afec58832c792d6 Mon Sep 17 00:00:00 2001 From: yaojingang Date: Mon, 15 Jun 2026 22:58:21 +0800 Subject: [PATCH] Add approval-gated adaptive apply --- references/autonomous-adaptation.md | 24 +- reports/adaptation_approval_ledger.json | 20 + reports/adaptation_proposals.json | 4 +- reports/adaptation_regression_report.json | 32 ++ reports/adaptation_regression_report.md | 11 + reports/skill_os2_coverage.json | 21 +- reports/skill_os2_coverage.md | 15 +- schemas/adaptation-proposal.schema.json | 8 +- scripts/apply_adaptation.py | 458 ++++++++++++++++++++++ scripts/propose_adaptation.py | 4 +- scripts/yao.py | 2 +- scripts/yao_cli_adaptation_commands.py | 36 +- scripts/yao_cli_parser.py | 25 ++ tests/verify_adaptation_safety.py | 190 ++++++++- tests/verify_skill_os2_coverage.py | 22 +- tests/verify_yao_cli.py | 2 +- 16 files changed, 833 insertions(+), 41 deletions(-) create mode 100644 reports/adaptation_approval_ledger.json create mode 100644 reports/adaptation_regression_report.json create mode 100644 reports/adaptation_regression_report.md create mode 100644 scripts/apply_adaptation.py diff --git a/references/autonomous-adaptation.md b/references/autonomous-adaptation.md index 5eaf0ed..1bab9bd 100644 --- a/references/autonomous-adaptation.md +++ b/references/autonomous-adaptation.md @@ -4,18 +4,24 @@ This reference defines the safe foundation for adaptive self-iteration. ## Scope -Adaptive iteration is a proposal-only loop until a human explicitly approves a patch application workflow. The current implementation may: +Adaptive iteration is proposal-only until a human explicitly approves a patch application workflow. The current implementation may: - read one user-provided local source file; - redact sensitive text before storing evidence excerpts; - summarize repeated preferences and operational signals; - produce adaptation proposals with target files, risks, tests, and rollback plans. +- dry-run an approved patch through `adapt-apply`, after patch hash, approval, and target allowlist checks pass. +- apply a patch only when the operator passes `--apply` and the approval ledger names the reviewer, reason, patch hash, target files, verification commands, and rollback plan. +- automatically reverse an applied patch when `--run-verification` fails, unless the operator explicitly passes `--no-rollback-on-failure`. It must not: - scan shell history, browser history, chat logs, mail, or private folders by default; - infer permanent user memory from a single comment; - write source files as part of scan or proposal generation; +- write source files through `adapt-apply` without explicit `--apply`; +- apply a patch whose target files are outside both the proposal and approval allowlists; +- leave a failed verified apply in place by default; - count proposals as completed implementation evidence. ## Flow @@ -23,7 +29,10 @@ It must not: 1. `adapt-scan` reads an explicit source path and writes `reports/user_patterns.json` plus `reports/user_patterns.md`. 2. `adapt-propose` reads the pattern report and writes `reports/adaptation_proposals.json` plus `reports/adaptation_proposals.md`. 3. A reviewer decides whether any proposal is worth implementing. -4. Future `adapt-apply` work must require approval evidence, allowlisted targets, regression commands, and rollback metadata before writing files. +4. `adapt-apply --write-template` creates `reports/adaptation_approval_ledger.json` and `reports/adaptation_regression_report.json` so the review surface exists before any patch is applied. +5. `adapt-apply --proposal-id --patch-file ` defaults to a dry-run and records patch, target, approval, regression, and rollback evidence. +6. `adapt-apply --apply --run-verification` may write files only after approval, patch hash, allowlist, `git apply --check`, and safe regression command checks pass. +7. If a verification command fails after a patch is applied, `adapt-apply` runs `git apply -R ` by default and records `failed-rolled-back` plus rollback evidence in `reports/adaptation_regression_report.json`. ## Evidence Standard @@ -37,6 +46,15 @@ Each proposal should include: - rollback plan; - a clear `proposal-only` status. +Each approved application should include: + +- reviewer, reason, approval date, and optional expiry; +- exact patch SHA-256; +- target file allowlist; +- regression commands restricted to local `make` targets or local Python verifier scripts; +- rollback command or plan. +- rollback result if regression failed after an apply attempt. + ## Review Boundary -The adaptive loop improves iteration quality, but it does not replace normal review. Any proposal touching trigger behavior, reports, packaging, telemetry, privacy, or governance must still pass the same tests and release gates as a manually designed change. +The adaptive loop improves iteration quality, but it does not replace normal review. Any proposal touching trigger behavior, reports, packaging, telemetry, privacy, or governance must still pass the same tests and release gates as a manually designed change. `adapt-apply` evidence proves that an approved patch path was checked or applied; it does not make world-class external or human evidence complete. diff --git a/reports/adaptation_approval_ledger.json b/reports/adaptation_approval_ledger.json new file mode 100644 index 0000000..ab70ac3 --- /dev/null +++ b/reports/adaptation_approval_ledger.json @@ -0,0 +1,20 @@ +{ + "schema_version": "1.0", + "ok": true, + "generated_at": "2026-06-15T00:00:00Z", + "summary": { + "approval_count": 0, + "active_approval_count": 0, + "applied_count": 0, + "rollback_count": 0 + }, + "approval_contract": { + "approval_required": true, + "patch_sha256_required": true, + "allowlisted_targets_required": true, + "dry_run_default": true, + "writes_repository_files_only_with_apply": true, + "rollback_required": true + }, + "entries": [] +} diff --git a/reports/adaptation_proposals.json b/reports/adaptation_proposals.json index c1ea0ec..2e5c025 100644 --- a/reports/adaptation_proposals.json +++ b/reports/adaptation_proposals.json @@ -7,7 +7,7 @@ "summary": { "pattern_count": 5, "proposal_count": 5, - "apply_supported": false, + "apply_supported": true, "failure_count": 0 }, "proposal_contract": { @@ -16,7 +16,7 @@ "writes_repository_files": false, "allowlisted_targets_required": true, "rollback_required_for_apply": true, - "apply_command_available": false + "apply_command_available": true }, "proposals": [ { diff --git a/reports/adaptation_regression_report.json b/reports/adaptation_regression_report.json new file mode 100644 index 0000000..75d985f --- /dev/null +++ b/reports/adaptation_regression_report.json @@ -0,0 +1,32 @@ +{ + "schema_version": "1.0", + "ok": true, + "generated_at": "2026-06-15T00:00:00Z", + "skill_dir": ".", + "summary": { + "apply_supported": true, + "attempt_count": 0, + "applied_count": 0, + "dry_run_count": 0, + "rollback_count": 0, + "regression_run_count": 0, + "regression_pass_count": 0, + "failure_count": 0 + }, + "apply_contract": { + "approval_required": true, + "patch_sha256_required": true, + "allowlisted_targets_required": true, + "dry_run_default": true, + "writes_repository_files_only_with_apply": true, + "rollback_required": true, + "safe_regression_commands_only": true, + "rollback_on_failure_default": true + }, + "attempts": [], + "failures": [], + "artifacts": { + "json": "reports/adaptation_regression_report.json", + "approval_ledger": "reports/adaptation_approval_ledger.json" + } +} diff --git a/reports/adaptation_regression_report.md b/reports/adaptation_regression_report.md new file mode 100644 index 0000000..cdcaa68 --- /dev/null +++ b/reports/adaptation_regression_report.md @@ -0,0 +1,11 @@ +# Adaptation Regression Report + +- generated_at: `2026-06-15T00:00:00Z` +- apply_supported: `true` +- attempts: `0` +- applied: `0` +- dry runs: `0` +- rollbacks: `0` +- failures: `0` + +This report proves the adaptive apply harness behavior. It does not count proposals as applied changes. diff --git a/reports/skill_os2_coverage.json b/reports/skill_os2_coverage.json index 97e5bb1..15c3913 100644 --- a/reports/skill_os2_coverage.json +++ b/reports/skill_os2_coverage.json @@ -11,10 +11,10 @@ "warn_count": 0, "missing_count": 0, "extension_track_count": 2, - "extension_partial_count": 1, + "extension_partial_count": 0, "extension_planned_count": 0, - "extension_covered_count": 1, - "adaptive_extension_ready": false, + "extension_covered_count": 2, + "adaptive_extension_ready": true, "local_blueprint_ready": true, "public_world_class_ready": false, "world_class_evidence_pending_count": 4, @@ -26,8 +26,7 @@ "missing": 0 }, "extension_status_counts": { - "covered": 1, - "partial": 1 + "covered": 2 }, "modules": [ { @@ -673,9 +672,9 @@ { "key": "adaptive-self-iteration", "label": "Adaptive Self-Iteration", - "status": "partial", + "status": "covered", "objective": "Local-first preference memory, repeated-signal extraction, adaptation proposals, approval, patch application, regression evidence, and rollback.", - "current": "Proposal-only adapt-scan/adapt-propose foundation exists with policy, schema, and safety tests; approval-gated patch application is not implemented yet.", + "current": "Full adaptive loop includes proposal, approval, patch application, regression evidence, and rollback metadata.", "target": "Proposal-only adaptation with explicit input source, redaction, allowlisted write targets, approval ledger, regression report, and rollback plan.", "evidence": [ { @@ -704,15 +703,15 @@ }, { "path": "scripts/apply_adaptation.py", - "exists": false + "exists": true }, { "path": "reports/adaptation_approval_ledger.json", - "exists": false + "exists": true }, { "path": "reports/adaptation_regression_report.json", - "exists": false + "exists": true }, { "path": "reports/user_patterns.json", @@ -731,7 +730,7 @@ "exists": true } ], - "next_action": "Add adapt-apply only after approval ledger, allowlisted targets, dry-run diffs, regression reports, and rollback artifacts are designed." + "next_action": "Start with policy and read-only scan tests; do not read shell history or private logs unless the user provides an explicit source path." } ], "next_highest_leverage": [ diff --git a/reports/skill_os2_coverage.md b/reports/skill_os2_coverage.md index ddc7227..847bae9 100644 --- a/reports/skill_os2_coverage.md +++ b/reports/skill_os2_coverage.md @@ -11,10 +11,10 @@ Generated at: `2026-06-15` - missing: `0` - warn: `0` - reference extensions: `2` -- extension covered: `1` -- extension partial: `1` +- extension covered: `2` +- extension partial: `0` - extension planned: `0` -- adaptive extension ready: `false` +- adaptive extension ready: `true` - world-class evidence pending: `4` This report maps the Skill OS 2.0 upgrade blueprint to concrete local artifacts, commands, and tests. It does not count pending human review, provider runs, metadata fallbacks, or planned work as public world-class evidence. @@ -55,7 +55,7 @@ This report maps the Skill OS 2.0 upgrade blueprint to concrete local artifacts, | Track | Status | Current | Target | Next action | | --- | --- | --- | --- | --- | | Skill Interpretation Report | `covered` | Skill Overview v2 is canonical and mirrored as first-class skill-interpretation HTML/JSON with schema and tests. | Either keep skill-overview as the canonical interpretation report with an explicit contract, or split a dedicated reports/skill-interpretation.* renderer and tests. | Keep overview and interpretation contracts in lockstep when report sections, metrics, or layout semantics change. | -| Adaptive Self-Iteration | `partial` | Proposal-only adapt-scan/adapt-propose foundation exists with policy, schema, and safety tests; approval-gated patch application is not implemented yet. | Proposal-only adaptation with explicit input source, redaction, allowlisted write targets, approval ledger, regression report, and rollback plan. | Add adapt-apply only after approval ledger, allowlisted targets, dry-run diffs, regression reports, and rollback artifacts are designed. | +| Adaptive Self-Iteration | `covered` | Full adaptive loop includes proposal, approval, patch application, regression evidence, and rollback metadata. | Proposal-only adaptation with explicit input source, redaction, allowlisted write targets, approval ledger, regression report, and rollback plan. | Start with policy and read-only scan tests; do not read shell history or private logs unless the user provides an explicit source path. | These extension tracks come from the user-supplied 2.0 reference plan. They are tracked separately from the formal Skill OS blueprint so the report can distinguish landed local architecture from planned explainer/adaptor evolution. @@ -225,7 +225,6 @@ These extension tracks come from the user-supplied 2.0 reference plan. They are ### Adaptive Self-Iteration - objective: Local-first preference memory, repeated-signal extraction, adaptation proposals, approval, patch application, regression evidence, and rollback. -- status: `partial` -- existing evidence: `references/autonomous-adaptation.md`, `references/user-memory-policy.md`, `schemas/adaptation-proposal.schema.json`, `scripts/summarize_user_signals.py`, `scripts/propose_adaptation.py`, `tests/verify_adaptation_safety.py`, `reports/user_patterns.json`, `reports/adaptation_proposals.json`, `reports/iteration-directions.md`, `reports/adoption_drift_report.md` -- missing evidence: `scripts/apply_adaptation.py`, `reports/adaptation_approval_ledger.json`, `reports/adaptation_regression_report.json` -- next action: Add adapt-apply only after approval ledger, allowlisted targets, dry-run diffs, regression reports, and rollback artifacts are designed. +- status: `covered` +- existing evidence: `references/autonomous-adaptation.md`, `references/user-memory-policy.md`, `schemas/adaptation-proposal.schema.json`, `scripts/summarize_user_signals.py`, `scripts/propose_adaptation.py`, `tests/verify_adaptation_safety.py`, `scripts/apply_adaptation.py`, `reports/adaptation_approval_ledger.json`, `reports/adaptation_regression_report.json`, `reports/user_patterns.json`, `reports/adaptation_proposals.json`, `reports/iteration-directions.md`, `reports/adoption_drift_report.md` +- next action: Start with policy and read-only scan tests; do not read shell history or private logs unless the user provides an explicit source path. diff --git a/schemas/adaptation-proposal.schema.json b/schemas/adaptation-proposal.schema.json index 4e6017d..876e84a 100644 --- a/schemas/adaptation-proposal.schema.json +++ b/schemas/adaptation-proposal.schema.json @@ -39,7 +39,7 @@ "minimum": 0 }, "apply_supported": { - "const": false + "type": "boolean" } }, "additionalProperties": true @@ -51,7 +51,8 @@ "approval_required", "writes_repository_files", "allowlisted_targets_required", - "rollback_required_for_apply" + "rollback_required_for_apply", + "apply_command_available" ], "properties": { "proposal_only": { @@ -68,6 +69,9 @@ }, "rollback_required_for_apply": { "const": true + }, + "apply_command_available": { + "type": "boolean" } }, "additionalProperties": true diff --git a/scripts/apply_adaptation.py b/scripts/apply_adaptation.py new file mode 100644 index 0000000..a0bb6b5 --- /dev/null +++ b/scripts/apply_adaptation.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +"""Apply an approved adaptation patch with allowlisted targets and regression evidence.""" + +import argparse +import hashlib +import json +import os +import shlex +import subprocess +import sys +from datetime import date, datetime, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT_INTERFACE = "cli" +SCRIPT_INTERFACE_REASON = "Approval-gated adaptive patch application with dry-run, allowlist, regression, and rollback evidence." + +BLOCKED_PATH_PARTS = {".git", "__pycache__", ".pytest_cache", "dist"} + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def display_path(path: Path, skill_dir: Path) -> str: + try: + return str(path.resolve().relative_to(skill_dir.resolve())) + except ValueError: + return str(path) + + +def resolve_path(skill_dir: Path, value: str) -> Path: + path = Path(value) + return path if path.is_absolute() else skill_dir / path + + +def load_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def normalize_patch_path(raw: str) -> str | None: + token = raw.strip().split("\t", 1)[0].split(" ", 1)[0] + if token == "/dev/null": + return None + if token.startswith("a/") or token.startswith("b/"): + token = token[2:] + path = Path(token) + if path.is_absolute() or ".." in path.parts or any(part in BLOCKED_PATH_PARTS for part in path.parts): + raise ValueError(f"Unsafe patch path: {raw}") + if not token or token == ".": + raise ValueError(f"Empty patch path: {raw}") + return token + + +def patch_target_files(patch_text: str) -> list[str]: + targets: set[str] = set() + for line in patch_text.splitlines(): + if line.startswith("--- ") or line.startswith("+++ "): + raw = line[4:].strip() + path = normalize_patch_path(raw) + if path: + targets.add(path) + return sorted(targets) + + +def approved_entries(ledger: dict[str, Any]) -> list[dict[str, Any]]: + entries = ledger.get("entries") + return [item for item in entries if isinstance(item, dict)] if isinstance(entries, list) else [] + + +def find_proposal(proposals: dict[str, Any], proposal_id: str) -> dict[str, Any]: + for item in proposals.get("proposals", []): + if isinstance(item, dict) and item.get("proposal_id") == proposal_id: + return item + return {} + + +def find_approval(ledger: dict[str, Any], proposal_id: str) -> dict[str, Any]: + for item in approved_entries(ledger): + if item.get("proposal_id") == proposal_id and item.get("decision") == "approved": + return item + return {} + + +def parse_date(value: str) -> date | None: + if not value: + return None + try: + return date.fromisoformat(value[:10]) + except ValueError: + return None + + +def validate_approval(approval: dict[str, Any], today: date) -> list[str]: + failures = [] + required = ["reviewer", "reason", "approved_at", "patch_sha256", "target_files", "verification_commands", "rollback_plan"] + for key in required: + if not approval.get(key): + failures.append(f"Approval entry missing required field: {key}") + expires_at = parse_date(str(approval.get("expires_at", ""))) + if expires_at and expires_at < today: + failures.append(f"Approval entry is expired: {approval.get('expires_at')}") + if not isinstance(approval.get("target_files"), list): + failures.append("Approval target_files must be a list.") + if not isinstance(approval.get("verification_commands"), list): + failures.append("Approval verification_commands must be a list.") + return failures + + +def safe_command(command: str) -> tuple[bool, list[str], str]: + try: + parts = shlex.split(command) + except ValueError as exc: + return False, [], f"cannot parse command: {exc}" + if not parts: + return False, [], "empty command" + if parts[0] == "make" and len(parts) == 2 and all(ch.isalnum() or ch in {"-", "_"} for ch in parts[1]): + return True, parts, "make target" + if parts[0] in {"python3", sys.executable} and len(parts) >= 2: + script = Path(parts[1]) + if not script.is_absolute() and script.parts and script.parts[0] in {"tests", "scripts"} and script.suffix == ".py": + return True, parts, "local python verifier" + return False, parts, "command is not in the safe regression allowlist" + + +def run_command(command: str, skill_dir: Path) -> dict[str, Any]: + allowed, parts, reason = safe_command(command) + if not allowed: + return {"command": command, "ok": False, "returncode": None, "stdout": "", "stderr": reason} + proc = subprocess.run(parts, cwd=skill_dir, capture_output=True, text=True) + return { + "command": command, + "ok": proc.returncode == 0, + "returncode": proc.returncode, + "stdout": proc.stdout[-4000:], + "stderr": proc.stderr[-4000:], + } + + +def git_apply_check(skill_dir: Path, patch_file: Path) -> dict[str, Any]: + env = dict(os.environ) + env["GIT_CEILING_DIRECTORIES"] = str(skill_dir.parent) + proc = subprocess.run(["git", "apply", "--check", str(patch_file)], cwd=skill_dir, capture_output=True, text=True, env=env) + return {"ok": proc.returncode == 0, "returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr} + + +def git_apply(skill_dir: Path, patch_file: Path) -> dict[str, Any]: + env = dict(os.environ) + env["GIT_CEILING_DIRECTORIES"] = str(skill_dir.parent) + proc = subprocess.run(["git", "apply", str(patch_file)], cwd=skill_dir, capture_output=True, text=True, env=env) + return {"ok": proc.returncode == 0, "returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr} + + +def git_apply_reverse(skill_dir: Path, patch_file: Path) -> dict[str, Any]: + env = dict(os.environ) + env["GIT_CEILING_DIRECTORIES"] = str(skill_dir.parent) + proc = subprocess.run(["git", "apply", "-R", str(patch_file)], cwd=skill_dir, capture_output=True, text=True, env=env) + return {"ok": proc.returncode == 0, "returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr} + + +def empty_approval_ledger(generated_at: str) -> dict[str, Any]: + return { + "schema_version": "1.0", + "ok": True, + "generated_at": generated_at, + "summary": { + "approval_count": 0, + "active_approval_count": 0, + "applied_count": 0, + "rollback_count": 0, + }, + "approval_contract": { + "approval_required": True, + "patch_sha256_required": True, + "allowlisted_targets_required": True, + "dry_run_default": True, + "writes_repository_files_only_with_apply": True, + "rollback_required": True, + }, + "entries": [], + } + + +def empty_regression_report(skill_dir: Path, generated_at: str) -> dict[str, Any]: + return { + "schema_version": "1.0", + "ok": True, + "generated_at": generated_at, + "skill_dir": display_path(skill_dir, skill_dir), + "summary": { + "apply_supported": True, + "attempt_count": 0, + "applied_count": 0, + "dry_run_count": 0, + "rollback_count": 0, + "regression_run_count": 0, + "regression_pass_count": 0, + "failure_count": 0, + }, + "apply_contract": { + "approval_required": True, + "patch_sha256_required": True, + "allowlisted_targets_required": True, + "dry_run_default": True, + "writes_repository_files_only_with_apply": True, + "rollback_required": True, + "safe_regression_commands_only": True, + "rollback_on_failure_default": True, + }, + "attempts": [], + "failures": [], + "artifacts": { + "json": "reports/adaptation_regression_report.json", + "approval_ledger": "reports/adaptation_approval_ledger.json", + }, + } + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def render_markdown(report: dict[str, Any]) -> str: + summary = report["summary"] + lines = [ + "# Adaptation Regression Report", + "", + f"- generated_at: `{report['generated_at']}`", + f"- apply_supported: `{str(summary['apply_supported']).lower()}`", + f"- attempts: `{summary['attempt_count']}`", + f"- applied: `{summary['applied_count']}`", + f"- dry runs: `{summary['dry_run_count']}`", + f"- rollbacks: `{summary.get('rollback_count', 0)}`", + f"- failures: `{summary['failure_count']}`", + "", + "This report proves the adaptive apply harness behavior. It does not count proposals as applied changes.", + "", + ] + for attempt in report.get("attempts", []): + lines.extend( + [ + f"## {attempt['proposal_id']}", + "", + f"- mode: `{attempt['mode']}`", + f"- status: `{attempt['status']}`", + f"- patch: `{attempt['patch']}`", + f"- patch sha256: `{attempt['patch_sha256']}`", + f"- targets: `{', '.join(attempt['target_files'])}`", + f"- rollback: {attempt['rollback']['plan']}", + ] + ) + if attempt.get("regression_runs"): + lines.append("- regression:") + lines.extend(f" - `{item['command']}`: `{str(item['ok']).lower()}`" for item in attempt["regression_runs"]) + lines.append("") + if report.get("failures"): + lines.extend(["## Failures", ""]) + lines.extend(f"- {failure}" for failure in report["failures"]) + return "\n".join(lines).rstrip() + "\n" + + +def build_report(args: argparse.Namespace) -> dict[str, Any]: + skill_dir = Path(args.skill_dir).resolve() + generated_at = args.generated_at or utc_now() + ledger_path = resolve_path(skill_dir, args.approval_ledger) + output_json = resolve_path(skill_dir, args.output_json) + output_md = resolve_path(skill_dir, args.output_md) + if args.write_template: + ledger = empty_approval_ledger(generated_at) + report = empty_regression_report(skill_dir, generated_at) + write_json(ledger_path, ledger) + write_json(output_json, report) + output_md.parent.mkdir(parents=True, exist_ok=True) + output_md.write_text(render_markdown(report), encoding="utf-8") + return report + + failures: list[str] = [] + proposals_path = resolve_path(skill_dir, args.proposals_json) + patch_path = resolve_path(skill_dir, args.patch_file) if args.patch_file else Path("") + proposals = load_json(proposals_path) + ledger = load_json(ledger_path) + if not proposals: + failures.append(f"Proposal report missing or invalid: {display_path(proposals_path, skill_dir)}") + if not ledger: + failures.append(f"Approval ledger missing or invalid: {display_path(ledger_path, skill_dir)}") + if not args.proposal_id: + failures.append("--proposal-id is required unless --write-template is used.") + if not args.patch_file or not patch_path.exists(): + failures.append("--patch-file must point to an existing unified diff.") + + today = parse_date(str(args.today or date.today().isoformat())) + if today is None: + failures.append("--today must be an ISO date when provided.") + + proposal = find_proposal(proposals, args.proposal_id) if not failures else {} + approval = find_approval(ledger, args.proposal_id) if not failures else {} + if not failures and not proposal: + failures.append(f"Proposal id is not present in proposal report: {args.proposal_id}") + if not failures and not approval: + failures.append(f"Proposal id is not approved in the ledger: {args.proposal_id}") + if approval: + failures.extend(validate_approval(approval, today or date.today())) + + patch_sha = sha256_file(patch_path) if patch_path.exists() else "" + target_files: list[str] = [] + if not failures: + try: + target_files = patch_target_files(patch_path.read_text(encoding="utf-8", errors="replace")) + except ValueError as exc: + failures.append(str(exc)) + if not failures: + approved_targets = set(str(item) for item in approval.get("target_files", [])) + proposal_targets = set(str(item) for item in proposal.get("target_files", [])) + patch_targets = set(target_files) + if patch_sha != approval.get("patch_sha256"): + failures.append("Patch sha256 does not match approval ledger.") + if not patch_targets: + failures.append("Patch does not declare any target files.") + if not patch_targets <= approved_targets: + failures.append("Patch touches files outside approval target_files.") + if not patch_targets <= proposal_targets: + failures.append("Patch touches files outside proposal target_files.") + + check = git_apply_check(skill_dir, patch_path) if not failures else {"ok": False, "returncode": None, "stdout": "", "stderr": ""} + if not failures and not check["ok"]: + failures.append(f"git apply --check failed: {check['stderr'].strip()}") + + mode = "apply" if args.apply else "dry-run" + applied = False + apply_result = {"ok": False, "returncode": None, "stdout": "", "stderr": "not requested"} + if not failures and args.apply: + apply_result = git_apply(skill_dir, patch_path) + applied = bool(apply_result["ok"]) + if not applied: + failures.append(f"git apply failed: {apply_result['stderr'].strip()}") + + regression_runs: list[dict[str, Any]] = [] + if not failures and args.run_verification: + for command in approval.get("verification_commands", []): + regression_runs.append(run_command(str(command), skill_dir)) + if any(not item["ok"] for item in regression_runs): + failures.append("One or more regression commands failed or were not allowed.") + + rollback_result = {"ok": None, "returncode": None, "stdout": "", "stderr": "not needed"} + rolled_back = False + if applied and failures and args.rollback_on_failure: + rollback_result = git_apply_reverse(skill_dir, patch_path) + rolled_back = bool(rollback_result["ok"]) + if not rolled_back: + failures.append(f"Automatic rollback failed: {rollback_result['stderr'].strip()}") + + attempt = { + "proposal_id": args.proposal_id or "", + "mode": mode, + "status": "failed-rolled-back" if rolled_back else ("failed" if failures else ("applied" if applied else "dry-run-pass")), + "patch": display_path(patch_path, skill_dir) if args.patch_file else "", + "patch_sha256": patch_sha, + "target_files": target_files, + "approval": { + "reviewer": approval.get("reviewer", ""), + "approved_at": approval.get("approved_at", ""), + "expires_at": approval.get("expires_at", ""), + }, + "git_apply_check": check, + "git_apply": apply_result, + "regression_runs": regression_runs, + "rollback_result": rollback_result, + "rollback": { + "plan": approval.get("rollback_plan", ""), + "command": f"git apply -R {display_path(patch_path, skill_dir)}" if args.patch_file else "", + }, + } + report = { + "schema_version": "1.0", + "ok": not failures, + "generated_at": generated_at, + "skill_dir": display_path(skill_dir, skill_dir), + "summary": { + "apply_supported": True, + "attempt_count": 1, + "applied_count": 1 if applied and not rolled_back else 0, + "dry_run_count": 0 if args.apply else 1, + "rollback_count": 1 if rolled_back else 0, + "regression_run_count": len(regression_runs), + "regression_pass_count": sum(1 for item in regression_runs if item["ok"]), + "failure_count": len(failures), + }, + "apply_contract": { + "approval_required": True, + "patch_sha256_required": True, + "allowlisted_targets_required": True, + "dry_run_default": True, + "writes_repository_files_only_with_apply": True, + "rollback_required": True, + "safe_regression_commands_only": True, + "rollback_on_failure_default": True, + }, + "attempts": [attempt], + "failures": failures, + "artifacts": { + "json": display_path(output_json, skill_dir), + "markdown": display_path(output_md, skill_dir), + "approval_ledger": display_path(ledger_path, skill_dir), + }, + } + write_json(output_json, report) + output_md.parent.mkdir(parents=True, exist_ok=True) + output_md.write_text(render_markdown(report), encoding="utf-8") + return report + + +def main() -> None: + parser = argparse.ArgumentParser(description="Apply an approved adaptation patch with review and regression evidence.") + parser.add_argument("skill_dir", nargs="?", default=".") + parser.add_argument("--proposal-id") + parser.add_argument("--patch-file") + parser.add_argument("--proposals-json", default="reports/adaptation_proposals.json") + parser.add_argument("--approval-ledger", default="reports/adaptation_approval_ledger.json") + parser.add_argument("--output-json", default="reports/adaptation_regression_report.json") + parser.add_argument("--output-md", default="reports/adaptation_regression_report.md") + parser.add_argument("--generated-at") + parser.add_argument("--today") + parser.add_argument("--write-template", action="store_true") + parser.add_argument("--apply", action="store_true", help="Write the patch after every approval and allowlist check passes.") + parser.add_argument("--run-verification", action="store_true") + parser.add_argument( + "--no-rollback-on-failure", + dest="rollback_on_failure", + action="store_false", + help="Leave an applied patch in place if verification fails. Default is to reverse the patch.", + ) + parser.set_defaults(rollback_on_failure=True) + args = parser.parse_args() + + report = build_report(args) + print(json.dumps(report, ensure_ascii=False, indent=2)) + raise SystemExit(0 if report["ok"] else 2) + + +if __name__ == "__main__": + main() diff --git a/scripts/propose_adaptation.py b/scripts/propose_adaptation.py index b485df0..c8d0701 100644 --- a/scripts/propose_adaptation.py +++ b/scripts/propose_adaptation.py @@ -170,7 +170,7 @@ def build_report(skill_dir: Path, patterns_json: Path, generated_at: str) -> dic "summary": { "pattern_count": len(patterns), "proposal_count": len(proposals), - "apply_supported": False, + "apply_supported": (ROOT / "scripts" / "apply_adaptation.py").exists(), "failure_count": len(failures), }, "proposal_contract": { @@ -179,7 +179,7 @@ def build_report(skill_dir: Path, patterns_json: Path, generated_at: str) -> dic "writes_repository_files": False, "allowlisted_targets_required": True, "rollback_required_for_apply": True, - "apply_command_available": False, + "apply_command_available": (ROOT / "scripts" / "apply_adaptation.py").exists(), }, "proposals": proposals, "failures": failures, diff --git a/scripts/yao.py b/scripts/yao.py index 9676e58..50a2538 100644 --- a/scripts/yao.py +++ b/scripts/yao.py @@ -10,7 +10,7 @@ from yao_cli_config import ( resolve_promotion_target, resolve_target, ) -from yao_cli_adaptation_commands import command_adapt_propose, command_adapt_scan +from yao_cli_adaptation_commands import command_adapt_apply, command_adapt_propose, command_adapt_scan from yao_cli_create_commands import command_init, command_quickstart from yao_cli_distribution_commands import ( command_compile_skill, diff --git a/scripts/yao_cli_adaptation_commands.py b/scripts/yao_cli_adaptation_commands.py index 7d83095..058c3f9 100644 --- a/scripts/yao_cli_adaptation_commands.py +++ b/scripts/yao_cli_adaptation_commands.py @@ -1,4 +1,4 @@ -"""Adaptive proposal command handlers for the Yao CLI.""" +"""Adaptive scan, proposal, and apply command handlers for the Yao CLI.""" import argparse import json @@ -8,7 +8,7 @@ from yao_cli_runtime import run_script SCRIPT_INTERFACE = "internal-module" -SCRIPT_INTERFACE_REASON = "Imported by yao.py to keep adaptive scan/proposal command handlers outside the thin CLI orchestrator." +SCRIPT_INTERFACE_REASON = "Imported by yao.py to keep adaptive scan/proposal/apply command handlers outside the thin CLI orchestrator." def command_adapt_scan(args: argparse.Namespace) -> int: @@ -43,3 +43,35 @@ def command_adapt_propose(args: argparse.Namespace) -> int: result = run_script("propose_adaptation.py", cmd) print(json.dumps(result["payload"] if result["payload"] is not None else result, ensure_ascii=False, indent=2)) return 0 if result["ok"] else 2 + + +def command_adapt_apply(args: argparse.Namespace) -> int: + skill_dir = str(Path(args.skill_dir).resolve()) + cmd = [skill_dir] + if args.proposal_id: + cmd.extend(["--proposal-id", args.proposal_id]) + if args.patch_file: + cmd.extend(["--patch-file", args.patch_file]) + if args.proposals_json: + cmd.extend(["--proposals-json", args.proposals_json]) + if args.approval_ledger: + cmd.extend(["--approval-ledger", args.approval_ledger]) + if args.output_json: + cmd.extend(["--output-json", args.output_json]) + if args.output_md: + cmd.extend(["--output-md", args.output_md]) + if args.generated_at: + cmd.extend(["--generated-at", args.generated_at]) + if args.today: + cmd.extend(["--today", args.today]) + if args.write_template: + cmd.append("--write-template") + if args.apply: + cmd.append("--apply") + if args.run_verification: + cmd.append("--run-verification") + if not args.rollback_on_failure: + cmd.append("--no-rollback-on-failure") + result = run_script("apply_adaptation.py", cmd) + print(json.dumps(result["payload"] if result["payload"] is not None else result, ensure_ascii=False, indent=2)) + return 0 if result["ok"] else 2 diff --git a/scripts/yao_cli_parser.py b/scripts/yao_cli_parser.py index 11c5aff..158e7b6 100644 --- a/scripts/yao_cli_parser.py +++ b/scripts/yao_cli_parser.py @@ -416,6 +416,31 @@ def build_parser(command_handlers: dict[str, Callable[[argparse.Namespace], int] adapt_propose_cmd.add_argument("--generated-at") adapt_propose_cmd.set_defaults(func=_handler(command_handlers, "command_adapt_propose")) + adapt_apply_cmd = subparsers.add_parser( + "adapt-apply", + help="Dry-run or apply an approved adaptation patch with allowlist, regression, and rollback evidence.", + ) + adapt_apply_cmd.add_argument("skill_dir", nargs="?", default=".") + adapt_apply_cmd.add_argument("--proposal-id") + adapt_apply_cmd.add_argument("--patch-file") + adapt_apply_cmd.add_argument("--proposals-json") + adapt_apply_cmd.add_argument("--approval-ledger") + adapt_apply_cmd.add_argument("--output-json") + adapt_apply_cmd.add_argument("--output-md") + adapt_apply_cmd.add_argument("--generated-at") + adapt_apply_cmd.add_argument("--today") + adapt_apply_cmd.add_argument("--write-template", action="store_true") + adapt_apply_cmd.add_argument("--apply", action="store_true") + adapt_apply_cmd.add_argument("--run-verification", action="store_true") + adapt_apply_cmd.add_argument( + "--no-rollback-on-failure", + dest="rollback_on_failure", + action="store_false", + help="Leave an applied patch in place if verification fails. Default is to reverse the patch.", + ) + adapt_apply_cmd.set_defaults(rollback_on_failure=True) + adapt_apply_cmd.set_defaults(func=_handler(command_handlers, "command_adapt_apply")) + adoption_drift_cmd = subparsers.add_parser( "adoption-drift", help="Render local-first metadata-only adoption and drift telemetry for a skill package.", diff --git a/tests/verify_adaptation_safety.py b/tests/verify_adaptation_safety.py index 233299b..e7175e3 100644 --- a/tests/verify_adaptation_safety.py +++ b/tests/verify_adaptation_safety.py @@ -3,6 +3,7 @@ import json import shutil import subprocess import sys +import hashlib from pathlib import Path @@ -10,6 +11,7 @@ ROOT = Path(__file__).resolve().parent.parent CLI = ROOT / "scripts" / "yao.py" SCAN_SCRIPT = ROOT / "scripts" / "summarize_user_signals.py" PROPOSE_SCRIPT = ROOT / "scripts" / "propose_adaptation.py" +APPLY_SCRIPT = ROOT / "scripts" / "apply_adaptation.py" TMP = ROOT / "tests" / "tmp_adaptation_safety" @@ -22,6 +24,10 @@ def run_script(*args: str) -> subprocess.CompletedProcess[str]: ) +def sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + def main() -> None: shutil.rmtree(TMP, ignore_errors=True) skill_dir = TMP / "adaptive-demo-skill" @@ -81,9 +87,10 @@ def main() -> None: assert propose_proc.returncode == 0, propose_proc.stderr proposal_payload = json.loads(propose_proc.stdout) assert proposal_payload["ok"], proposal_payload - assert proposal_payload["summary"]["apply_supported"] is False, proposal_payload + assert proposal_payload["summary"]["apply_supported"] is True, proposal_payload assert proposal_payload["proposal_contract"]["proposal_only"] is True, proposal_payload assert proposal_payload["proposal_contract"]["writes_repository_files"] is False, proposal_payload + assert proposal_payload["proposal_contract"]["apply_command_available"] is True, proposal_payload assert proposal_payload["summary"]["proposal_count"] >= 3, proposal_payload assert all(item["status"] == "proposal-only" for item in proposal_payload["proposals"]), proposal_payload assert all(item["requires_approval"] is True for item in proposal_payload["proposals"]), proposal_payload @@ -92,10 +99,17 @@ def main() -> None: any("tests/verify_adaptation_safety.py" in command for command in item["verification_commands"]) for item in proposal_payload["proposals"] ), proposal_payload - assert not (skill_dir / "scripts" / "apply_adaptation.py").exists(), skill_dir assert (reports_dir / "adaptation_proposals.json").exists(), reports_dir assert (reports_dir / "adaptation_proposals.md").exists(), reports_dir + template_proc = run_script(str(APPLY_SCRIPT), str(skill_dir), "--write-template", "--generated-at", "2026-06-15T00:00:00Z") + assert template_proc.returncode == 0, template_proc.stderr + template_payload = json.loads(template_proc.stdout) + assert template_payload["summary"]["apply_supported"] is True, template_payload + assert template_payload["summary"]["attempt_count"] == 0, template_payload + assert (reports_dir / "adaptation_approval_ledger.json").exists(), reports_dir + assert (reports_dir / "adaptation_regression_report.json").exists(), reports_dir + missing_source_proc = run_script(str(SCAN_SCRIPT), str(skill_dir)) assert missing_source_proc.returncode != 0, missing_source_proc @@ -148,8 +162,180 @@ def main() -> None: assert cli_propose.returncode == 0, cli_propose.stderr cli_proposal_payload = json.loads(cli_propose.stdout) assert cli_proposal_payload["summary"]["proposal_count"] >= 3, cli_proposal_payload + assert cli_proposal_payload["summary"]["apply_supported"] is True, cli_proposal_payload assert cli_proposal_payload["proposal_contract"]["proposal_only"] is True, cli_proposal_payload assert cli_proposal_payload["proposal_contract"]["writes_repository_files"] is False, cli_proposal_payload + assert cli_proposal_payload["proposal_contract"]["apply_command_available"] is True, cli_proposal_payload + + policy_path = cli_skill_dir / "references" / "user-memory-policy.md" + policy_path.parent.mkdir(parents=True, exist_ok=True) + policy_path.write_text("old policy\n", encoding="utf-8") + verifier = cli_skill_dir / "tests" / "check_policy.py" + verifier.parent.mkdir(parents=True, exist_ok=True) + verifier.write_text( + "from pathlib import Path\n" + "assert 'approved adaptive note' in Path('references/user-memory-policy.md').read_text(encoding='utf-8')\n", + encoding="utf-8", + ) + approval_proposal = next( + item for item in cli_proposal_payload["proposals"] if item["pattern_id"] == "approval_safety" + ) + patch_text = ( + "diff --git a/references/user-memory-policy.md b/references/user-memory-policy.md\n" + "--- a/references/user-memory-policy.md\n" + "+++ b/references/user-memory-policy.md\n" + "@@ -1 +1,2 @@\n" + " old policy\n" + "+approved adaptive note\n" + ) + patch_path = TMP / "approved-adaptation.patch" + patch_path.write_text(patch_text, encoding="utf-8") + approval_ledger = { + "schema_version": "1.0", + "ok": True, + "generated_at": "2026-06-15T00:00:00Z", + "approval_contract": { + "approval_required": True, + "patch_sha256_required": True, + "allowlisted_targets_required": True, + "dry_run_default": True, + "writes_repository_files_only_with_apply": True, + "rollback_required": True, + }, + "entries": [ + { + "proposal_id": approval_proposal["proposal_id"], + "decision": "approved", + "reviewer": "qa-reviewer", + "reason": "Fixture validates approval-gated adaptive apply.", + "approved_at": "2026-06-15", + "expires_at": "2026-12-31", + "patch_sha256": sha256_text(patch_text), + "target_files": ["references/user-memory-policy.md"], + "verification_commands": ["python3 tests/check_policy.py"], + "rollback_plan": "git apply -R approved-adaptation.patch", + } + ], + } + ledger_path = cli_skill_dir / "reports" / "adaptation_approval_ledger.json" + ledger_path.write_text(json.dumps(approval_ledger, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + dry_run = run_script( + str(CLI), + "adapt-apply", + str(cli_skill_dir), + "--proposal-id", + approval_proposal["proposal_id"], + "--patch-file", + str(patch_path), + "--generated-at", + "2026-06-15T00:00:00Z", + "--today", + "2026-06-15", + ) + assert dry_run.returncode == 0, dry_run.stderr + dry_payload = json.loads(dry_run.stdout) + assert dry_payload["summary"]["dry_run_count"] == 1, dry_payload + assert dry_payload["summary"]["applied_count"] == 0, dry_payload + assert "approved adaptive note" not in policy_path.read_text(encoding="utf-8"), policy_path + + apply_proc = run_script( + str(CLI), + "adapt-apply", + str(cli_skill_dir), + "--proposal-id", + approval_proposal["proposal_id"], + "--patch-file", + str(patch_path), + "--generated-at", + "2026-06-15T00:00:00Z", + "--today", + "2026-06-15", + "--apply", + "--run-verification", + ) + assert apply_proc.returncode == 0, apply_proc.stderr + apply_payload = json.loads(apply_proc.stdout) + assert apply_payload["summary"]["applied_count"] == 1, apply_payload + assert apply_payload["summary"]["regression_run_count"] == 1, apply_payload + assert apply_payload["summary"]["regression_pass_count"] == 1, apply_payload + assert "approved adaptive note" in policy_path.read_text(encoding="utf-8"), policy_path + + policy_path.write_text("old policy\n", encoding="utf-8") + failing_verifier = cli_skill_dir / "tests" / "failing_policy.py" + failing_verifier.write_text( + "from pathlib import Path\n" + "assert 'impossible verifier token' in Path('references/user-memory-policy.md').read_text(encoding='utf-8')\n", + encoding="utf-8", + ) + rollback_patch = ( + "diff --git a/references/user-memory-policy.md b/references/user-memory-policy.md\n" + "--- a/references/user-memory-policy.md\n" + "+++ b/references/user-memory-policy.md\n" + "@@ -1 +1,2 @@\n" + " old policy\n" + "+rolled back adaptive note\n" + ) + rollback_patch_path = TMP / "rollback-adaptation.patch" + rollback_patch_path.write_text(rollback_patch, encoding="utf-8") + approval_ledger["entries"][0]["patch_sha256"] = sha256_text(rollback_patch) + approval_ledger["entries"][0]["verification_commands"] = ["python3 tests/failing_policy.py"] + ledger_path.write_text(json.dumps(approval_ledger, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + rollback_proc = run_script( + str(CLI), + "adapt-apply", + str(cli_skill_dir), + "--proposal-id", + approval_proposal["proposal_id"], + "--patch-file", + str(rollback_patch_path), + "--output-json", + str(cli_skill_dir / "reports" / "rollback_adaptation_regression.json"), + "--output-md", + str(cli_skill_dir / "reports" / "rollback_adaptation_regression.md"), + "--today", + "2026-06-15", + "--apply", + "--run-verification", + ) + assert rollback_proc.returncode == 2, rollback_proc.stdout + rollback_payload = json.loads(rollback_proc.stdout) + assert rollback_payload["summary"]["applied_count"] == 0, rollback_payload + assert rollback_payload["summary"]["rollback_count"] == 1, rollback_payload + assert rollback_payload["attempts"][0]["status"] == "failed-rolled-back", rollback_payload + assert rollback_payload["attempts"][0]["rollback_result"]["ok"] is True, rollback_payload + assert "rolled back adaptive note" not in policy_path.read_text(encoding="utf-8"), policy_path + assert policy_path.read_text(encoding="utf-8") == "old policy\n", policy_path + + unsafe_patch = ( + "diff --git a/README.md b/README.md\n" + "--- a/README.md\n" + "+++ b/README.md\n" + "@@ -1 +1,2 @@\n" + " old\n" + "+unsafe\n" + ) + unsafe_patch_path = TMP / "unsafe-adaptation.patch" + unsafe_patch_path.write_text(unsafe_patch, encoding="utf-8") + approval_ledger["entries"][0]["patch_sha256"] = sha256_text(unsafe_patch) + ledger_path.write_text(json.dumps(approval_ledger, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + unsafe_proc = run_script( + str(CLI), + "adapt-apply", + str(cli_skill_dir), + "--proposal-id", + approval_proposal["proposal_id"], + "--patch-file", + str(unsafe_patch_path), + "--output-json", + str(cli_skill_dir / "reports" / "unsafe_adaptation_regression.json"), + "--output-md", + str(cli_skill_dir / "reports" / "unsafe_adaptation_regression.md"), + "--today", + "2026-06-15", + ) + assert unsafe_proc.returncode == 2, unsafe_proc.stdout + unsafe_payload = json.loads(unsafe_proc.stdout) + assert any("outside approval target_files" in item for item in unsafe_payload["failures"]), unsafe_payload print(json.dumps({"ok": True}, ensure_ascii=False, indent=2)) diff --git a/tests/verify_skill_os2_coverage.py b/tests/verify_skill_os2_coverage.py index 62203c6..c01d96e 100644 --- a/tests/verify_skill_os2_coverage.py +++ b/tests/verify_skill_os2_coverage.py @@ -45,10 +45,10 @@ def main() -> None: assert summary["warn_count"] == 0, summary assert summary["missing_count"] == 0, summary assert summary["extension_track_count"] == 2, summary - assert summary["extension_partial_count"] == 1, summary + assert summary["extension_partial_count"] == 0, summary assert summary["extension_planned_count"] == 0, summary - assert summary["extension_covered_count"] == 1, summary - assert summary["adaptive_extension_ready"] is False, summary + assert summary["extension_covered_count"] == 2, summary + assert summary["adaptive_extension_ready"] is True, summary assert summary["local_blueprint_ready"] is True, summary assert summary["public_world_class_ready"] is False, summary assert summary["world_class_evidence_pending_count"] == 4, summary @@ -91,7 +91,7 @@ def main() -> None: assert payload["source_blueprint"]["reference_extension_count"] == 2, payload extension_tracks = {item["key"]: item for item in payload["reference_extension_tracks"]} assert extension_tracks["skill-interpretation-report"]["status"] == "covered", extension_tracks - assert extension_tracks["adaptive-self-iteration"]["status"] == "partial", extension_tracks + assert extension_tracks["adaptive-self-iteration"]["status"] == "covered", extension_tracks assert any( entry["path"] == "reports/skill-overview.html" and entry["exists"] for entry in extension_tracks["skill-interpretation-report"]["evidence"] @@ -109,7 +109,15 @@ def main() -> None: for entry in extension_tracks["adaptive-self-iteration"]["evidence"] ), extension_tracks["adaptive-self-iteration"] assert any( - entry["path"] == "scripts/apply_adaptation.py" and not entry["exists"] + entry["path"] == "scripts/apply_adaptation.py" and entry["exists"] + for entry in extension_tracks["adaptive-self-iteration"]["evidence"] + ), extension_tracks["adaptive-self-iteration"] + assert any( + entry["path"] == "reports/adaptation_approval_ledger.json" and entry["exists"] + for entry in extension_tracks["adaptive-self-iteration"]["evidence"] + ), extension_tracks["adaptive-self-iteration"] + assert any( + entry["path"] == "reports/adaptation_regression_report.json" and entry["exists"] for entry in extension_tracks["adaptive-self-iteration"]["evidence"] ), extension_tracks["adaptive-self-iteration"] assert "Close the four world-class evidence ledger entries" in payload["next_highest_leverage"][0], payload @@ -119,8 +127,8 @@ def main() -> None: assert "Skill OS 2.0 Blueprint Coverage" in markdown, markdown assert "local blueprint ready: `true`" in markdown, markdown assert "public world-class ready: `false`" in markdown, markdown - assert "extension covered: `1`" in markdown, markdown - assert "extension partial: `1`" in markdown, markdown + assert "extension covered: `2`" in markdown, markdown + assert "extension partial: `0`" in markdown, markdown assert "## Core Modules" in markdown, markdown assert "## Recommended PR Coverage" in markdown, markdown assert "Evidence Consistency" in markdown, markdown diff --git a/tests/verify_yao_cli.py b/tests/verify_yao_cli.py index bd60398..c239024 100644 --- a/tests/verify_yao_cli.py +++ b/tests/verify_yao_cli.py @@ -82,7 +82,7 @@ def main() -> None: "quickstart skill-interpretation review-studio python-compat architecture-audit skill-os2-audit skill-os2-coverage " "world-class-evidence world-class-ledger world-class-intake world-class-submission-kit " "world-class-submission-review world-class-runbook world-class-claim-guard " - "benchmark-reproducibility evidence-consistency output-review-kit adapt-scan adapt-propose " + "benchmark-reproducibility evidence-consistency output-review-kit adapt-scan adapt-propose adapt-apply " "telemetry-import telemetry-emit telemetry-hooks --record-cli-telemetry" ).split() assert all(item in parser_help for item in expected_help), parser_help